/** * Model config page. * * Model entries are persisted as two independent fields, `provider` and `model_id`; * the (provider, model_id) pair is the entry's unique key — **zero * concatenation** anywhere in the pipeline, `model_id` is sent to AgentHub verbatim as the * upstream request id. The dialog's identity section = (group dropdown, upstream id input); * changing either one is a rename, submitted as a paired `renamedFrom` (the server uses it * to migrate the credential and pointers). * * The list is purely for "finding a model": grouped by vendor (group header = logo + vendor * name + count, collapsible), with one card per model within a group — the card shows only * the display name + upstream id + status badges (default / vision / proxy-read), while * context, pricing, and key status are folded into a single line of small text. Clicking a * card opens the config dialog (credentials, context, pricing, vision toggle, plus set as * default / set as vision model / delete); the "add model" entry point lives in each group * header (owner only) and reuses the same dialog — provider is pre-filled with that group; * the protocol follows group semantics: a first-party vendor group doesn't persist * client_type (AgentHub auto-routes by upstream id, with env fallback resolved live from the * id), while custom / user-defined groups / gateways use a fixed OpenAI protocol, and * gateways (OpenRouter / SiliconFlow / Qwen Token Plan) additionally pre-fill their endpoint * base URL; the "get model id / API key" external links sit next to the corresponding * input's label (shown in both add and edit dialogs). The group list ends with an "add * group" action (user-defined groups share custom's semantics; the group appears once the * first model saves successfully — groups are carried by the model entry's provider field, * not persisted separately). The header also holds an owner-only "sync presets" action next * to the search box (union-merge with the built-in catalog, see catalog-sync.ts). * * Saving does a PUT full-table replace (models not present are deleted; an empty apiKey * means keep the existing value); only the owner can edit. */ import { useCallback, useEffect, useMemo, useState } from "react"; import type { CredentialInfo, ModelRefDto, ModelsResponse, ModelsUpdateRequest, ModelTestRequest, ModelUpdateEntry, } from "@prismshadow/penguin-server/api"; import * as api from "../../api/endpoints"; import { S } from "../../lib/strings"; import { apiErrorText } from "../../lib/api-error"; import { useDocumentTitle } from "../../lib/use-document-title"; import { useProject } from "../../state/project"; import { useAuth } from "../../state/auth"; import { USD_TO_CNY, useTheme } from "../../state/theme"; import type { Currency } from "../../state/theme"; import { Button } from "../../components/ui/button"; import { Input } from "../../components/ui/input"; import { FieldError, FieldLabel } from "../../components/ui/field"; import { PasswordInput } from "../../components/ui/password-input"; import { Modal } from "../../components/ui/modal"; import { ConfirmModal } from "../../components/ui/confirm-modal"; import { Select } from "../../components/ui/select"; import { Switch } from "../../components/ui/switch"; import { toastError, toastInfo, toastSuccess } from "../../components/ui/toast"; import { Badge } from "../../components/ui/badge"; import { Chevron } from "../../components/ui/chevron"; import { GlyphIcon } from "../../components/ui/glyph-icon"; import { ProviderLogo } from "../../components/ui/provider-logo"; import { SkeletonList } from "../../components/ui/skeleton"; import { EmptyState } from "../../components/ui/empty-state"; import { formatDateTime, humanizeTokens } from "../../lib/format"; import { MODEL_PROVIDERS, catalogEntryFor, modelHomepageUrl, providerInfo, resolveModelEnv, } from "@prismshadow/penguin-core/model-catalog"; import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog"; import { groupModelRows, isFreeModel, sameModelRef, userProviderInfo } from "./model-grouping"; import { draftKey, loadDraft, saveDraft } from "../chat/draft-cache"; import { syncRowsWithCatalog } from "./catalog-sync"; import { tpsTone, ttftTone } from "./speed-test"; import type { SpeedResult, SpeedTone } from "./speed-test"; /** Display currency follows the user setting (pricing is always stored in USD/million tokens; conversion happens only for display and input). */ const CURRENCY_SYMBOL: Record = { USD: "$", CNY: "¥" }; /** Trailing-zero-trimmed price storage value (keeps up to 6 decimal places, for USD persistence). */ function trimNum(v: number): string { if (!Number.isFinite(v)) return "0"; return String(Math.round(v * 1e6) / 1e6); } /** Trailing-zero-trimmed display/input value (keeps up to 4 decimal places): absorbs floating-point noise from USD<->CNY(x7) round trips. */ function trim4(v: number): string { if (!Number.isFinite(v)) return "0"; return String(Math.round(v * 1e4) / 1e4); } /** USD/million-token string -> display string in the selected currency (with symbol). */ function displayPrice(usdStr: string, currency: Currency): string { const n = Number(usdStr || "0"); const v = currency === "CNY" ? n * USD_TO_CNY : n; return `${CURRENCY_SYMBOL[currency]}${trim4(v)}`; } /** USD storage string -> input string in the selected currency (for edit-form initialization; empty value passes through). */ function usdToInput(usdStr: string, currency: Currency): string { const t = usdStr.trim(); if (!t) return ""; const n = Number(t); if (!Number.isFinite(n)) return t; return currency === "CNY" ? trim4(n * USD_TO_CNY) : trim4(n); } /** Input string in the selected currency -> USD storage string (converted before submit; empty/invalid passes through). */ function inputToUsd(inputStr: string, currency: Currency): string { const t = inputStr.trim(); if (!t) return ""; const n = Number(t); if (!Number.isFinite(n)) return t; return currency === "CNY" ? trimNum(n / USD_TO_CNY) : trimNum(n); } /** Group-header action glyphs (24x24 line paths): add, bulk key, gauge for speed test. */ const PLUS_ICON = "M12 5v14M5 12h14"; const KEY_ICON = "M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"; /** Speed-test glyphs (24x24 line paths): gauge for the group action, clock = TTFT, zap = TPS. */ const GAUGE_ICON = "M12 14l3.5-3.5M20.49 17A10 10 0 1 0 3.5 17"; const CLOCK_ICON = "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20ZM12 7v5l3.5 2"; const ZAP_ICON = "M13 2 3 14h9l-1 8 10-12h-9l1-8Z"; /** Metric tone -> text color classes for the card speed badges. */ const TONE_CLASS: Record = { green: "text-green-600 dark:text-green-400", yellow: "text-amber-600 dark:text-amber-400", red: "text-red-600 dark:text-red-400", }; /** In-page key for one model's speed result. */ const speedKey = (provider: string, modelId: string) => `${provider}\u0000${modelId}`; /** Default context window (tokens) for custom models when left unset. */ const CUSTOM_CONTEXT_DEFAULT = 128000; /** Numeric input filter: context window keeps digits only. */ export function digitsOnly(v: string): string { return v.replace(/[^\d]/g, ""); } /** Numeric input filter: pricing keeps digits and **at most one** decimal point. */ export function decimalOnly(v: string): string { const cleaned = v.replace(/[^\d.]/g, ""); const i = cleaned.indexOf("."); return i === -1 ? cleaned : cleaned.slice(0, i + 1) + cleaned.slice(i + 1).replace(/\./g, ""); } /** Local edit state for one model row (string-typed for form use; parsed uniformly on save). */ export interface RowState { /** * Vendor id (entry field, i.e. group membership): a value not in the catalog list is a * user-defined group, kept **verbatim** — an operation that only edits the key, * for instance, must not silently rewrite it to custom; each forms its own group when * displayed (see model-grouping). */ provider: string; /** Upstream model id (i.e. the stored model_id, sent to AgentHub verbatim). */ modelId: string; /** * The identity as loaded (paired reference): differing from the current (provider, * modelId) in either field means a rename — submitted as a paired renamedFrom, which the * server uses to migrate the credential and pointers. null for a new entry. */ original: ModelRefDto | null; /** Display name from the built-in catalog; absent for custom models. */ displayName?: string; /** * Whether to treat this as a vision model (effective semantics): the server already * resolves this via "TOML vision annotation -> built-in catalog -> default support"; * preset models are annotated by the catalog, custom models are editable (supported by * default). */ vision: boolean; /** Environment variable name used as fallback when api_key is empty (given by the server based on catalog/protocol). */ envKey?: string; contextWindow: string; /** Per-model max output tokens ("" = inherit the Agent setting): caps output per request; user-only, never preset by the catalog. */ maxTokens: string; /** AgentHub client protocol: defaults for preset models (auto-routed), "openai" for new custom models; kept as-is, not user-editable. */ clientType: string; cacheRead: string; cacheWrite: string; output: string; /** Current base_url input; compared against originalBaseUrl to decide omit/override/clear (null). */ baseUrl: string; originalBaseUrl: string; /** Newly entered API key; empty means keep the existing value. */ apiKeyInput: string; clearApiKey: boolean; credential?: CredentialInfo; } /** The row's current paired reference (the config's unique key). */ export function rowRef(row: Pick): ModelRefDto { return { provider: row.provider, modelId: row.modelId }; } /** * After saving a model config, which entries the defaultModel / visionModel pointers should * point to (always paired references). * * The key case is a **rename** (either provider or model_id changes): if a pointer still * points at the old reference, what gets submitted is a reference no longer present in * models, and the server responds with a flat 400 (it validates that defaultModel/ * visionModel must be in models). */ export function nextPointers(args: { /** The paired reference being edited; null for a new model. */ editing: ModelRefDto | null; /** The paired reference after saving (differing from editing in either field means a rename). */ ref: ModelRefDto; action: DialogAction; defaultModel: ModelRefDto | undefined; visionModel: ModelRefDto | undefined; }): { defaultModel: ModelRefDto | undefined; visionModel: ModelRefDto | undefined } { const { editing, ref, action, defaultModel, visionModel } = args; const isNew = editing === null; const renamedFrom = !isNew && !sameModelRef(editing, ref) ? editing : null; const follow = (p: ModelRefDto | undefined) => (sameModelRef(p, renamedFrom) ? ref : p); return { defaultModel: action === "setDefault" ? ref : // The first model added (when there was no previous default) is auto-set as default. isNew && !defaultModel ? ref : follow(defaultModel), visionModel: action === "setVisionModel" ? ref : follow(visionModel), }; } /** Fields in the config dialog that can be highlighted red on error (keys match RowState field names, so they can be cleared per edit action). */ type FieldErrors = Partial< Record< "modelId" | "baseUrl" | "contextWindow" | "maxTokens" | "cacheRead" | "cacheWrite" | "output", string > >; /** * Preset model (present in the built-in catalog): id and vision annotation are read-only, * only credentials/pricing/context are configurable. Determined by the built-in catalog * (not by vendor group): a model added via a group header belongs to that vendor group but * isn't in the catalog, so it's still treated as a custom model when edited (vision is * checkable, base URL is required, an empty context falls back to the default). Matches * the catalog using the **identity as loaded** (original's paired reference). */ function isPreset(row: RowState): boolean { return ( row.original !== null && catalogEntryFor(row.original.provider, row.original.modelId) !== undefined ); } /** Whether this row already has (or will have, after this edit) an API key configured. */ function hasKey(row: RowState): boolean { return ( !row.clearApiKey && (Boolean(row.apiKeyInput.trim()) || Boolean(row.credential?.apiKeyMasked)) ); } /** DTO -> row edit state (exported for unit tests): provider and modelId are both entry fields, never decomposed. */ export function toRow(m: ModelsResponse["models"][number]): RowState { const row: RowState = { provider: m.provider, modelId: m.modelId, original: { provider: m.provider, modelId: m.modelId }, vision: m.vision !== false, contextWindow: m.contextWindow !== undefined ? String(m.contextWindow) : "", maxTokens: m.maxTokens !== undefined ? String(m.maxTokens) : "", clientType: m.clientType ?? "", cacheRead: m.pricing ? String(m.pricing.cacheRead) : "", cacheWrite: m.pricing ? String(m.pricing.cacheWrite) : "", output: m.pricing ? String(m.pricing.output) : "", baseUrl: m.credential?.baseUrl ?? "", originalBaseUrl: m.credential?.baseUrl ?? "", apiKeyInput: "", clearApiKey: false, }; if (m.displayName !== undefined) row.displayName = m.displayName; if (m.envKey !== undefined) row.envKey = m.envKey; if (m.credential) row.credential = m.credential; return row; } function rowToEntry(row: RowState): ModelUpdateEntry { // provider and modelId are always submitted as separate fields ((provider, modelId) is the entry's unique key, no concatenation). const entry: ModelUpdateEntry = { provider: row.provider, modelId: row.modelId }; // Rename (either provider or model_id changing is a key change): include the original paired reference so the server // migrates the credential and unknown fields (otherwise a full-table replace would drop them). if (row.original && !sameModelRef(row.original, rowRef(row))) { entry.renamedFrom = row.original; } // Display name: the server only persists it when it differs from the built-in catalog (keeps preset model configs clean). if (row.displayName?.trim()) entry.displayName = row.displayName.trim(); const cw = Number(row.contextWindow.trim()); if (row.contextWindow.trim() && Number.isFinite(cw)) entry.contextWindow = cw; if (row.clientType.trim()) entry.clientType = row.clientType.trim(); // Supported by default: submit false only when explicitly marked "unsupported" (preset vision models and checked custom models aren't persisted). if (!row.vision) entry.vision = false; // Output cap ("" = inherit the Agent setting): submitted only when filled; omitting clears the stored annotation. const mt = Number(row.maxTokens.trim()); if (row.maxTokens.trim() && Number.isFinite(mt) && mt > 0) entry.maxTokens = mt; const cr = Number(row.cacheRead.trim()); const cwr = Number(row.cacheWrite.trim()); const out = Number(row.output.trim()); if ( row.cacheRead.trim() && row.cacheWrite.trim() && row.output.trim() && Number.isFinite(cr) && Number.isFinite(cwr) && Number.isFinite(out) ) { entry.pricing = { cacheRead: cr, cacheWrite: cwr, output: out }; } if (row.apiKeyInput.trim()) entry.apiKey = row.apiKeyInput.trim(); if (row.clearApiKey) entry.clearApiKey = true; const baseUrl = row.baseUrl.trim(); if (baseUrl !== row.originalBaseUrl) { // Submit only on change: non-empty overrides, empty explicitly sets null to clear. entry.baseUrl = baseUrl ? baseUrl : null; } return entry; } export function ModelsPage() { useDocumentTitle(S.models.title); const { currentProject } = useProject(); const projectId = currentProject?.projectId ?? null; const isOwner = currentProject?.role === "owner"; const userId = useAuth().user?.userId ?? null; /** Per-model speed results (in-memory, reset on every project switch; "pending" while that model's turn is running). */ const [speedResults, setSpeedResults] = useState>(new Map()); /** Group whose speed-test confirmation dialog is open (provider id). */ const [speedFor, setSpeedFor] = useState(null); /** Group currently being speed-tested (provider id); tests run strictly one model at a time. */ const [speedRunning, setSpeedRunning] = useState(null); const [rows, setRows] = useState(null); const [defaultModel, setDefaultModel] = useState(undefined); // Vision model used for describe_image proxy-reads (describes images for session models with vision=false). const [visionModel, setVisionModel] = useState(undefined); /** Edit target: paired reference of an existing row. */ const [editing, setEditing] = useState(null); /** Target group (provider id) for adding a model: taken from the group header entry point, falling back to custom when empty. */ const [addingTo, setAddingTo] = useState(null); const [query, setQuery] = useState(""); /** Collapsed vendor groups (all expanded by default). */ const [collapsed, setCollapsed] = useState>(new Set()); /** Vendor group (provider id) currently having its API key configured in bulk. */ const [groupKeyFor, setGroupKeyFor] = useState(null); /** "Add group" popup (user-defined group): a valid name proceeds to that group's add-model dialog. */ const [addGroupOpen, setAddGroupOpen] = useState(false); const [groupName, setGroupName] = useState(""); const [groupNameError, setGroupNameError] = useState(null); /** Initial load failure: shown inline only when the whole page has no content (there's no context to pop a toast against). */ const [loadError, setLoadError] = useState(null); const [busy, setBusy] = useState(false); // Currency follows the user setting (toggled in sidebar settings). const { currency } = useTheme(); const load = useCallback(async () => { if (!projectId) return; setRows(null); setLoadError(null); // Speed results are keyed by (provider, model_id) only, so another Project's identically // named model would inherit a timing measured against a different endpoint and key — // drop them along with the rows they annotate whenever the active Project changes. setSpeedResults(new Map()); try { const res = await api.getModels(projectId); setRows(res.models.map(toRow)); setDefaultModel(res.defaultModel); setVisionModel(res.visionModel); } catch (e) { setLoadError(apiErrorText(e)); } }, [projectId]); useEffect(() => { void load(); }, [load]); /** * Changes save immediately (dialog confirm / set default / set vision model / delete): * avoids the trap of "still have to click save after confirming". On failure, the error * is echoed back and local changes are kept so the user can fix and retry. */ const persist = async ( nextRows: RowState[], nextDefault: ModelRefDto | undefined, nextVision: ModelRefDto | undefined, /** Success toast text (defaults to "saved"); on failure this function shows an error toast instead. */ successText?: string, ): Promise => { if (!projectId) return false; setBusy(true); // The vision model pointer must point to a row that still exists and isn't marked "doesn't support images" (invalidated on delete/re-annotation). const effectiveVision = nextVision && nextRows.some((r) => sameModelRef(rowRef(r), nextVision) && r.vision) ? nextVision : undefined; try { const body: ModelsUpdateRequest = { models: nextRows.map(rowToEntry) }; if (nextDefault) body.defaultModel = nextDefault; if (effectiveVision) body.visionModel = effectiveVision; const res = await api.putModels(projectId, body); setRows(res.models.map(toRow)); setDefaultModel(res.defaultModel); setVisionModel(res.visionModel); // Default model changed: drop the stored draft's model selection so the draft chat // follows the new default (a stored pick would otherwise pin the old model forever). if (userId && res.defaultModel && !sameModelRef(res.defaultModel, defaultModel)) { const key = draftKey(userId, projectId); const draft = loadDraft(key); if (draft.modelRef) saveDraft(key, { ...draft, modelRef: undefined }); } toastSuccess(successText ?? S.common.saved); return true; } catch (e) { setRows(nextRows); if (nextDefault !== undefined) setDefaultModel(nextDefault); if (effectiveVision !== undefined) setVisionModel(effectiveVision); toastError(apiErrorText(e)); return false; } finally { setBusy(false); } }; const groups = useMemo(() => (rows ? groupModelRows(rows, query) : []), [rows, query]); /** * "Sync presets": merge the built-in catalog into the current table (union; the catalog * wins on differing preset entries, local additions and API keys stay untouched — see * catalog-sync.ts). No-op with a toast when everything is already up to date. */ const syncPresets = async () => { if (!rows) return; const merged = syncRowsWithCatalog(rows); if (merged.added === 0 && merged.updated === 0) { toastInfo(S.models.syncUpToDate); return; } await persist( merged.rows, defaultModel, visionModel, S.models.syncDone(merged.added, merged.updated), ); }; /** * Group speed test: one real request per model, strictly sequential (concurrent probes * trip provider rate limits), each result written to the card as it lands. The * confirmation dialog (speedFor) has already warned about quota by the time this runs. */ const runSpeedTest = async (providerId: string) => { if (!projectId || !rows) return; const targets = rows.filter((r) => r.provider === providerId); setSpeedRunning(providerId); try { for (const row of targets) { const key = speedKey(row.provider, row.modelId); setSpeedResults((prev) => new Map(prev).set(key, "pending")); try { const res = await api.testModel(projectId, { provider: row.provider, modelId: row.modelId, speed: true, }); setSpeedResults((prev) => new Map(prev).set(key, res)); } catch (e) { setSpeedResults((prev) => new Map(prev).set(key, { ok: false, message: apiErrorText(e), }), ); } } } finally { setSpeedRunning(null); } }; /** * "Add group" confirm: a valid name that doesn't conflict with a built-in group or an * existing provider proceeds directly to that group's add-model dialog — groups are * carried by the model entry's provider field and aren't persisted separately, so the * group appears once the first model saves successfully (canceling leaves nothing behind). */ const confirmAddGroup = () => { const name = groupName.trim(); if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(name)) { setGroupNameError(S.models.groupNameInvalid); return; } if (MODEL_PROVIDERS.some((p) => p.id === name) || rows?.some((r) => r.provider === name)) { setGroupNameError(S.models.groupNameExists); return; } setAddGroupOpen(false); setGroupName(""); setAddingTo(name); }; const editingRow = editing !== null ? rows?.find((r) => sameModelRef(rowRef(r), editing)) : undefined; if (!projectId) return null; const toggleGroup = (id: string) => setCollapsed((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); return (

