diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index f4b1f8b4..3aa62025 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -328,7 +328,7 @@ const PROVIDERS = [ // openai: configured + used (drives the "Last used" sub-line and the status dot). { name: "openai", title: "OpenAI", needs_key: true, fields: [{ key: "api_key", label: "OpenAI API key", secret: true, required: true, help: "", placeholder: "sk-…" }], configured: true, values: {}, suggested_models: ["gpt-5.5"], key_set_at: "2026-06-12", last_used_at: Math.floor(Date.now() / 1000) - 7200 }, // anthropic: configured but never used ("Not used yet"). - { name: "anthropic", title: "Claude (Anthropic)", needs_key: true, fields: [{ key: "api_key", label: "API key", secret: true, required: true, help: "", placeholder: "sk-…" }], configured: true, values: {}, suggested_models: ["claude-opus-4-8"], key_set_at: null, last_used_at: null }, + { name: "anthropic", title: "Claude (Anthropic)", needs_key: true, fields: [{ key: "api_key", label: "API key", secret: true, required: true, help: "", placeholder: "sk-…" }, { key: "thinking_budget", label: "Extended thinking budget (tokens, optional)", secret: false, required: false, help: "Turns on Claude's extended thinking for every request.", placeholder: "e.g. 8192 — blank = off" }], configured: true, values: {}, suggested_models: ["claude-opus-4-8"], key_set_at: null, last_used_at: null }, // zai: an OpenAI-compatible vendor — unconfigured, with a prefilled editable endpoint + blurb. { name: "zai", title: "Z AI (GLM)", needs_key: true, blurb: "Uses Z AI's OpenAI-compatible API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Z AI API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Prefilled with Z AI's international endpoint.", placeholder: "https://api.z.ai/api/paas/v4", default: "https://api.z.ai/api/paas/v4" }], configured: false, values: {}, suggested_models: ["glm-5.2"], key_set_at: null, last_used_at: null }, // ollama: keyless local provider — "configured" without proving anything runs; the @@ -1240,7 +1240,12 @@ export async function mockApi(page: import("@playwright/test").Page) { prov.configured = true; prov.key_set_at = "2026-07-05"; } - if (b.fields?.base_url) prov.values = { ...prov.values, base_url: b.fields.base_url }; + // Backend parity: non-secret fields merge into `values` (empty clears them). + for (const [k, v] of Object.entries(b.fields || {})) { + if (k === "api_key") continue; + if (v) prov.values = { ...prov.values, [k]: v }; + else if (prov.values) delete prov.values[k]; + } return json({ ok: true, provider: b.name, recommended_model: null }); } // forget a provider's stored config (Settings ▸ Models "Remove key…"). diff --git a/surfaces/gui/e2e/provider-keys.spec.ts b/surfaces/gui/e2e/provider-keys.spec.ts index bf3e75ac..934e4d2a 100644 --- a/surfaces/gui/e2e/provider-keys.spec.ts +++ b/surfaces/gui/e2e/provider-keys.spec.ts @@ -52,3 +52,21 @@ test("a configured provider's form opens with the saved state, no plaintext key" await expect(page.getByTestId("set-field-api_key")).toHaveValue(""); await expect(page.getByTestId("set-field-api_key")).toHaveAttribute("placeholder", "••••••••"); }); + +test("non-secret extras blur-save on a configured provider (thinking budget)", async ({ + page, +}) => { + // Owner-hit 2026-07-23: typed a thinking budget, left Settings, value silently never + // saved — the Test button was the form's only save path. Blur now saves extras. + await openModels(page); + await page.getByTestId("set-provider-anthropic").click(); + const budget = page.getByTestId("set-field-thinking_budget"); + await budget.fill("8192"); + await budget.blur(); + await expect(page.getByTestId("set-field-saved-thinking_budget")).toBeVisible(); + + // Leave and come back: the value survived (served from the provider's stored values). + await page.getByTestId("set-back").click(); + await page.getByTestId("set-provider-anthropic").click(); + await expect(page.getByTestId("set-field-thinking_budget")).toHaveValue("8192"); +}); diff --git a/surfaces/gui/src/providers/ProviderSetup.tsx b/surfaces/gui/src/providers/ProviderSetup.tsx index 15e6d2c9..eef8c8b7 100644 --- a/surfaces/gui/src/providers/ProviderSetup.tsx +++ b/surfaces/gui/src/providers/ProviderSetup.tsx @@ -84,6 +84,11 @@ export interface ProviderSetupState { removeKey: () => Promise; cancelBackTimer: () => void; statusFor: (p: ProviderInfo, opts?: { lastUsed?: boolean }) => ReactNode; + // Blur-save for non-secret fields on an already-configured provider (the Test button is + // the KEY's save path; extras like anthropic's thinking_budget must not need a re-test — + // owner-hit 2026-07-23: the budget silently never saved). + saveField: (key: string) => Promise; + fieldSaved: string | null; // field key flashing "✓ Saved" } export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetupState { @@ -100,6 +105,9 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup // Unsaved per-provider input survives switching cards (owner complaint 2026-07-16). const [drafts, setDrafts] = useState>>({}); const backTimer = useRef(null); + // Which non-secret field just blur-saved (flashes "✓ Saved" in the input). + const [fieldSaved, setFieldSaved] = useState(null); + const fieldSavedTimer = useRef(null); const refreshProviders = () => getProviders() @@ -167,6 +175,26 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup return true; }; + // Blur-save for non-secret fields when the provider is already configured: extras like + // anthropic's thinking_budget must persist without a key re-test (owner-hit 2026-07-23 — + // typed, left Settings, silently never saved). Secrets keep the explicit Test-to-save + // contract; unconfigured providers save everything on their first Test. + const saveField = async (key: string) => { + if (!sel || !info?.configured) return; + const spec = info.fields.find((f) => f.key === key); + if (!spec || spec.secret) return; + const current = (fields[key] || "").trim(); + const stored = (info.values?.[key] || "").trim(); + if (current === stored) return; + const res = await setProvider(sel, { [key]: current }).catch(() => ({ ok: false })); + if (!res.ok) return; + await refreshProviders(); + opts?.onSaved?.(); + setFieldSaved(key); + if (fieldSavedTimer.current) window.clearTimeout(fieldSavedTimer.current); + fieldSavedTimer.current = window.setTimeout(() => setFieldSaved(null), 1400); + }; + // Settings-only: forget the stored key; the card reverts to "Not set up". const removeKey = async () => { if (!sel) return; @@ -227,6 +255,8 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup backToGallery, runTestAndSave, removeKey, + saveField, + fieldSaved, cancelBackTimer: () => { if (backTimer.current) window.clearTimeout(backTimer.current); }, @@ -320,7 +350,16 @@ export function ProviderForm({ value={ps.fields[f.key] || ""} data-testid={`${tp}-field-${f.key}`} onChange={(e) => ps.setFieldValue(f.key, e.target.value)} + onBlur={f.secret ? undefined : () => void ps.saveField(f.key)} /> + {ps.fieldSaved === f.key && ( + + ✓ Saved + + )} {/* §39: state lives IN the field — no status lines below. */} {ps.savedState && f.secret && ( - ps.setFieldValue(ep.key, e.target.value)} - /> +
+ ps.setFieldValue(ep.key, e.target.value)} + onBlur={() => void ps.saveField(ep.key)} + /> + {ps.fieldSaved === ep.key && ( + + ✓ Saved + + )} +
{ep.help &&

{ep.help}

} ); diff --git a/tests/test_server.py b/tests/test_server.py index 7a9c723c..97d5e986 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -766,3 +766,25 @@ def test_google_one_click_paused_but_manual_alive(tmp_path): refused = client.post("/v1/connectors/gmail/connect-managed", json={}).json() assert refused["ok"] is False and "coming soon" in refused["error"] + + +def test_set_provider_persists_extra_fields(tmp_path): + """Non-secret descriptor extras (anthropic thinking_budget) round-trip: saved into the + profile, echoed by get_providers for form prefill, cleared by an empty save.""" + manager = SessionManager(workspace=tmp_path, provider=ScriptedProvider([])) + assert manager.set_provider( + "anthropic", {"api_key": "sk-ant-test", "thinking_budget": "8192"} + )["ok"] + providers = {p["name"]: p for p in manager.get_providers()} + assert providers["anthropic"]["values"]["thinking_budget"] == "8192" + + from coworker.providers.registry import build_provider_client + + built = build_provider_client( + "anthropic", manager.secrets.get("provider:anthropic"), manager.secrets + ) + assert built.thinking_budget == 8192 + + manager.set_provider("anthropic", {"thinking_budget": ""}) + providers = {p["name"]: p for p in manager.get_providers()} + assert "thinking_budget" not in providers["anthropic"]["values"]