mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-12 15:20:19 +00:00
Blur-save non-secret provider fields on configured providers
The Test button was the form's only save path — extras like the thinking budget silently never persisted. Blur saves with a Saved flash; empty clears.
This commit is contained in:
@@ -328,7 +328,7 @@ const PROVIDERS = [
|
|||||||
// openai: configured + used (drives the "Last used" sub-line and the status dot).
|
// 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 },
|
{ 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").
|
// 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.
|
// 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 },
|
{ 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
|
// 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.configured = true;
|
||||||
prov.key_set_at = "2026-07-05";
|
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 });
|
return json({ ok: true, provider: b.name, recommended_model: null });
|
||||||
}
|
}
|
||||||
// forget a provider's stored config (Settings ▸ Models "Remove key…").
|
// forget a provider's stored config (Settings ▸ Models "Remove key…").
|
||||||
|
|||||||
@@ -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")).toHaveValue("");
|
||||||
await expect(page.getByTestId("set-field-api_key")).toHaveAttribute("placeholder", "••••••••");
|
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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ export interface ProviderSetupState {
|
|||||||
removeKey: () => Promise<void>;
|
removeKey: () => Promise<void>;
|
||||||
cancelBackTimer: () => void;
|
cancelBackTimer: () => void;
|
||||||
statusFor: (p: ProviderInfo, opts?: { lastUsed?: boolean }) => ReactNode;
|
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<void>;
|
||||||
|
fieldSaved: string | null; // field key flashing "✓ Saved"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetupState {
|
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).
|
// Unsaved per-provider input survives switching cards (owner complaint 2026-07-16).
|
||||||
const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>({});
|
const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>({});
|
||||||
const backTimer = useRef<number | null>(null);
|
const backTimer = useRef<number | null>(null);
|
||||||
|
// Which non-secret field just blur-saved (flashes "✓ Saved" in the input).
|
||||||
|
const [fieldSaved, setFieldSaved] = useState<string | null>(null);
|
||||||
|
const fieldSavedTimer = useRef<number | null>(null);
|
||||||
|
|
||||||
const refreshProviders = () =>
|
const refreshProviders = () =>
|
||||||
getProviders()
|
getProviders()
|
||||||
@@ -167,6 +175,26 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup
|
|||||||
return true;
|
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".
|
// Settings-only: forget the stored key; the card reverts to "Not set up".
|
||||||
const removeKey = async () => {
|
const removeKey = async () => {
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
@@ -227,6 +255,8 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup
|
|||||||
backToGallery,
|
backToGallery,
|
||||||
runTestAndSave,
|
runTestAndSave,
|
||||||
removeKey,
|
removeKey,
|
||||||
|
saveField,
|
||||||
|
fieldSaved,
|
||||||
cancelBackTimer: () => {
|
cancelBackTimer: () => {
|
||||||
if (backTimer.current) window.clearTimeout(backTimer.current);
|
if (backTimer.current) window.clearTimeout(backTimer.current);
|
||||||
},
|
},
|
||||||
@@ -320,7 +350,16 @@ export function ProviderForm({
|
|||||||
value={ps.fields[f.key] || ""}
|
value={ps.fields[f.key] || ""}
|
||||||
data-testid={`${tp}-field-${f.key}`}
|
data-testid={`${tp}-field-${f.key}`}
|
||||||
onChange={(e) => ps.setFieldValue(f.key, e.target.value)}
|
onChange={(e) => ps.setFieldValue(f.key, e.target.value)}
|
||||||
|
onBlur={f.secret ? undefined : () => void ps.saveField(f.key)}
|
||||||
/>
|
/>
|
||||||
|
{ps.fieldSaved === f.key && (
|
||||||
|
<span
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-[11px] font-medium text-ok bg-okSoft rounded-full px-2 py-0.5 pointer-events-none"
|
||||||
|
data-testid={`${tp}-field-saved-${f.key}`}
|
||||||
|
>
|
||||||
|
✓ Saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{/* §39: state lives IN the field — no status lines below. */}
|
{/* §39: state lives IN the field — no status lines below. */}
|
||||||
{ps.savedState && f.secret && (
|
{ps.savedState && f.secret && (
|
||||||
<span
|
<span
|
||||||
@@ -399,14 +438,25 @@ export function ProviderForm({
|
|||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<label className={label}>{ep.label}</label>
|
<label className={label}>{ep.label}</label>
|
||||||
<input
|
<div className="relative">
|
||||||
className={input + " border-line"}
|
<input
|
||||||
type="text"
|
className={input + " border-line"}
|
||||||
placeholder={ep.placeholder}
|
type="text"
|
||||||
value={ps.fields[ep.key] || ""}
|
placeholder={ep.placeholder}
|
||||||
data-testid={`${tp}-field-${ep.key}`}
|
value={ps.fields[ep.key] || ""}
|
||||||
onChange={(e) => ps.setFieldValue(ep.key, e.target.value)}
|
data-testid={`${tp}-field-${ep.key}`}
|
||||||
/>
|
onChange={(e) => ps.setFieldValue(ep.key, e.target.value)}
|
||||||
|
onBlur={() => void ps.saveField(ep.key)}
|
||||||
|
/>
|
||||||
|
{ps.fieldSaved === ep.key && (
|
||||||
|
<span
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-[11px] font-medium text-ok bg-okSoft rounded-full px-2 py-0.5 pointer-events-none"
|
||||||
|
data-testid={`${tp}-field-saved-${ep.key}`}
|
||||||
|
>
|
||||||
|
✓ Saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{ep.help && <p className="text-[11.5px] text-faint mt-1">{ep.help}</p>}
|
{ep.help && <p className="text-[11.5px] text-faint mt-1">{ep.help}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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()
|
refused = client.post("/v1/connectors/gmail/connect-managed", json={}).json()
|
||||||
assert refused["ok"] is False and "coming soon" in refused["error"]
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user