mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
GUI: multi-field provider credentials and add-model family dropdown
Test button and saved pill follow the required-secret field (or the first field for cloud providers); Bedrock/Vertex add-model rows get a family selector.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
// Add-model family dropdown for the cloud-account providers: the family choice folds
|
||||
// into the model id (`bedrock:claude/…`, `vertex:openweight/…`); plain providers keep
|
||||
// the bare add-model row.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ModelChecklist } from "./ModelChecklist";
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
addModel: vi.fn(async (id: string) => ({ ok: true, models: [id], model: id })),
|
||||
removeModel: vi.fn(async () => ({ ok: true, models: [], model: "" })),
|
||||
setDefaultModel: vi.fn(async () => ({ ok: true })),
|
||||
getSettings: vi.fn(async () => ({ models: [], model: "" })),
|
||||
}));
|
||||
|
||||
import { addModel } from "../api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const KNOWN = ["openai", "anthropic", "bedrock", "vertex", "openrouter"];
|
||||
|
||||
function renderList(provider: string) {
|
||||
return render(
|
||||
<ModelChecklist
|
||||
provider={provider}
|
||||
knownProviders={KNOWN}
|
||||
suggested={[]}
|
||||
curated={[]}
|
||||
defaultModel=""
|
||||
onChanged={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function addTyped(id: string) {
|
||||
fireEvent.change(screen.getByPlaceholderText("Add another model…"), {
|
||||
target: { value: id },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Add"));
|
||||
}
|
||||
|
||||
describe("ModelChecklist add-model family dropdown", () => {
|
||||
it("folds the selected vertex family into the id", async () => {
|
||||
renderList("vertex");
|
||||
fireEvent.change(screen.getByTestId("mlist-family"), {
|
||||
target: { value: "openweight" },
|
||||
});
|
||||
addTyped("meta/llama-4-maverick-17b-128e-instruct-maas");
|
||||
expect(addModel).toHaveBeenCalledWith(
|
||||
"vertex:openweight/meta/llama-4-maverick-17b-128e-instruct-maas",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults bedrock to the Claude family and keeps a typed family verbatim", async () => {
|
||||
renderList("bedrock");
|
||||
addTyped("anthropic.claude-sonnet-4-6-v1:0");
|
||||
expect(addModel).toHaveBeenCalledWith(
|
||||
"bedrock:claude/anthropic.claude-sonnet-4-6-v1:0",
|
||||
);
|
||||
addTyped("other/amazon.nova-2-pro-v1:0");
|
||||
expect(addModel).toHaveBeenLastCalledWith("bedrock:other/amazon.nova-2-pro-v1:0");
|
||||
});
|
||||
|
||||
it("shows no family dropdown for plain providers", async () => {
|
||||
renderList("openrouter");
|
||||
expect(screen.queryByTestId("mlist-family")).toBeNull();
|
||||
addTyped("z-ai/glm-5.2");
|
||||
expect(addModel).toHaveBeenCalledWith("openrouter:z-ai/glm-5.2");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,22 @@
|
||||
import { useState } from "react";
|
||||
import { addModel, getSettings, removeModel, setDefaultModel } from "../api";
|
||||
|
||||
// Cloud-account providers dispatch by a family segment baked into the model id
|
||||
// (`bedrock:claude/…`, `vertex:openweight/…`). The add-model row shows a dropdown so
|
||||
// users pick the family instead of memorizing the prefix; curated matrix ids already
|
||||
// carry theirs.
|
||||
const MODEL_FAMILIES: Record<string, { value: string; label: string }[]> = {
|
||||
bedrock: [
|
||||
{ value: "claude", label: "Claude family" },
|
||||
{ value: "other", label: "Other models" },
|
||||
],
|
||||
vertex: [
|
||||
{ value: "gemini", label: "Gemini family" },
|
||||
{ value: "claude", label: "Claude family" },
|
||||
{ value: "openweight", label: "Open-weight" },
|
||||
],
|
||||
};
|
||||
|
||||
// One provider's models as a checklist: tick = shown in the composer's model picker (the
|
||||
// curated list), the black "default" badge marks the model new sessions use, and hovering any
|
||||
// other row reveals "Make default". A free-type row below adds models by hand, so brand-new
|
||||
@@ -23,6 +39,8 @@ export function ModelChecklist({
|
||||
onChanged: (next: { models: string[]; model: string }) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const families = MODEL_FAMILIES[provider];
|
||||
const [family, setFamily] = useState(families?.[0]?.value || "");
|
||||
|
||||
const provOf = (id: string) => {
|
||||
const i = id.indexOf(":");
|
||||
@@ -52,8 +70,12 @@ export function ModelChecklist({
|
||||
await refresh();
|
||||
};
|
||||
const add = async () => {
|
||||
const typed = draft.trim();
|
||||
let typed = draft.trim();
|
||||
if (!typed) return;
|
||||
// Fold the family choice into the id unless the user already typed one.
|
||||
if (families && !families.some((f) => typed.startsWith(`${f.value}/`))) {
|
||||
typed = `${family}/${typed}`;
|
||||
}
|
||||
const res = await addModel(prefixed(typed));
|
||||
if (res.ok) {
|
||||
setDraft("");
|
||||
@@ -90,6 +112,20 @@ export function ModelChecklist({
|
||||
);
|
||||
})}
|
||||
<div className="mlist-add">
|
||||
{families && (
|
||||
<select
|
||||
value={family}
|
||||
onChange={(e) => setFamily(e.target.value)}
|
||||
aria-label="Model family"
|
||||
data-testid="mlist-family"
|
||||
>
|
||||
{families.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<input
|
||||
placeholder="Add another model…"
|
||||
value={draft}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const KEY_HELP: Record<string, { url: string; label: string }> = {
|
||||
anthropic: { url: "https://console.anthropic.com/settings/keys", label: "console.anthropic.com" },
|
||||
openai: { url: "https://platform.openai.com/api-keys", label: "platform.openai.com" },
|
||||
gemini: { url: "https://aistudio.google.com/apikey", label: "aistudio.google.com" },
|
||||
openrouter: { url: "https://openrouter.ai/keys", label: "openrouter.ai" },
|
||||
fireworks: { url: "https://fireworks.ai/account/api-keys", label: "fireworks.ai" },
|
||||
together: { url: "https://api.together.xyz/settings/api-keys", label: "together.xyz" },
|
||||
zai: { url: "https://z.ai/manage-apikey/apikey-list", label: "z.ai" },
|
||||
@@ -250,7 +251,11 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup
|
||||
// The in-field saved state (§39): green border + pill INSIDE the key box — shown
|
||||
// for stored credentials and fresh test-passes alike; typing clears it.
|
||||
savedState: (credentialed && !dirty) || verify.state === "ok",
|
||||
secretFilled: (info?.fields || []).every((f) => !f.secret || (fields[f.key] || "").trim()),
|
||||
// Only REQUIRED secrets gate the Test button — cloud providers (Bedrock, Vertex)
|
||||
// have optional key fields whose credentials may live in ~/.aws or ADC instead.
|
||||
secretFilled: (info?.fields || []).every(
|
||||
(f) => !f.secret || !f.required || (fields[f.key] || "").trim(),
|
||||
),
|
||||
openProvider,
|
||||
backToGallery,
|
||||
runTestAndSave,
|
||||
@@ -335,16 +340,18 @@ export function ProviderForm({
|
||||
// A keyed provider's base_url is an expert option — it renders BELOW the key-help
|
||||
// line as its own advanced section (owner nit 2026-07-19), not inside the loop.
|
||||
if (f.key === "base_url" && keyed) return null;
|
||||
const testable =
|
||||
(f.secret && f.key === (info?.fields || []).find((x) => x.secret)?.key) ||
|
||||
(!keyed && f.key === (info?.fields || [])[0]?.key);
|
||||
// Test lives next to the required secret (the API key) when there is one; cloud
|
||||
// providers whose secrets are all optional (Bedrock, Vertex) test from the first
|
||||
// field instead — their credentials may be ambient (~/.aws, ADC).
|
||||
const requiredSecret = (info?.fields || []).find((x) => x.secret && x.required);
|
||||
const testable = requiredSecret ? f.key === requiredSecret.key : f.key === (info?.fields || [])[0]?.key;
|
||||
return (
|
||||
<div key={f.key}>
|
||||
<label className={label}>{f.label}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<input
|
||||
className={input + (ps.savedState && f.secret ? " border-ok pr-32" : " border-line")}
|
||||
className={input + (ps.savedState && testable ? " border-ok pr-32" : " border-line")}
|
||||
type={f.secret ? "password" : "text"}
|
||||
placeholder={f.secret && ps.credentialed && !ps.dirty ? "••••••••" : f.placeholder}
|
||||
value={ps.fields[f.key] || ""}
|
||||
@@ -361,20 +368,12 @@ export function ProviderForm({
|
||||
</span>
|
||||
)}
|
||||
{/* §39: state lives IN the field — no status lines below. */}
|
||||
{ps.savedState && f.secret && (
|
||||
{ps.savedState && testable && (
|
||||
<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}-saved-pill`}
|
||||
>
|
||||
✓ Tested & saved
|
||||
</span>
|
||||
)}
|
||||
{ps.savedState && !f.secret && testable && (
|
||||
<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}-saved-pill`}
|
||||
>
|
||||
✓ Detected
|
||||
{info?.needs_key ? <>✓ Tested & saved</> : <>✓ Detected</>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -382,14 +381,14 @@ export function ProviderForm({
|
||||
<button
|
||||
className="px-4 rounded-lg border border-line text-[13px] font-medium text-ink hover:border-lineStrong shrink-0 disabled:opacity-40"
|
||||
onClick={() => ps.runTestAndSave()}
|
||||
disabled={ps.verify.state === "testing" || (f.secret && !ps.secretFilled && !ps.credentialed)}
|
||||
disabled={ps.verify.state === "testing" || (!ps.secretFilled && !ps.credentialed)}
|
||||
data-testid={`${tp}-test`}
|
||||
>
|
||||
{ps.verify.state === "testing" ? "…" : info?.needs_key ? "Test" : "Detect"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{f.help && !f.secret && <p className="text-[11.5px] text-faint mt-1">{f.help}</p>}
|
||||
{f.help && <p className="text-[11.5px] text-faint mt-1">{f.help}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -43,6 +43,9 @@ export const PROVIDER_ORDER = [
|
||||
"gemini",
|
||||
"meta",
|
||||
"ollama",
|
||||
"bedrock",
|
||||
"vertex",
|
||||
"openrouter",
|
||||
"fireworks",
|
||||
"together",
|
||||
"zai",
|
||||
|
||||
@@ -862,6 +862,11 @@ button.btn.danger { color: var(--accent); }
|
||||
padding: 7px 10px; color: var(--ink); font: inherit; font-size: 13px; outline: none;
|
||||
}
|
||||
.mlist-add input:focus { border-color: var(--accent); }
|
||||
.mlist-add select {
|
||||
background: var(--panel); border: 1px solid var(--line-strong); border-radius: 8px;
|
||||
padding: 7px 8px; color: var(--ink); font: inherit; font-size: 13px; outline: none;
|
||||
}
|
||||
.mlist-add select:focus { border-color: var(--accent); }
|
||||
|
||||
/* .mcp-error is used by ScheduledView; the MCP servers list itself is Tailwind-styled inline
|
||||
(ManageTabs McpTab/McpRow/AddForm). */
|
||||
|
||||
Reference in New Issue
Block a user