From 241af5e15f2d88b2a9574f57f3ca438ab1b6feb3 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sat, 25 Jul 2026 16:22:27 -0700 Subject: [PATCH] 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. --- .../src/components/ModelChecklist.test.tsx | 72 +++++++++++++++++++ .../gui/src/components/ModelChecklist.tsx | 38 +++++++++- surfaces/gui/src/providers/ProviderSetup.tsx | 33 +++++---- surfaces/gui/src/providers/logos.ts | 3 + surfaces/gui/src/styles.css | 5 ++ 5 files changed, 133 insertions(+), 18 deletions(-) create mode 100644 surfaces/gui/src/components/ModelChecklist.test.tsx diff --git a/surfaces/gui/src/components/ModelChecklist.test.tsx b/surfaces/gui/src/components/ModelChecklist.test.tsx new file mode 100644 index 00000000..03fa47d8 --- /dev/null +++ b/surfaces/gui/src/components/ModelChecklist.test.tsx @@ -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( + {}} + />, + ); +} + +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"); + }); +}); diff --git a/surfaces/gui/src/components/ModelChecklist.tsx b/surfaces/gui/src/components/ModelChecklist.tsx index 47f856f2..6c501979 100644 --- a/surfaces/gui/src/components/ModelChecklist.tsx +++ b/surfaces/gui/src/components/ModelChecklist.tsx @@ -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 = { + 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({ ); })}
+ {families && ( + + )} = { 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 (
)} {/* §39: state lives IN the field — no status lines below. */} - {ps.savedState && f.secret && ( + {ps.savedState && testable && ( - ✓ Tested & saved - - )} - {ps.savedState && !f.secret && testable && ( - - ✓ Detected + {info?.needs_key ? <>✓ Tested & saved : <>✓ Detected} )}
@@ -382,14 +381,14 @@ export function ProviderForm({ )}
- {f.help && !f.secret &&

{f.help}

} + {f.help &&

{f.help}

}
); })} diff --git a/surfaces/gui/src/providers/logos.ts b/surfaces/gui/src/providers/logos.ts index 14c10332..9bcf3040 100644 --- a/surfaces/gui/src/providers/logos.ts +++ b/surfaces/gui/src/providers/logos.ts @@ -43,6 +43,9 @@ export const PROVIDER_ORDER = [ "gemini", "meta", "ollama", + "bedrock", + "vertex", + "openrouter", "fireworks", "together", "zai", diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index 3e8e3cce..7cd0d7e3 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -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). */