{S.models.title}

{!isOwner && (

{S.models.readOnlyHint}

)}
{/* The header holds search plus the owner-only "sync presets" action (add-model entry points live in each group header); on narrow screens (flex-wrap wraps it to its own line) the search box shrinks flexibly, fixed width at >=sm. */}
setQuery(e.target.value)} placeholder={S.models.searchPlaceholder} />
{isOwner && ( )}
{rows === null ? ( ) : rows.length === 0 ? ( setAddingTo("custom")}>{S.models.addCustom} } /> ) : groups.length === 0 ? ( ) : (
{groups.map((group) => { const open = !collapsed.has(group.provider.id); return (
{/* Group header: collapse button (logo + vendor name + count) + group-level actions on the right (bulk configure key, get-key link). Actions are separate elements — can't be nested inside the collapse button (buttons can't nest). The hover highlight applies to the whole header row (not individual buttons) so the header reads as a single unit. */}
{isOwner && ( // Add-model entry point: present on every group header (including // custom), new models belong to that group. The page header has no // global add button, so this doesn't collapse on narrow screens either. )} {isOwner && group.provider.id !== "custom" && ( // Not enough room on phone width for group-level actions: collapse it // (per-model keys can still be configured in the card dialog). Wrapped // in a span and hidden there: passing hidden directly to Button conflicts // with its inline-flex base class. )} {isOwner && ( )} {group.provider.apiKeyUrl && ( // Not enough room on phone width for all group-level actions: collapse // it the same way as "bulk configure key", keeping the add entry and // vendor name + count from crowding each other. {S.models.getApiKey} ↗ )} {/* Collapse arrow sits at the far right of the header (after group actions); it too can be clicked to collapse. */}
{/* Expand/collapse height transition: grid-template-rows tweens between 0fr and 1fr, with the inner overflow-hidden handling clipping — pure CSS, no need to measure content height. Content stays in the DOM while collapsed (height is 0), so both directions animate. */}
{/* inert while collapsed: a card with zero height shouldn't still be Tab-focusable or clickable. */}
{group.rows.length === 0 ? ( // An empty group only ever occurs for custom (always shown when there's no search query, to host the add entry point).

{S.models.groupEmptyHint}

) : ( group.rows.map((row) => ( setEditing(rowRef(row))} /> )) )}
); })} {isOwner && query.trim() === "" && ( // "Add group" (user-defined group): hidden while searching (the group list itself is being filtered). )}
)} {loadError &&

{loadError}

}
{rows && groupKeyFor && ( p.id === groupKeyFor) ?? userProviderInfo(groupKeyFor) } count={rows.filter((r) => r.provider === groupKeyFor).length} onClose={() => setGroupKeyFor(null)} onSubmit={(key) => { const target = groupKeyFor; setGroupKeyFor(null); const affected = rows.filter((r) => r.provider === target); if (affected.length === 0) return; const nextRows = rows.map((r) => r.provider === target ? { ...r, apiKeyInput: key, clearApiKey: false } : r, ); // Success toast is shown inside persist (with "configured N" text); on failure // only an error toast is shown, no false success report (persist swallows the // error and doesn't reject, so a .then can't unconditionally report success). void persist( nextRows, defaultModel, visionModel, S.models.groupKeyApplied(affected.length), ); }} /> )} {speedFor !== null && ( setSpeedFor(null)}>

{S.models.speedTestConfirm(rows?.filter((r) => r.provider === speedFor).length ?? 0)}

)} {addGroupOpen && ( setAddGroupOpen(false)} widthClass="sm:max-w-sm" footer={ <> } >

{S.models.addGroupDesc}

)} {rows && (addingTo !== null || editingRow) && ( { setEditing(null); setAddingTo(null); }} onSubmit={(next, action) => { const isNew = addingTo !== null; setEditing(null); setAddingTo(null); if (action === "remove") { // Filter by the **identity as loaded**: rows / pointers are both keyed by the // paired reference as loaded. If the user edited identity fields before // deleting, next's current reference wouldn't match any row -> nothing gets // deleted while it still reports "saved". const removed = next.original; void persist( rows.filter((r) => !sameModelRef(rowRef(r), removed)), sameModelRef(removed, defaultModel) ? undefined : defaultModel, sameModelRef(removed, visionModel) ? undefined : visionModel, ); return; } const nextRows = isNew ? [...rows, next] : rows.map((r) => (sameModelRef(rowRef(r), editing) ? next : r)); const ptr = nextPointers({ editing: isNew ? null : editing, ref: rowRef(next), action, defaultModel, visionModel, }); void persist(nextRows, ptr.defaultModel, ptr.visionModel); }} /> )}
); } // --------------------------------------------------------------------------- // Model card // --------------------------------------------------------------------------- /** * Card: display name + upstream id + status badges; context / pricing / key status folded * into one line of small text; group speed-test results (TTFT / TPS, tone-colored) ride the * title row's right edge. The whole card is clickable (the model homepage link lives in the * config dialog). */ function ModelCard({ row, currency, isDefault, isVisionModel, speed, onOpen, }: { row: RowState; currency: Currency; isDefault: boolean; isVisionModel: boolean; speed?: SpeedResult | "pending"; onOpen: () => void; }) { const priced = row.cacheRead || row.cacheWrite || row.output; const meta = [ row.contextWindow ? humanizeTokens(Number(row.contextWindow)) : null, // Three prices (cache read / cache write / output); units are explained in the config dialog, not repeated on the card. priced ? `${displayPrice(row.cacheRead, currency)} / ${displayPrice(row.cacheWrite, currency)} / ${displayPrice(row.output, currency)}` : null, // Key status: shows the mask when configured, otherwise "not configured" (doesn't mention environment variables). row.credential?.apiKeyMasked && !row.clearApiKey ? row.credential.apiKeyMasked : hasKey(row) ? S.models.keyConfigured : S.models.noKey, ].filter((v): v is string => v !== null); const speedBadges = speed === "pending" ? ( {S.models.speedPending} ) : speed ? ( speed.ok ? ( {speed.ttftMs !== undefined && ( {Math.round(speed.ttftMs)}ms )} {speed.tps !== undefined && ( {speed.tps} tok/s )} ) : ( {S.models.speedFailed} ) ) : null; return ( ); } // --------------------------------------------------------------------------- // Config dialog (shared by editing an existing model / adding a custom model) // --------------------------------------------------------------------------- type DialogAction = "save" | "setDefault" | "setVisionModel" | "remove"; /** Confirmation text per action (S is a live runtime binding, must be read at render time, not frozen at module scope). */ const CONFIRM_TITLE: Record string> = { save: () => S.models.confirmSaveTitle, setDefault: () => S.models.confirmDefaultTitle, setVisionModel: () => S.models.confirmVisionModelTitle, remove: () => S.models.confirmDeleteTitle, }; const CONFIRM_BODY: Record string> = { save: (n) => S.models.confirmSave(n), setDefault: (n) => S.models.confirmDefault(n), setVisionModel: (n) => S.models.confirmVisionModel(n), remove: (n) => S.models.confirmDelete(n), }; function ModelDialog({ projectId, row, addProvider, existingRefs, currency, canEdit, isDefault, isVisionModel, onClose, onSubmit, }: { projectId: string; row: RowState | null; /** Target group for add mode (row is null): the group of the header entry point / falls back to custom when empty. */ addProvider: string; existingRefs: ModelRefDto[]; currency: Currency; canEdit: boolean; isDefault: boolean; isVisionModel: boolean; onClose: () => void; onSubmit: (row: RowState, action: DialogAction) => void; }) { // Pricing input is displayed/entered in the current currency; converted back to USD storage on submit (RowState always stores USD). const [form, setForm] = useState(() => { if (row) { return { ...row, cacheRead: usdToInput(row.cacheRead, currency), cacheWrite: usdToInput(row.cacheWrite, currency), output: usdToInput(row.output, currency), }; } // New model: protocol follows group semantics — a first-party vendor group // doesn't persist client_type (AgentHub auto-routes by upstream id, with env fallback // resolved live from the id); custom / user-defined groups / gateways use a fixed // openai protocol (env fallback OPENAI_*), and gateways additionally pre-fill their // endpoint base URL. provider keeps the entry point's original value (a user-defined // group must not collapse into custom), stored as a separate field from model_id, with // no concatenation on save. const info = providerInfo(addProvider); const vendorAdd = info !== undefined && info.id !== "custom" && info.gatewayBaseUrl === undefined; return { provider: addProvider, modelId: "", original: null, vision: true, contextWindow: "", maxTokens: "", clientType: vendorAdd ? "" : "openai", cacheRead: "", cacheWrite: "", output: "", baseUrl: info?.gatewayBaseUrl ?? "", originalBaseUrl: "", apiKeyInput: "", clearApiKey: false, }; }); /** Field-level validation errors: text below the corresponding input, input highlighted red — closer to the error site than a top-level banner. */ const [fieldErrors, setFieldErrors] = useState({}); /** Connectivity test in progress. */ const [testing, setTesting] = useState(false); /** * Action pending confirmation: anything that writes to the Project config goes through a * confirmation step — save config / set as default / set as vision proxy model / delete. * (Adding a new custom model isn't confirmed: opening the dialog is itself a clear intent.) */ const [confirming, setConfirming] = useState(null); const isNew = row === null; const preset = row !== null && isPreset(row); const set = (patch: Partial) => { setForm((prev) => ({ ...prev, ...patch })); // Clear the error marker for whichever field was changed (keys match RowState field names). setFieldErrors((prev) => { const next = { ...prev }; for (const k of Object.keys(patch)) delete next[k as keyof FieldErrors]; return next; }); }; /** * Connectivity test: POST /models/test, sending the paired reference (provider, modelId) * in the request body (no URL-encoding concerns), along with the form's not-yet-saved * apiKey / baseUrl as overrides — so the user can verify right after typing a key without * saving first (an unsaved new model is entirely sourced from the request body: verify * before persisting). */ const runTest = async () => { setTesting(true); try { // Always tests against the **current form draft**, unaffected by already-persisted // values (per user intent: test exactly what's typed right now): // - API key: use it if newly typed; if "clear" is checked, send clearApiKey (the // server won't fall back to the stored key); only if neither applies does the server // read the stored key (the frontend never sees the plaintext key, only its mask). // - base URL isn't sensitive, so the frontend always sends the form's current value (empty means null = explicitly no base URL). const key = form.apiKeyInput.trim(); const bu = form.baseUrl.trim(); const body: ModelTestRequest = { provider: form.provider, modelId: form.modelId.trim(), baseUrl: bu ? bu : null, }; if (key) body.apiKey = key; else if (form.clearApiKey) body.clearApiKey = true; if (form.clientType.trim()) body.clientType = form.clientType.trim(); const res = await api.testModel(projectId, body); if (res.ok) toastSuccess(S.models.testOk(res.latencyMs ?? 0)); else toastError(S.models.testFailed(res.message ?? "")); } catch (e) { toastError(S.models.testFailed(apiErrorText(e))); } finally { setTesting(false); } }; /** * Validate and convert pricing back to USD storage; returns null on validation failure — * every error is placed below the offending input, which is highlighted red (no more * top-level banner: it's too far from the error site, and with three price fields it's * hard to tell which one is wrong). */ // base URL required-field policy: an OpenAI-protocol endpoint can't be // inferred — required for custom / user-defined groups and entries with an explicit // openai protocol (gateway groups already have it pre-filled); optional for entries // auto-routed within a first-party vendor group (the client has its own official // default endpoint). Shared by validation and the label's required "*" mark. const openAiLike = form.clientType.trim().toLowerCase().includes("openai") || form.provider === "custom" || providerInfo(form.provider) === undefined; const baseUrlRequired = !preset && openAiLike; const validated = (): RowState | null => { const modelId = form.modelId.trim(); const ref: ModelRefDto = { provider: form.provider, modelId }; const baseUrl = form.baseUrl.trim(); const errs: FieldErrors = {}; if (!modelId) errs.modelId = S.common.requiredField; // A new or renamed (provider, modelId) must not duplicate another entry (renaming back to itself isn't a conflict). else if (!sameModelRef(ref, form.original) && existingRefs.some((r) => sameModelRef(r, ref))) { errs.modelId = S.models.modelIdExists; } if (baseUrlRequired && !baseUrl) errs.baseUrl = S.models.baseUrlRequired; // Under PUT full-table replace semantics, omitting pricing means deleting it: all three // prices must be either all empty or all filled, to avoid a partial entry silently // clearing already-configured pricing (context window likewise must be a valid number, to prevent silent loss). const priceFields = [ ["cacheRead", form.cacheRead.trim()], ["cacheWrite", form.cacheWrite.trim()], ["output", form.output.trim()], ] as const; const filled = priceFields.filter(([, v]) => v !== "").length; for (const [key, v] of priceFields) { // Partial fill: highlight the missing fields red (the filled-in ones are fine). if (v === "" && filled > 0) errs[key] = S.models.pricingAllOrNone; else if (v !== "" && !Number.isFinite(Number(v))) errs[key] = S.models.pricingInvalid; } const contextWindow = form.contextWindow.trim(); if (contextWindow && !Number.isFinite(Number(contextWindow))) { errs.contextWindow = S.models.contextWindowInvalid; } // Output cap: digits-only input can still hold "0"/pasted junk; the server requires a positive integer. const maxTokensInput = form.maxTokens.trim(); if ( maxTokensInput && !(Number.isInteger(Number(maxTokensInput)) && Number(maxTokensInput) > 0) ) { errs.maxTokens = S.models.maxTokensInvalid; } if (Object.keys(errs).length > 0) { setFieldErrors(errs); return null; } setFieldErrors({}); return { ...form, modelId, // Custom models with an empty context window fall back to the default value (preset models left empty just mean "unknown", not auto-filled). contextWindow: !preset && !contextWindow ? String(CUSTOM_CONTEXT_DEFAULT) : form.contextWindow, cacheRead: inputToUsd(form.cacheRead, currency), cacheWrite: inputToUsd(form.cacheWrite, currency), output: inputToUsd(form.output, currency), }; }; const submit = (action: DialogAction) => { const next = validated(); if (next) onSubmit(next, action); }; // Provider info for the current group (updates live as the group dropdown // changes): the "get model id / API key" links come from it (shown next to // the model id and API key labels in both the add and edit dialogs; custom // and self-defined groups have no link). const dialogProvider = providerInfo(form.provider); // env fallback resolves live from the current form (uses the same // resolveModelEnv as the server's getModels): explicit // client_type takes priority, otherwise auto-route by model_id; no // fallback if it can't be routed. const liveEnvKey = resolveModelEnv( form.modelId.trim(), form.clientType.trim() || undefined, )?.envKey; // First-party provider group (built-in, non-gateway, non-custom): adding // goes through auto-routing — show a hint when the id can't be routed // (doesn't block saving: the routing table evolves with the AgentHub // version, so it's judged at runtime). const vendorGroup = dialogProvider !== undefined && dialogProvider.id !== "custom" && dialogProvider.gatewayBaseUrl === undefined; const autoRouteMiss = isNew && vendorGroup && !form.clientType.trim() && form.modelId.trim() !== "" && liveEnvKey === undefined; /** Identity section: upstream model id (renamable; "get model id" link next * to the label) + display name and group side by side (both editable; * group is the entry's provider field — changing either is a key change, * submitted together as renamedFrom). */ const identityFields = ( <>
set({ displayName: e.target.value })} placeholder={S.models.displayNameHint} />
); // Hint for a blank API key: an existing key means keep the original value; // no existing key but an env var fallback exists means use the env var // (the fallback name resolves live, so it updates as the id / protocol is edited). const apiKeyHint = form.credential?.apiKeyMasked ? S.models.apiKeyKeepHint : liveEnvKey ? S.models.apiKeyEnvHint(liveEnvKey) : S.models.apiKeyKeepHint; // Default endpoint note (zhipu / moonshot each have domestic / international // endpoints): shown only when the env fallback hint appears (no existing key) // and this entry actually goes through the provider's own client (the // resolved envKey matches the provider) — entries going through the OpenAI // client (OPENAI_API_KEY) have no provider default endpoint to speak of. const envNote = !form.credential?.apiKeyMasked && liveEnvKey && liveEnvKey === dialogProvider?.envKey ? S.models.providerEnvNotes[form.provider] : undefined; return ( {canEdit && ( )} } >
{/* Header: logo + display name + badges + upstream id (existing model); the model homepage entry lives here as a small secondary button on the right (moved out of the form body — it's a property of the model, not an input). */} {!isNew && (
{form.displayName ?? form.modelId} {isDefault && {S.models.default}} {form.vision && {S.models.visionBadge}} {isVisionModel && {S.models.visionModelBadge}} {/* Upstream id in small text: when there's no display name, the main line is already showing it, so don't repeat. */} {form.displayName !== undefined && ( {form.modelId} )}
{row && modelHomepageUrl(row.provider, row.modelId) && ( {S.models.homepage} {/* External-link glyph (opens in a new tab) */} )}
)} {/* Adding a model: protocol note first (first-party provider group = auto-route by id; custom / self-defined group / gateway = fixed OpenAI protocol), then the identity fields ("get model id / API key" links next to the respective inputs; fill in the id to test connectivity — verify before saving). */} {isNew && ( <>

{vendorGroup ? S.models.addAutoRouteHint : S.models.addProtocolHint}

{identityFields} )} {/* Model-level actions pinned at the top: test connectivity (for a new model, fill in the id and key to verify before saving) / set default / set as vision proxy model / remove. */} {canEdit && (
{!isNew && !isDefault && ( )} {!isNew && form.vision && !isVisionModel && ( )} {!isNew && ( <> )}
)} {/* 1) API key — the most commonly used, placed first in the field section; "get API key" link next to the label. PasswordInput carries its own show/hide toggle and brings its own