diff --git a/components/model-catalog.ts b/components/model-catalog.ts new file mode 100644 index 0000000..6a97320 --- /dev/null +++ b/components/model-catalog.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { ModelCatalogResponse } from "@/src/core"; +import { onSettingsSaved } from "@/components/settings-signal"; + +// Shared stale-while-revalidate catalog. Metadata refreshes retain the last +// good snapshot; connection changes clear selectable models immediately. +let catalogCache: ModelCatalogResponse | undefined; +let catalogError = false; +let catalogInflight: Promise | undefined; +let catalogGeneration = 0; +let stopCatalogSignals: (() => void) | undefined; +const catalogReaders = new Set<() => void>(); +const publishCatalog = () => { for (const reader of catalogReaders) reader(); }; + +function fetchCatalog(revalidate = false): void { + if (revalidate) { catalogGeneration++; catalogInflight = undefined; } + if (catalogInflight) return; + const generation = catalogGeneration; + catalogError = false; + publishCatalog(); + catalogInflight = fetch("/api/models") + .then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise; }) + .then((c) => { + if (generation === catalogGeneration) { catalogCache = c; catalogError = false; publishCatalog(); } + }) + .catch(() => { + if (generation === catalogGeneration) { catalogError = true; publishCatalog(); } + }) + .finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; }); +} + +export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; failed: boolean; refresh: () => void } { + const [snapshot, setSnapshot] = useState(() => ({ catalog: catalogCache, failed: catalogError })); + useEffect(() => { + const first = catalogReaders.size === 0; + const read = () => setSnapshot({ catalog: catalogCache, failed: catalogError }); + catalogReaders.add(read); + if (first) { + stopCatalogSignals = onSettingsSaved((section) => { + if (section === "keys") { + catalogGeneration++; catalogInflight = undefined; catalogCache = undefined; + fetchCatalog(); + } else if (section === "models") fetchCatalog(true); + }); + fetchCatalog(true); + } else fetchCatalog(); + read(); + return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } }; + }, []); + const refresh = useCallback(() => fetchCatalog(true), []); + return { ...snapshot, refresh }; +} diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 1ae092d..b618e73 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -8,52 +8,11 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men import { composeSlotModel } from "@/components/settings/agent-section"; import { MenuShell } from "@/components/menu-shell"; import { Hint } from "@/components/ui/hint"; -import { notifySettingsSaved, onSettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { useModelCatalog } from "@/components/model-catalog"; +export { useModelCatalog } from "@/components/model-catalog"; import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; -/** GET /api/models is cached per page load for instant menu opens, but - * the federation is LIVE per call — keys saved mid-session widen the - * catalog immediately server-side. ModelControl therefore re-fetches on - * every model-menu open (stale-while-revalidate: the cached list shows - * instantly, the fresh one swaps in while the menu is open). */ -let catalogCache: ModelCatalogResponse | undefined; -let catalogInflight: Promise | undefined; -let catalogGeneration = 0; -let stopCatalogSignals: (() => void) | undefined; -const catalogReaders = new Set<() => void>(); -const publishCatalog = () => { for (const reader of catalogReaders) reader(); }; -function fetchCatalog(revalidate = false): void { - if (revalidate) { catalogGeneration++; catalogInflight = undefined; } - if (catalogInflight) return; - const generation = catalogGeneration; - catalogInflight = fetch("/api/models") - .then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise; }) - .then((c) => { if (generation === catalogGeneration) { catalogCache = c; publishCatalog(); } }) - .catch(() => { /* existing unavailable/usage-only posture, no retry timer */ }) - .finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; }); -} -export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } { - const [catalog, setCatalog] = useState(catalogCache); - useEffect(() => { - const first = catalogReaders.size === 0; - const read = () => setCatalog(catalogCache); - catalogReaders.add(read); - if (first) { - stopCatalogSignals = onSettingsSaved((section) => { - if (section === "keys") { - catalogGeneration++; catalogInflight = undefined; catalogCache = undefined; - publishCatalog(); fetchCatalog(); - } else if (section === "models") fetchCatalog(true); - }); - fetchCatalog(true); - } else fetchCatalog(); - read(); - return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } }; - }, []); - const refresh = useCallback(() => fetchCatalog(true), []); - return { catalog, refresh }; -} - /** provider:id → contextWindow over the cached catalog — the client-side * twin of the server's catalogContextLimits join (context-meter * amendment, owner 2026-09-23: pi's live catalog is the ONLY limit @@ -315,10 +274,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod return (
diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index bd7c810..7c79b64 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -55,6 +55,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { MenuShell } from "@/components/menu-shell"; import { useSettingsSaved } from "@/components/settings-signal"; import { defaultOrderLevel, populateLevel } from "@/components/thinking-default"; +import { useModelCatalog } from "@/components/model-catalog"; +import { Hint } from "@/components/ui/hint"; import type React from "react"; import { COMPACTION_THRESHOLD_FLOOR, @@ -63,7 +65,6 @@ import { type AgentSettings, type CompactionSettings, type GraphSettings, - type ModelCatalogEntry, type ModelSlotName, type ModelsSettings, } from "@/src/core"; @@ -101,15 +102,6 @@ interface SegmentInfo { shared: boolean; } -/** The GET /api/models response fields this section consumes. */ -interface ModelsBody { - models: ModelCatalogEntry[]; - /** The three tiers' RESOLVED pins (BYOK: NULL per unset tier — no - * factory floor) — machinery only (the thinking list fallback); the - * rows' displays read the record override, never a phantom pin. */ - slots: Record; -} - /** Compose: one tool toggled, the rest of the section carried verbatim. * Pure — exported for tests. */ export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings { @@ -234,8 +226,10 @@ function MenuTrigger({ onClick, widthClass, disabled, + hint, }: { value: string; + hint?: string; /** What the trigger shows when there is no override ("Default"). */ emptyLabel: string; ariaLabel: string; @@ -250,7 +244,7 @@ function MenuTrigger({ /** The no-key search state grays the whole row; the trigger follows. */ disabled?: boolean; }) { - return ( + const button = ( ); + return hint ? {button} : button; } /* The section's dropdowns all mount the shared MenuShell (fix wave chunk @@ -303,10 +298,9 @@ export function AgentSection({ /** Writes the whole compaction section (replace-PUT discipline). */ onCompactionChange: (next: CompactionSettings) => void; }) { - const [catalog, setCatalog] = useState(null); - /** The three tiers' resolved pins — GET /api/models `slots`. The rows' - * Default hints and thinking lists resolve from it. */ - const [slots, setSlots] = useState(null); + const { catalog: body, failed: catalogFailed, refresh: refreshCatalog } = useModelCatalog(); + const catalog = body?.models; + const slots = body?.slots; /** The live search state (/api/search) — the row's reason line. */ const [search, setSearch] = useState<{ available: boolean; engine: string | null; reason: string } | null>(null); /** Which search engines have a key (/api/keys) — the no-keys-anywhere @@ -341,28 +335,9 @@ export function AgentSection({ .catch(() => {}); }, []); - // The catalog (federated: keyed providers only), the resolved slots, - // the prompt segments, and the search engines' key presence — one - // fetch each per section mount. The models read ALSO re-runs on the - // cross-surface signal (fix wave: another surface's write re-reads - // into the slot rows live). - const modelsRequest = useRef(0); - const modelsMounted = useRef(false); - const readModels = useCallback((connectionChanged = false) => { - const request = ++modelsRequest.current; - if (connectionChanged) setCatalog(null); - fetch("/api/models") - .then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); }) - .then((body: ModelsBody) => { - if (!modelsMounted.current || request !== modelsRequest.current) return; - setCatalog(body.models); - setSlots(body.slots); - }) - .catch(() => { if (modelsMounted.current && request === modelsRequest.current) setCatalog(null); }); - }, []); - + // The shared catalog seeds the first frame and refreshes per mount and + // keys/models signal. Settings props still own saved selections/levels. useSettingsSaved((section) => { - if (section === "models" || section === "keys") readModels(section === "keys"); // THE state-echo root cause: the prompt switches write through the // agent section, but this view (the toggles' truth) never re-read — // the settings-saved law (every reader re-reads) was missing this @@ -371,8 +346,6 @@ export function AgentSection({ }); useEffect(() => { - modelsMounted.current = true; - readModels(); fetch("/api/settings/prompts") .then((r) => r.json()) .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)) @@ -388,7 +361,6 @@ export function AgentSection({ setEngineKeys(present); }) .catch(() => {}); - return () => { modelsMounted.current = false; modelsRequest.current++; }; }, []); // The reason line re-reads /api/search AFTER an engine write lands — @@ -615,6 +587,7 @@ export function AgentSection({ return ( <> + {catalogFailed &&

Model catalog could not be loaded. Check the connection and retry.

} {MODEL_SLOTS.map((slot) => { const override = models?.[slot]; const modelId = override?.model?.id ?? ""; @@ -652,6 +625,7 @@ export function AgentSection({ {slot !== "strong" && <>} p.envVar === id); + if (matches?.some((p) => p.id === "opencode") && matches.some((p) => p.id === "opencode-go")) return "OpenCode (Zen / Go)"; + return matches?.[0]?.label; +} + /** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an * environment-owned value — the row itself is read-only then). Pure. */ export function keyStatusLine(status: AuthKeyStatus): string { @@ -417,7 +425,7 @@ export function KeysSection({ {providerRows.map((status) => ( p.envVar === status.id)?.label ?? status.info?.label ?? status.id} + label={providerKeyLabel(status.id, providers) ?? status.info?.label ?? status.id} docsUrl={status.info?.docsUrl} status={status} expanded={openRow === status.id} diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 461e507..641898f 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -13,7 +13,8 @@ * STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models — * there's no point presenting the model picker before the user has set a * key because there are no models to pick then"): - * Stage 1 — no keyed provider: a SELF-CONTAINED first-key screen + * Unknown catalog — neutral loading (or error/Retry), never a key screen. + * Stage 1 — no keyed provider or saved selection: a SELF-CONTAINED first-key screen * (zero-keys wave, owner 2026-09-25): provider picker + one key * input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED — * saving dispatches the "keys" signal, the fetch effect re-runs on @@ -23,6 +24,8 @@ * Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog * (keyed providers only), the vision slot filtered to image-capable * models, in the app's shared dropdown language (ModelMenuList). + * Saved disconnected selections stay visible; Add provider opens the + * connection body when no provider remains. Refreshes preserve edits. * All three required (owner: no skip, no defaults); Save PUTs the * models section and reports onDone(models). * @@ -41,6 +44,7 @@ import { prettyModel } from "@/components/usage-format"; import { ModelMenuList } from "@/components/settings/model-menu"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { useModelCatalog } from "@/components/model-catalog"; import { Hint } from "@/components/ui/hint"; import { type ProviderKeyInfo } from "@/components/settings/keys-section"; import { ProviderMenuList } from "@/components/settings/model-menu"; @@ -73,27 +77,26 @@ interface ModelsBody { * (owner's dependency ordering). Exported for tests. */ export function setupStage( providers: { id: string; keyPresent: boolean }[] | null, -): "keys" | "pickers" { - if (providers === null) return "keys"; // fetch failed → the honest fallback +): "loading" | "keys" | "pickers" { + if (providers === null) return "loading"; return providers.some((p) => p.keyPresent) ? "pickers" : "keys"; } -/** Prefill (owner, BYOK wave 2026-09-22): each pinned tier opens with its - * pick — matched in the FEDERATED catalog (a lapsed key's pin does not - * prefill); unpinned tiers stay empty and carry the "needed" mark. - * First-run = nothing pinned → all three empty + marked. Exported pure - * for tests. */ +type ModelSelection = Pick; + +/** Saved identity is independent of selectable metadata. A disconnected + * pin still prefills; only the menu is restricted to the live catalog. */ export function prefillFromSlots( models: ModelCatalogEntry[], slots: Record | undefined, -): Partial> { - const pre: Partial> = {}; +): Partial> { + const pre: Partial> = {}; for (const slot of SLOT_ORDER) { const pin = slots?.[slot]; const entry = pin?.model ? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id) : undefined; - if (entry) pre[slot] = entry; + if (pin?.model) pre[slot] = entry ?? pin.model; } return pre; } @@ -217,24 +220,39 @@ export function SetupKeysBody({ ); } -export function SetupDialog({ - open, - onClose, - /** Saved: the models section landed; the caller marks configured (the - * draft stays put — save never sends). */ - onDone, -}: { +function prefillLevels(body: ModelsBody | undefined): Partial> { + const levels: Partial> = {}; + for (const slot of SLOT_ORDER) { + const pin = body?.slots?.[slot]; + const entry = body?.models.find((m) => m.provider === pin?.model.provider && m.id === pin.model.id); + if (pin?.thinkingLevel !== undefined) levels[slot] = pin.thinkingLevel; + else if (entry) levels[slot] = prefillLevelFromPin(entry, pin); + } + return levels; +} + +interface SetupDialogProps { open: boolean; onClose: () => void; + /** Saving never sends the caller's draft. */ onDone: (models: ModelsSettings) => void; -}) { +} + +export function SetupDialog(props: SetupDialogProps) { + // An open owns its draft. Mount-time cache seeding avoids a key-screen + // first frame, and reopening cannot reuse a previous open's edits. + return props.open ? : null; +} + +function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { const backdropDismiss = useBackdropDismiss(onClose); - const [body, setBody] = useState(null); - const [failed, setFailed] = useState(false); - const [picked, setPicked] = useState>>({}); - /** Per-slot thinking level (chunk 2): populated from the ordering on - * every fresh model pick, editable — an explicit pick always wins. */ - const [levels, setLevels] = useState>>({}); + const { catalog, failed, refresh } = useModelCatalog(); + const [lastBody, setLastBody] = useState(catalog); + const body = catalog ?? lastBody; + const [picked, setPicked] = useState(() => prefillFromSlots(catalog?.models ?? [], catalog?.slots)); + const [levels, setLevels] = useState(() => prefillLevels(catalog)); + const editedSlots = useRef(new Set()); + const [showKeys, setShowKeys] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null); @@ -249,9 +267,8 @@ export function SetupDialog({ const [keyError, setKeyError] = useState(null); const [keyMenuOpen, setKeyMenuOpen] = useState(false); // Stage 1's provider-picker trigger wrapper — the shared shell's anchor. - // Declared with the other hooks: the early `if (!open) return null` sits - // below them all. (Stage 2's slot menus anchor by anchorTestid instead — - // see the slot MenuShell below for why a ref cannot work there.) + // Stage 2's slot menus anchor by anchorTestid instead — see the slot + // MenuShell below for why a freshly attached ref cannot work there. const keyTriggerRef = useRef(null); // The keys signal (zero-keys wave): keys-section saves/deletes AND @@ -263,50 +280,44 @@ export function SetupDialog({ if (section === "keys") setKeysTick((t) => t + 1); }); - // Fresh /api/models per open AND per keys change — a key saved a - // moment ago (stage 1's own screen) widens the pickers immediately. useEffect(() => { - if (!open) return; let cancelled = false; - setBody(null); - setPicked({}); - setLevels({}); - setError(null); fetch("/api/keys") .then((r) => r.json()) .then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); }) .catch(() => { if (!cancelled) setKeyProviders([]); }); - fetch("/api/models") - .then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); }) - .then((b: ModelsBody) => { - if (cancelled) return; - setBody(b); - setFailed(false); - // Prefill (owner, 2026-09-22): each pinned tier opens with its - // pick (matched in the FEDERATED catalog — a lapsed key's pin - // does not prefill); unpinned tiers stay empty and carry the - // "needed" mark. First-run = nothing pinned → all three marked. - const pre = prefillFromSlots(b.models, b.slots); - setPicked(pre); - // Levels prefill with the pins (chunk 2): stored level if still - // supported, else the ordering's level for the pinned model. - const preLevels: Partial> = {}; - for (const slot of SLOT_ORDER) { - const entry = pre[slot]; - if (entry) preLevels[slot] = prefillLevelFromPin(entry, b.slots?.[slot]); - } - setLevels(preLevels); - }) - .catch(() => { if (!cancelled) setFailed(true); }); return () => { cancelled = true; }; - }, [open, keysTick]); + }, [keysTick]); + + useEffect(() => { + if (!catalog) return; + setLastBody(catalog); + const pre = prefillFromSlots(catalog.models, catalog.slots); + const preLevels = prefillLevels(catalog); + // Fresh metadata is always used, but a response cannot reset an + // edited slot, including a level chosen while the request was pending. + setPicked((prev) => { + const next = { ...prev }; + for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) { + if (pre[slot]) next[slot] = pre[slot]; else delete next[slot]; + } + return next; + }); + setLevels((prev) => { + const next = { ...prev }; + for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) { + if (preLevels[slot] !== undefined) next[slot] = preLevels[slot]; else delete next[slot]; + } + return next; + }); + if (catalog.providers.some((p) => p.keyPresent)) setShowKeys(false); + }, [catalog]); // Esc + outside-click — layered dismissal, closable WITHOUT // configuring (browsing stays possible; the gate is the send // interception, not this dialog). An open slot menu eats Esc first // (and any click outside it); only a bare Esc closes the dialog. useEffect(() => { - if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; // THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim at @@ -341,11 +352,10 @@ export function SetupDialog({ document.removeEventListener("keydown", onKey); document.removeEventListener("pointerdown", onPointerDown); }; - }, [open, onClose, openMenu, keyMenuOpen]); + }, [onClose, openMenu, keyMenuOpen]); - if (!open) return null; - - const stage = failed ? "keys" : setupStage(body ? body.providers : null); + const hasSelections = Object.keys(picked).length > 0 || SLOT_ORDER.some((slot) => body?.slots?.[slot]?.model); + const stage = hasSelections ? "pickers" : setupStage(body ? body.providers : null); const allPicked = SLOT_ORDER.every((s) => picked[s]); /** Stage 1's save: PUT the pasted key, dispatch the keys signal, and @@ -398,6 +408,20 @@ export function SetupDialog({ onDone(models); } + const keysBody = setKeyMenuOpen((v) => !v)} + onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }} + onValue={setKeyValue} + onSave={() => void saveKey()} + />; + return createPortal(
- {stage === "keys" ? ( -
- {failed ? ( -

- Model catalog could not be loaded. Check the connection and retry. -

- ) : ( - setKeyMenuOpen((v) => !v)} - onPick={(p) => { - setKeyProvider(p); - setKeyMenuOpen(false); - setKeyError(null); - }} - onValue={setKeyValue} - onSave={() => void saveKey()} - /> - )} -
+ {failed && ( +

+ Model catalog could not be loaded. Check the connection and retry. +

+ )} + {stage === "loading" ? ( + !failed &&

Loading models…

+ ) : stage === "keys" ? ( +
{keysBody}
) : (

Pick the model for each tier. All three are required before chats can start.

+ {!body?.providers.some((p) => p.keyPresent) && <> + + {showKeys && keysBody} + } {SLOT_ORDER.map((slot) => { const entry = picked[slot]; const level = levels[slot]; - const levelsForEntry = entry?.supportedLevels ?? []; - const catalog = - slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models; + const metadata = catalog?.models.find((m) => m.id === entry?.id && m.provider === entry.provider); + // Unavailable metadata cannot erase or clamp a saved preference. + const levelsForEntry = metadata?.supportedLevels ?? (entry ? Object.keys(THINKING_LABEL) : []); + const available = catalog?.models ?? (failed ? [] : undefined); + const slotCatalog = slot === "vision" ? available?.filter((m) => m.input.includes("image")) : available; return (
@@ -481,6 +494,7 @@ export function SetupDialog({ {slot !== "strong" && <>}
+ + {/* Anchor by TESTID, not a ref (the settings-section pattern): React attaches a host element's ref AFTER its descendants' layout effects run, so a ref freshly attached in this same @@ -512,10 +527,11 @@ export function SetupDialog({ menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl" > { + editedSlots.current.add(slot); setPicked((prev) => ({ ...prev, [slot]: m })); // Auto-populate (chunk 2, owner: "populate // everywhere"): no explicit level → the @@ -527,7 +543,7 @@ export function SetupDialog({ })); setOpenMenu(null); }} - footer={catalog && catalog.length === 0 ? "No image-capable models. Add a provider key with access to a vision model." : undefined} + footer={slotCatalog && slotCatalog.length === 0 && slot === "vision" ? "No image-capable models. Add a provider key with access to a vision model." : undefined} /> )} @@ -563,6 +579,7 @@ export function SetupDialog({ levels={levelsForEntry.map(String)} effectiveLevel={level} onPick={(lv) => { + editedSlots.current.add(slot); setLevels((prev) => ({ ...prev, [slot]: lv as ThinkingLevel })); setOpenMenu(null); }} diff --git a/components/usage-format.ts b/components/usage-format.ts index 3a400cd..2bd7d60 100644 --- a/components/usage-format.ts +++ b/components/usage-format.ts @@ -29,25 +29,11 @@ export function splitQualified(id: string): { provider: string | null; id: strin return { provider: id.slice(0, colon), id: id.slice(colon + 1) }; } -/** - * "z-ai/glm-5.3-flash" → "glm-5.3-flash" — vendor prefix dropped, the - * name KEPT EXACTLY as its provider writes it (dashes stay, no - * auto-caps — owner rounds 4-5, 2026-10-05); a trailing all-digit - * segment (OpenRouter's date suffix, "qwen3.8-max-0902") is dropped. - * Quiet-chip friendly. - * - * With `qualified` (usage stamps are "provider:id", model-slots wave): - * the provider rides as a quiet suffix — "glm-5.3-flash · openrouter" — - * because with federation the same model via two providers is genuinely - * different bookkeeping. Bare catalog ids are unchanged. - */ +/** Model-only labels omit the vendor path, never versions, dates or variants. + * Only usage callers pass `qualified`: a bare catalog id's colon belongs to + * the model (e.g. :free), not a provider. Usage retains its provider suffix. */ export function prettyModel(id: string, qualified = false): string { - const { provider, id: bare } = splitQualified(id); + const { provider, id: bare } = qualified ? splitQualified(id) : { provider: null, id }; const short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare; - const parts = short.split("-"); - const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts; - // No auto-capitalization, dashes KEPT (owner rounds 4-5, 2026-10-05): - // model names render exactly as their providers write them. - const pretty = trimmed.join("-"); - return qualified && provider ? `${pretty} · ${provider}` : pretty; + return provider ? `${short} · ${provider}` : short; } \ No newline at end of file diff --git a/tests/byok-refusal.test.ts b/tests/byok-refusal.test.ts index fab7db2..7627b8f 100644 --- a/tests/byok-refusal.test.ts +++ b/tests/byok-refusal.test.ts @@ -123,9 +123,9 @@ describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => { assert.deepEqual(pre, {}); }); - it("a pin whose model left the federated catalog does not prefill (lapsed key)", () => { + it("a disconnected saved identity still prefills without selectable metadata", () => { const pre = prefillFromSlots(catalog, { strong: { model: { provider: "gone", id: "vanished" } }, cheap: null, vision: null }); - assert.deepEqual(pre, {}); + assert.deepEqual(pre, { strong: { provider: "gone", id: "vanished" } }); }); it("provider collisions never cross-pollute (same id, different provider)", () => { @@ -158,9 +158,9 @@ describe("prefillLevelFromPin — the setup dialog's level prefill (chunk 2)", ( }); }); -describe("setupStage — unchanged by the BYOK wave", () => { +describe("setupStage — unknown is not keyless", () => { it("keys first (owner ordering), pickers when a provider is keyed", () => { - assert.equal(setupStage(null), "keys"); + assert.equal(setupStage(null), "loading"); assert.equal(setupStage([{ id: "x", keyPresent: false }]), "keys"); assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers"); }); diff --git a/tests/model-catalog-setup.test.tsx b/tests/model-catalog-setup.test.tsx new file mode 100644 index 0000000..43db670 --- /dev/null +++ b/tests/model-catalog-setup.test.tsx @@ -0,0 +1,176 @@ +import React, { act } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { createRoot, type Root } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { SetupDialog } from "../components/setup-dialog.tsx"; +import { AgentSection } from "../components/settings/agent-section.tsx"; +import { providerKeyLabel, classifyKeys } from "../components/settings/keys-section.tsx"; +import { useModelCatalog } from "../components/model-catalog.ts"; +import { notifySettingsSaved } from "../components/settings-signal.ts"; +import type { ModelCatalogEntry, ModelCatalogResponse, ModelsSettings } from "../src/core/index.ts"; + +const dom = parseHTML(""); +Object.assign(globalThis, { + window: dom.window, document: dom.document, Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement, + CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0), + cancelAnimationFrame: (id: number) => clearTimeout(id), +}); +Object.assign(dom.window, { innerWidth: 1280, innerHeight: 800 }); +dom.window.HTMLElement.prototype.scrollIntoView = () => {}; +const originalFetch = globalThis.fetch; +let root: Root | undefined; +let host: HTMLDivElement | undefined; +let requests: ReturnType>[] = []; +let writes: ModelsSettings[] = []; +let snapshot: ReturnType; +function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { resolve, promise }; } +const provider = { id: "fixture", envVar: "FIXTURE_KEY", label: "Fixture" }; +const a: ModelCatalogEntry = { id: "vendor/model:free", name: "Model free", provider: provider.id, input: ["text", "image"], reasoning: true, supportedLevels: ["off", "low", "high"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 }; +const b: ModelCatalogEntry = { ...a, id: "claude-opus-5-5", name: "Claude Opus 5.5", supportedLevels: ["off", "medium"] }; +function catalog(connected = true): ModelCatalogResponse { + return { + models: connected ? [a, b] : [], defaults: null, + rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null }, + slots: { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" }, cheap: { model: { provider: a.provider, id: a.id }, thinkingLevel: "high" }, vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } }, + providers: [{ ...provider, keyPresent: connected }], + }; +} +function Reader() { snapshot = useModelCatalog(); return null; } +function setup() { return {}} onDone={() => {}} />; } +function installFetch() { + requests = []; writes = []; + globalThis.fetch = async (input, init) => { + const path = String(input); + if (path === "/api/models") { const request = deferred(); requests.push(request); return request.promise; } + if (path === "/api/settings/models") { writes.push(JSON.parse(String(init?.body))); return Response.json({ settings: { models: writes.at(-1) } }); } + if (path === "/api/keys") return Response.json({ providers: [provider], keys: [] }); + if (path === "/api/settings/prompts") return Response.json({ prober: [], teacher: [] }); + if (path === "/api/search") return Response.json({ available: false, engine: null, reason: "fixture" }); + throw new Error(`Unexpected request ${path}`); + }; +} +async function render(children: React.ReactNode) { + if (!root) { host = document.createElement("div"); document.body.append(host); root = createRoot(host); } + await act(async () => root!.render(children)); +} +async function answer(index: number, body: ModelCatalogResponse | null) { await act(async () => requests[index]!.resolve(body ? Response.json(body) : new Response("unavailable", { status: 503 }))); } +function element(testid: string) { const e = document.querySelector(`[data-testid="${testid}"]`); assert.ok(e, testid); return e; } +async function click(testid: string) { await act(async () => element(testid).click()); } +function levels() { return [...document.querySelectorAll('[data-testid^="thinking-option-"]')].map((e) => e.textContent); } +async function warm(body = catalog()) { await render(); await answer(0, body); } + +afterEach(async () => { + await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; + globalThis.fetch = originalFetch; +}); + +test("cold setup is loading, not keyless; failure/Retry does not invent a key stage", async () => { + installFetch(); await render(setup()); + assert.ok(element("setup-stage-loading")); + assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); + await answer(0, null); + assert.ok(element("setup-catalog-error")); + assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); + await click("setup-retry"); assert.ok(element("setup-stage-loading")); + const empty = catalog(false); empty.slots = { strong: null, cheap: null, vision: null }; + await answer(1, empty); assert.ok(element("setup-stage-keys")); +}); + +test("cached setup is immediately usable; fresh metadata/untouched slots update without wiping model or thinking edits; failed refresh still allows exact-ID Save", async () => { + installFetch(); await warm(); + await render(<>{setup()}); + assert.equal(requests.length, 2); + assert.ok(element("setup-stage-pickers")); + assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); + assert.equal(element("setup-trigger-strong").textContent, "model:free▾"); + await click("setup-trigger-strong"); await click("model-option-claude-opus-5-5"); + await click("setup-thinking-strong"); await click("thinking-option-medium"); + const fresh = catalog(); fresh.slots.cheap = { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" }; + fresh.models[1] = { ...b, supportedLevels: ["off", "medium", "high"] }; + await answer(1, fresh); + assert.equal(element("setup-trigger-strong").textContent, "claude-opus-5-5▾"); + assert.equal(element("setup-thinking-strong").textContent?.trim(), "medium▾"); + assert.equal(element("setup-trigger-cheap").textContent, "claude-opus-5-5▾"); + await click("setup-thinking-strong"); assert.deepEqual(levels(), ["off", "medium✓", "high"]); + await click("setup-thinking-strong"); + await act(async () => snapshot.refresh()); await answer(2, null); + assert.ok(element("setup-catalog-error")); assert.equal(element("setup-save").hasAttribute("disabled"), false); + await click("setup-save"); + assert.deepEqual(writes[0], { + strong: { model: { provider: b.provider, id: b.id }, thinkingLevel: "medium" }, + cheap: { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" }, + vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" }, + }); +}); + +test("disconnect invalidates choices, not saved selections or thinking; all-disconnected setup offers Add provider", async () => { + installFetch(); await warm(); await render(<>{setup()}); await answer(1, catalog()); + await click("setup-trigger-strong"); + assert.ok(document.querySelector('[data-testid="model-option-claude-opus-5-5"]')); + await act(async () => notifySettingsSaved("keys")); + assert.equal(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'), null, "stale choices disappear before the fresh request resolves"); + await answer(2, catalog(false)); + assert.equal(element("setup-trigger-strong").textContent, "model:free▾"); + assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾"); + assert.ok(element("setup-add-provider")); await click("setup-add-provider"); assert.ok(element("setup-stage-keys")); + await click("setup-thinking-strong"); await click("thinking-option-xhigh"); + assert.equal(element("setup-thinking-strong").textContent?.trim(), "xhigh▾"); + await click("setup-save"); assert.equal(writes[0]?.strong?.thinkingLevel, "xhigh"); assert.equal(writes[0]?.strong?.model?.id, a.id); +}); + +test("cold reconnect checks saved pins before choosing the key stage, and reopening gets a new draft", async () => { + installFetch(); await warm(); + await act(async () => notifySettingsSaved("keys")); + const stages: Array = []; + function Probe() { + const { catalog } = useModelCatalog(); + React.useLayoutEffect(() => { stages.push(document.querySelector('[data-testid^="setup-stage-"]')?.dataset.testid); }, [catalog]); + return null; + } + await render(<>{setup()}); + assert.ok(element("setup-stage-loading")); + await answer(1, catalog(false)); + assert.deepEqual(stages, ["setup-stage-loading", "setup-stage-pickers"]); + await click("setup-thinking-strong"); await click("thinking-option-xhigh"); + await render(); + await render(<>{setup()}); + assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾", "a reopened draft uses saved state, not the abandoned edit"); + await answer(2, catalog(false)); +}); + +test("Agent opens with cached thinking metadata; refresh and disconnect never clear saved levels", async () => { + installFetch(); await warm(); + const models: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } }; + let changed: ModelsSettings | undefined; + await render(<> {}} onModelsChange={(next) => { changed = next; }} onCompactionChange={() => {}} />); + await click("slot-thinking-strong"); assert.deepEqual(levels(), ["off", "low✓", "high"]); + assert.equal(element("slot-model-strong").textContent, "model:free▾"); + await answer(1, catalog()); + await act(async () => notifySettingsSaved("keys")); await answer(2, catalog(false)); + assert.equal(element("slot-thinking-strong").textContent, "low▾"); + await click("thinking-option-xhigh"); assert.equal(changed?.strong?.thinkingLevel, "xhigh"); assert.equal(changed?.strong?.model?.id, a.id); +}); + +test("shared refresh fences late failure as well as late success, and Retry clears a current error", async () => { + installFetch(); await warm(); + await act(async () => snapshot.refresh()); await act(async () => snapshot.refresh()); + const fresh = catalog(); fresh.models = [b]; + await answer(2, fresh); await answer(1, null); + assert.equal(snapshot.failed, false); assert.equal(snapshot.catalog?.models[0]?.id, b.id); + await act(async () => snapshot.refresh()); await answer(3, null); + assert.equal(snapshot.failed, true); assert.equal(snapshot.catalog?.models[0]?.id, b.id); + await act(async () => snapshot.refresh()); assert.equal(snapshot.failed, false); + await answer(4, fresh); +}); + +test("OpenCode's saved shared credential names both Pi providers without changing addable-provider semantics", () => { + const providers = [{ id: "opencode", envVar: "OPENCODE_API_KEY", label: "OpenCode Zen" }, { id: "opencode-go", envVar: "OPENCODE_API_KEY", label: "OpenCode Go" }]; + assert.equal(providerKeyLabel("OPENCODE_API_KEY", providers), "OpenCode (Zen / Go)"); + assert.equal(providerKeyLabel("OPENCODE_API_KEY", [...providers].reverse()), "OpenCode (Zen / Go)"); + assert.equal(providerKeyLabel("FIXTURE_KEY", [provider]), "Fixture"); + assert.equal(classifyKeys([{ id: "OPENCODE_API_KEY", info: null, present: true, masked: "…fake", source: "file", shadowedByEnv: false }], providers).addable.length, 0); +}); diff --git a/tests/render-stats-bar.test.tsx b/tests/render-stats-bar.test.tsx index 329f186..4f80fa0 100644 --- a/tests/render-stats-bar.test.tsx +++ b/tests/render-stats-bar.test.tsx @@ -89,9 +89,15 @@ describe("stats bar", () => { assert.match(empty, /data-testid="stats-context">\? { + it("prettyModel strips only the vendor path, preserving versions, dates and variants", () => { assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5) - assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max"); + assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max-0902"); + assert.equal(prettyModel("google/gemma-4-27b-it:free"), "gemma-4-27b-it:free"); + assert.equal(prettyModel("anthropic/claude-opus-5:batch"), "claude-opus-5:batch"); + assert.equal(prettyModel("claude-opus-5"), "claude-opus-5"); + assert.equal(prettyModel("claude-opus-5-5"), "claude-opus-5-5"); + assert.equal(prettyModel("model:free"), "model:free"); + assert.equal(prettyModel("model:variant:free"), "model:variant:free"); }); it("provider-qualified stamps (usage contract, model-slots wave)", () => { @@ -99,8 +105,10 @@ describe("stats bar", () => { // the same model via two providers different bookkeeping assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "glm-5.3-flash · openrouter"); assert.equal(prettyModel("openai:gpt-5.2", true), "gpt-5.2 · openai"); - // unqualified on a qualified stamp: provider silently dropped (old shape) - assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "glm-5.3-flash"); + assert.equal(prettyModel("openrouter:google/gemma-4-27b-it:free", true), "gemma-4-27b-it:free · openrouter"); + assert.equal(prettyModel("opencode:claude-opus-5-5", true), "claude-opus-5-5 · opencode"); + // Bare ids are never parsed as usage stamps, even with a colon. + assert.equal(prettyModel("model:free"), "model:free"); // qualified on a bare id: no suffix invented assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm-5.3-flash"); }); diff --git a/tests/settings-pure.test.ts b/tests/settings-pure.test.ts index d629349..3c38f1d 100644 --- a/tests/settings-pure.test.ts +++ b/tests/settings-pure.test.ts @@ -320,7 +320,7 @@ describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", ( }); it("setupStage: keys before models (owner's dependency ordering)", () => { - assert.equal(setupStage(null), "keys", "fetch failed → the honest fallback"); + assert.equal(setupStage(null), "loading", "unknown catalog must not claim keys are missing"); assert.equal(setupStage([]), "keys", "no providers = keys"); assert.equal(setupStage([{ id: "zai", keyPresent: false }]), "keys", "provider present but keyless"); assert.equal(setupStage([{ id: "zai", keyPresent: false }, { id: "openai", keyPresent: true }]), "pickers", "one keyed provider widens the pickers");