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:
Rohit C Prasad
2026-07-25 16:22:27 -07:00
parent 050cc894e7
commit 241af5e15f
5 changed files with 133 additions and 18 deletions
@@ -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");
});
});
+37 -1
View File
@@ -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}