"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { useIsMobile } from "@/hooks/useIsMobile"; import { useI18n } from "@/hooks/useI18n"; import type { ModelCatalogPreset, ModelCatalogRecommendation } from "@/lib/model-catalog"; import type { DiscoveredModel } from "@/lib/model-discovery"; import { hasModelCostDraftValue, modelCostToDraft, parseCompleteModelCost, serializeHeaderRows, setCompatBool, updateHeaderRow, type HeaderRow, type ModelCostDraft, type ModelCostKey, } from "./models-config-helpers"; // Color icons (have their own fill colors — no background needed) import AnthropicIcon from "@lobehub/icons/es/Anthropic/components/Mono"; import OpenAIIcon from "@lobehub/icons/es/OpenAI/components/Mono"; import GoogleColorIcon from "@lobehub/icons/es/Google/components/Color"; import DeepSeekColorIcon from "@lobehub/icons/es/DeepSeek/components/Color"; import GroqIcon from "@lobehub/icons/es/Groq/components/Mono"; import MistralColorIcon from "@lobehub/icons/es/Mistral/components/Color"; import MoonshotIcon from "@lobehub/icons/es/Moonshot/components/Mono"; import MinimaxColorIcon from "@lobehub/icons/es/Minimax/components/Color"; import FireworksColorIcon from "@lobehub/icons/es/Fireworks/components/Color"; import HuggingFaceColorIcon from "@lobehub/icons/es/HuggingFace/components/Color"; import CerebrasColorIcon from "@lobehub/icons/es/Cerebras/components/Color"; import OpenRouterIcon from "@lobehub/icons/es/OpenRouter/components/Mono"; import XAIIcon from "@lobehub/icons/es/XAI/components/Mono"; import CloudflareColorIcon from "@lobehub/icons/es/Cloudflare/components/Color"; import VercelIcon from "@lobehub/icons/es/Vercel/components/Mono"; import GithubCopilotIcon from "@lobehub/icons/es/GithubCopilot/components/Mono"; import AwsColorIcon from "@lobehub/icons/es/Aws/components/Color"; import AzureColorIcon from "@lobehub/icons/es/Azure/components/Color"; import KimiColorIcon from "@lobehub/icons/es/Kimi/components/Color"; import QwenColorIcon from "@lobehub/icons/es/Qwen/components/Color"; import ZhipuColorIcon from "@lobehub/icons/es/Zhipu/components/Color"; import CohereColorIcon from "@lobehub/icons/es/Cohere/components/Color"; import PerplexityColorIcon from "@lobehub/icons/es/Perplexity/components/Color"; import TogetherColorIcon from "@lobehub/icons/es/Together/components/Color"; import GrokIcon from "@lobehub/icons/es/Grok/components/Mono"; import AntGroupColorIcon from "@lobehub/icons/es/AntGroup/components/Color"; import NvidiaColorIcon from "@lobehub/icons/es/Nvidia/components/Color"; import OpenCodeIcon from "@lobehub/icons/es/OpenCode/components/Mono"; import XiaomiMiMoIcon from "@lobehub/icons/es/XiaomiMiMo/components/Mono"; import ZAIIcon from "@lobehub/icons/es/ZAI/components/Mono"; type IconComponent = React.ComponentType<{ size?: number | string; style?: React.CSSProperties }>; // hasColor=true → Color icon (self-colored SVG, no wrapper) // hasColor=false → Mono icon (rendered with currentColor, inherits theme text color) const PROVIDER_ICONS: Record = { "anthropic": { Icon: AnthropicIcon, hasColor: false }, "openai": { Icon: OpenAIIcon, hasColor: false }, "openai-codex": { Icon: OpenAIIcon, hasColor: false }, "google": { Icon: GoogleColorIcon, hasColor: true }, "google-vertex": { Icon: GoogleColorIcon, hasColor: true }, "ant-ling": { Icon: AntGroupColorIcon, hasColor: true }, "deepseek": { Icon: DeepSeekColorIcon, hasColor: true }, "groq": { Icon: GroqIcon, hasColor: false }, "mistral": { Icon: MistralColorIcon, hasColor: true }, "moonshotai": { Icon: MoonshotIcon, hasColor: false }, "moonshotai-cn": { Icon: MoonshotIcon, hasColor: false }, "moonshot": { Icon: MoonshotIcon, hasColor: false }, "minimax": { Icon: MinimaxColorIcon, hasColor: true }, "minimax-cn": { Icon: MinimaxColorIcon, hasColor: true }, "fireworks": { Icon: FireworksColorIcon, hasColor: true }, "huggingface": { Icon: HuggingFaceColorIcon, hasColor: true }, "cerebras": { Icon: CerebrasColorIcon, hasColor: true }, "openrouter": { Icon: OpenRouterIcon, hasColor: false }, "xai": { Icon: XAIIcon, hasColor: false }, "cloudflare-ai-gateway": { Icon: CloudflareColorIcon, hasColor: true }, "cloudflare-workers-ai": { Icon: CloudflareColorIcon, hasColor: true }, "vercel-ai-gateway": { Icon: VercelIcon, hasColor: false }, "github-copilot": { Icon: GithubCopilotIcon, hasColor: false }, "amazon-bedrock": { Icon: AwsColorIcon, hasColor: true }, "azure-openai-responses": { Icon: AzureColorIcon, hasColor: true }, "kimi-coding": { Icon: KimiColorIcon, hasColor: true }, "nvidia": { Icon: NvidiaColorIcon, hasColor: true }, "opencode": { Icon: OpenCodeIcon, hasColor: false }, "opencode-go": { Icon: OpenCodeIcon, hasColor: false }, "qwen": { Icon: QwenColorIcon, hasColor: true }, "xiaomi": { Icon: XiaomiMiMoIcon, hasColor: false }, "xiaomi-token-plan-ams": { Icon: XiaomiMiMoIcon, hasColor: false }, "xiaomi-token-plan-cn": { Icon: XiaomiMiMoIcon, hasColor: false }, "xiaomi-token-plan-sgp": { Icon: XiaomiMiMoIcon, hasColor: false }, "zai": { Icon: ZAIIcon, hasColor: false }, "zai-coding-cn": { Icon: ZAIIcon, hasColor: false }, "zhipu": { Icon: ZhipuColorIcon, hasColor: true }, "cohere": { Icon: CohereColorIcon, hasColor: true }, "perplexity": { Icon: PerplexityColorIcon, hasColor: true }, "together": { Icon: TogetherColorIcon, hasColor: true }, "grok": { Icon: GrokIcon, hasColor: false }, }; // ── Types ───────────────────────────────────────────────────────────────────── interface OAuthProvider { id: string; name: string; usesCallbackServer: boolean; loggedIn: boolean; /** Provider also accepts an API key, so it appears in both picker sections. */ supportsApiKey?: boolean; } interface ApiKeyProvider { id: string; displayName: string; configured: boolean; source?: string; modelCount: number; /** Provider also supports OAuth, so it appears in both picker sections. */ supportsOAuth?: boolean; } type OAuthLoginState = | { phase: "idle" } | { phase: "connecting" } | { phase: "auth"; url: string; instructions: string | null; token: string } | { phase: "device_code"; userCode: string; verificationUri: string; intervalSeconds: number | null; expiresInSeconds: number | null } | { phase: "prompt"; message: string; placeholder: string | null; token: string } | { phase: "select"; message: string; options: { id: string; label: string }[]; token: string } | { phase: "progress"; message: string } | { phase: "success" } | { phase: "error"; message: string }; interface ModelEntry { id: string; name?: string; api?: string; reasoning?: boolean; thinkingLevelMap?: Record; input?: string[]; contextWindow?: number; maxTokens?: number; cost?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; tiers?: unknown }; headers?: Record; compat?: Record; } interface ProviderEntry { baseUrl?: string; api?: string; apiKey?: string; headers?: Record; compat?: Record; models?: ModelEntry[]; modelOverrides?: Record; } interface ModelsJson { providers?: Record; } type ModelTestState = | { phase: "idle" } | { phase: "testing" } | { phase: "success"; latencyMs?: number; status?: number; responseText?: string } | { phase: "error"; message: string; latencyMs?: number; status?: number }; type ModelDiscoveryState = | { phase: "idle" } | { phase: "loading" } | { phase: "success"; models: DiscoveredModel[]; endpoint: string } | { phase: "error"; message: string }; type ModelCatalogState = | { phase: "idle" } | { phase: "loading" } | { phase: "success"; recommendation: ModelCatalogRecommendation; appliedCount: number } | { phase: "error"; message: string }; type Selection = | { type: "provider"; name: string } | { type: "model"; providerName: string; index: number } | { type: "oauth"; providerId: string } | { type: "apikey"; providerId: string }; const API_OPTIONS = ["openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai"] as const; // ── Form field helpers ──────────────────────────────────────────────────────── function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
{children}
); } const inputStyle = { padding: "6px 9px", background: "var(--bg-panel)", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text)", fontSize: 12, outline: "none", width: "100%", boxSizing: "border-box" as const, }; function TextInput({ value, onChange, placeholder, mono }: { value: string; onChange: (v: string) => void; placeholder?: string; mono?: boolean }) { return onChange(e.target.value)} placeholder={placeholder} style={{ ...inputStyle, fontFamily: mono ? "var(--font-mono)" : "inherit" }} />; } function SecretTextInput({ value, onChange, placeholder, mono, onKeyDown, autoComplete = "off", spellCheck = false, style, }: { value: string; onChange: (v: string) => void; placeholder?: string; mono?: boolean; onKeyDown?: React.KeyboardEventHandler; autoComplete?: string; spellCheck?: boolean; style?: React.CSSProperties; }) { const [visible, setVisible] = useState(false); const { t } = useI18n(); useEffect(() => { if (!value) setVisible(false); }, [value]); return (
onChange(e.target.value)} onKeyDown={onKeyDown} placeholder={placeholder} style={{ ...inputStyle, paddingRight: 34, fontFamily: mono ? "var(--font-mono)" : "inherit" }} autoComplete={autoComplete} spellCheck={spellCheck} />
); } function NumInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) { return onChange(e.target.value)} placeholder={placeholder} style={inputStyle} />; } function Select({ value, onChange, options, required }: { value: string; onChange: (v: string) => void; options: readonly string[]; required?: boolean }) { const { t } = useI18n(); return ( ); } function Check({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) { return ( ); } function SectionTitle({ children }: { children: React.ReactNode }) { return
{children}
; } // ── Provider detail ─────────────────────────────────────────────────────────── function ProviderDetail({ name, provider, onChange, onRename, onDelete, onAddModels }: { name: string; provider: ProviderEntry; onChange: (p: ProviderEntry) => void; onRename: (n: string) => void; onDelete: () => void; onAddModels: (models: DiscoveredModel[]) => void; }) { const { t } = useI18n(); const [editingName, setEditingName] = useState(name); const [discoveryState, setDiscoveryState] = useState({ phase: "idle" }); const [discoveryQuery, setDiscoveryQuery] = useState(""); const [selectedModelIds, setSelectedModelIds] = useState([]); const discoveryRequestIdRef = useRef(0); const selectShownRef = useRef(null); useEffect(() => setEditingName(name), [name]); const set = (k: K, v: ProviderEntry[K]) => onChange({ ...provider, [k]: v }); useEffect(() => { if (!provider.api) onChange({ ...provider, api: "openai-completions" }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [provider.api]); useEffect(() => { discoveryRequestIdRef.current += 1; setDiscoveryState({ phase: "idle" }); setDiscoveryQuery(""); setSelectedModelIds([]); }, [name, provider.baseUrl, provider.api, provider.apiKey]); const handleDiscoverModels = useCallback(async () => { if (!provider.baseUrl?.trim() || discoveryState.phase === "loading") return; const requestId = ++discoveryRequestIdRef.current; setDiscoveryState({ phase: "loading" }); setSelectedModelIds([]); try { const res = await fetch("/api/models-config/discover", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providerName: name, provider: { ...provider, models: undefined } }), }); const data = await res.json() as { models?: DiscoveredModel[]; endpoint?: string; error?: string }; if (requestId !== discoveryRequestIdRef.current) return; if (!res.ok || data.error || !data.models) { setDiscoveryState({ phase: "error", message: data.error ?? `HTTP ${res.status}` }); return; } setDiscoveryState({ phase: "success", models: data.models, endpoint: data.endpoint ?? provider.baseUrl }); } catch (error) { if (requestId !== discoveryRequestIdRef.current) return; setDiscoveryState({ phase: "error", message: error instanceof Error ? error.message : String(error) }); } }, [discoveryState.phase, name, provider]); const existingModelIds = new Set((provider.models ?? []).map((model) => model.id)); const discoveredModels = discoveryState.phase === "success" ? discoveryState.models : []; const normalizedDiscoveryQuery = discoveryQuery.trim().toLocaleLowerCase(); const filteredDiscoveredModels = discoveredModels.filter((model) => !normalizedDiscoveryQuery || model.id.toLocaleLowerCase().includes(normalizedDiscoveryQuery) || model.name?.toLocaleLowerCase().includes(normalizedDiscoveryQuery)); const shownDiscoveredModels = filteredDiscoveredModels.slice(0, 300); const selectableShownIds = shownDiscoveredModels .filter((model) => !existingModelIds.has(model.id)) .map((model) => model.id); const selectedCount = selectedModelIds.filter((id) => !existingModelIds.has(id)).length; const allShownSelected = selectableShownIds.length > 0 && selectableShownIds.every((id) => selectedModelIds.includes(id)); const someShownSelected = !allShownSelected && selectableShownIds.some((id) => selectedModelIds.includes(id)); useEffect(() => { if (selectShownRef.current) selectShownRef.current.indeterminate = someShownSelected; }, [someShownSelected]); const toggleDiscoveredModel = (id: string) => { setSelectedModelIds((current) => current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id]); }; const toggleShownModels = () => { const shownIds = new Set(selectableShownIds); setSelectedModelIds((current) => allShownSelected ? current.filter((id) => !shownIds.has(id)) : Array.from(new Set([...current, ...selectableShownIds]))); }; const addSelectedModels = () => { if (discoveryState.phase !== "success") return; const selected = new Set(selectedModelIds); const additions = discoveryState.models.filter((model) => selected.has(model.id) && !existingModelIds.has(model.id)); if (additions.length === 0) return; onAddModels(additions); setSelectedModelIds([]); }; return (
{t("i18n.provider")}
{editingName !== name && editingName.trim() && ( )} set("baseUrl", v || undefined)} placeholder="https://api.example.com/v1" mono /> set("apiKey", v || undefined)} placeholder="ENV_VAR_NAME, !shell-command, or literal key" mono /> Prefix with ! to run a shell command, or use an env var name setDiscoveryQuery(event.target.value)} placeholder={t("models.discoveryFilterPlaceholder", { count: discoveryState.models.length })} aria-label={t("models.discoveryFilter")} style={{ ...inputStyle, width: "100%", minWidth: 0 }} />
{shownDiscoveredModels.length === 0 ? (
{t("models.discoveryNoMatches")}
) : shownDiscoveredModels.map((model, index) => { const alreadyAdded = existingModelIds.has(model.id); const checked = selectedModelIds.includes(model.id); return ( ); })}
{filteredDiscoveredModels.length > shownDiscoveredModels.length ? t("models.discoveryShowing", { shown: shownDiscoveredModels.length, total: filteredDiscoveredModels.length }) : t("models.discoveryFetched", { count: discoveryState.models.length })}
)}
); } // ── ThinkingLevelMap editor ─────────────────────────────────────────────────── const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; type ThinkingLevel = typeof THINKING_LEVELS[number]; const LEVEL_COLORS: Record = { off: "var(--text-dim)", minimal: "#6b7280", low: "#60a5fa", medium: "#a78bfa", high: "#f472b6", xhigh: "#fb923c", max: "#ef4444", }; function ThinkingLevelMapEditor({ value, onChange, }: { value: Record | undefined; onChange: (v: Record | undefined) => void; }) { const map = value ?? {}; const setLevel = (level: ThinkingLevel, entry: string | null | "omit") => { const next = { ...map }; if (entry === "omit") { delete next[level]; } else { next[level] = entry; } onChange(Object.keys(next).length ? next : undefined); }; return (
{THINKING_LEVELS.map((level) => { const raw = map[level]; const state: "omit" | "null" | "string" = !(level in map) ? "omit" : raw === null ? "null" : "string"; const strVal = typeof raw === "string" ? raw : ""; const color = LEVEL_COLORS[level]; const btnBase: React.CSSProperties = { padding: "4px 10px", fontSize: 10, border: "none", cursor: "pointer", fontWeight: 400, transition: "background 0.1s, color 0.1s", whiteSpace: "nowrap", background: "var(--bg-panel)", color: "var(--text-dim)", }; const btnActive: React.CSSProperties = { background: "var(--accent)", color: "#fff", fontWeight: 600, }; const btnActiveDisabled: React.CSSProperties = { background: "#ef4444", color: "#fff", fontWeight: 600, }; return (
{level}
setLevel(level, e.target.value)} onFocus={() => { if (state !== "string") setLevel(level, strVal || level); }} placeholder={level} maxLength={10} style={{ width: "12ch", background: state === "string" ? "var(--bg)" : "var(--bg-panel)", border: "none", outline: "none", color: state === "string" ? "var(--text)" : "var(--text-dim)", fontFamily: "var(--font-mono)", fontSize: 11, padding: "4px 7px", transition: "background 0.1s, color 0.1s", }} />
); })}
); } // ── Model detail ────────────────────────────────────────────────────────────── const DEEPSEEK_COMPAT = { thinkingFormat: "deepseek", requiresReasoningContentOnAssistantMessages: true, } as const; function hasDeepseekCompat(model: ModelEntry): boolean { return model.compat?.thinkingFormat === "deepseek"; } function setDeepseekCompat(model: ModelEntry, enabled: boolean): ModelEntry { if (enabled) { return { ...model, compat: { ...(model.compat ?? {}), ...DEEPSEEK_COMPAT } }; } if (!model.compat) return model; const rest = { ...model.compat }; delete rest.thinkingFormat; delete rest.requiresReasoningContentOnAssistantMessages; return { ...model, compat: Object.keys(rest).length ? rest : undefined }; } // Compat can be configured at the provider or model level; provider-composer // merges them (model wins) at runtime. The UI reads the effective value so // hand-edited models.json settings are reflected correctly, while toggles // write to the model entry so a per-model override is explicit. function effectiveCompat(provider: ProviderEntry, model: ModelEntry): Record { return { ...(provider.compat ?? {}), ...(model.compat ?? {}) }; } // Editable key/value request-header list for a provider or model. Rows stay // local so a blank draft is never persisted as an invalid HTTP header name. function HeaderListEditor({ headers, onChange }: { headers: Record | undefined; onChange: (h: Record | undefined) => void; }) { const [rows, setRows] = useState(() => Object.entries(headers ?? {}).map( ([name, value], id) => ({ id, name, value }), )); const nextRowIdRef = useRef(rows.length); const applyRows = (next: HeaderRow[]): void => { setRows(next); onChange(serializeHeaderRows(next)); }; const setEntry = (id: number, changes: Partial>): void => { applyRows(updateHeaderRow(rows, id, changes)); }; const removeEntry = (id: number): void => { applyRows(rows.filter((row) => row.id !== id)); }; const rowBtnStyle = { padding: "6px 9px", background: "none", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 4, color: "#ef4444", cursor: "pointer", fontSize: 11, lineHeight: 1, } satisfies React.CSSProperties; return (
{rows.map((row) => (
setEntry(row.id, { name: e.target.value })} placeholder="Header-Name" style={{ ...inputStyle, fontFamily: "var(--font-mono)", flex: 1 }} /> setEntry(row.id, { value: e.target.value })} placeholder="value" style={{ ...inputStyle, fontFamily: "var(--font-mono)", flex: 1 }} />
))}
); } function fillEmptyModelFields( model: ModelEntry, preset: ModelCatalogPreset, ): { model: ModelEntry; appliedCount: number } { const next = { ...model }; let appliedCount = 0; if (!model.name?.trim() && preset.name) { next.name = preset.name; appliedCount += 1; } if (model.reasoning === undefined && preset.reasoning === true) { next.reasoning = true; appliedCount += 1; } if (!model.input?.length && preset.input?.length) { next.input = [...preset.input]; appliedCount += 1; } if (model.contextWindow === undefined && preset.contextWindow !== undefined) { next.contextWindow = preset.contextWindow; appliedCount += 1; } if (model.maxTokens === undefined && preset.maxTokens !== undefined) { next.maxTokens = preset.maxTokens; appliedCount += 1; } if (preset.cost) { const cost = { ...(model.cost ?? {}) }; let filledCostCount = 0; for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) { if (cost[key] === undefined && preset.cost[key] !== undefined) { cost[key] = preset.cost[key]; filledCostCount += 1; } } const completeCost = parseCompleteModelCost(modelCostToDraft(cost)); if (filledCostCount > 0 && completeCost) { next.cost = { ...cost, ...completeCost }; appliedCount += filledCostCount; } } return { model: next, appliedCount }; } function ModelDetail({ providerName, provider, model, onChange, onDelete, }: { providerName: string; provider: ProviderEntry; model: ModelEntry; onChange: (m: ModelEntry) => void; onDelete: () => void; }) { const [testState, setTestState] = useState({ phase: "idle" }); const { t } = useI18n(); const [catalogState, setCatalogState] = useState({ phase: "idle" }); const [costEditing, setCostEditing] = useState(false); const [costDraft, setCostDraft] = useState(() => modelCostToDraft(model.cost)); const costDraftRef = useRef(costDraft); const [advancedOpen, setAdvancedOpen] = useState(false); const catalogRequestIdRef = useRef(0); const catalogUndoRef = useRef(null); const costTemplateRef = useRef(model.cost); const set = (k: K, v: ModelEntry[K]) => onChange({ ...model, [k]: v }); const setCost = (key: ModelCostKey, value: string) => { const nextDraft = { ...costDraftRef.current, [key]: value }; const completeCost = parseCompleteModelCost(nextDraft); const nextModel = { ...model }; costDraftRef.current = nextDraft; setCostDraft(nextDraft); if (completeCost) { nextModel.cost = { ...(costTemplateRef.current ?? {}), ...completeCost }; costTemplateRef.current = nextModel.cost; } else { delete nextModel.cost; } onChange(nextModel); }; const toggleCostEditing = () => { if (costEditing) { setCostEditing(false); return; } costTemplateRef.current = model.cost; const nextDraft = modelCostToDraft(model.cost); costDraftRef.current = nextDraft; setCostDraft(nextDraft); setCostEditing(true); }; const testSummary = (() => { if (testState.phase === "idle") return null; if (testState.phase === "testing") return t("i18n.testingModel"); const meta = [ testState.latencyMs !== undefined ? `${testState.latencyMs}ms` : null, testState.status !== undefined ? `HTTP ${testState.status}` : null, ].filter(Boolean); if (testState.phase === "success") { return [t("i18n.connected"), ...meta, testState.responseText || null].filter(Boolean).join(" · "); } return [t("i18n.failed"), ...meta, testState.message].filter(Boolean).join(" · "); })(); useEffect(() => { setTestState({ phase: "idle" }); }, [providerName, provider.baseUrl, provider.api, provider.apiKey, model.id, model.api]); useEffect(() => { catalogRequestIdRef.current += 1; setCatalogState({ phase: "idle" }); catalogUndoRef.current = null; }, [providerName, provider.baseUrl, model.id]); const handleTest = useCallback(async () => { if (!model.id.trim() || testState.phase === "testing") return; setTestState({ phase: "testing" }); try { const res = await fetch("/api/models-config/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providerName, provider, model }), }); const d = await res.json() as { ok?: boolean; error?: string; latencyMs?: number; status?: number; responseText?: string; }; if (!res.ok || !d.ok) { setTestState({ phase: "error", message: d.error ?? `HTTP ${res.status}`, latencyMs: d.latencyMs, status: d.status, }); return; } setTestState({ phase: "success", latencyMs: d.latencyMs, status: d.status, responseText: d.responseText, }); } catch (e) { setTestState({ phase: "error", message: e instanceof Error ? e.message : String(e) }); } }, [model, provider, providerName, testState.phase]); const handleCatalogFill = useCallback(async () => { const query = model.id.trim(); if (!query || catalogState.phase === "loading") return; const requestId = ++catalogRequestIdRef.current; setCatalogState({ phase: "loading" }); try { const params = new URLSearchParams({ q: query, provider: providerName, limit: "50" }); if (provider.baseUrl?.trim()) params.set("baseUrl", provider.baseUrl.trim()); const res = await fetch(`/api/models-config/catalog?${params}`); const data = await res.json() as { recommendation?: ModelCatalogRecommendation; error?: string }; if (requestId !== catalogRequestIdRef.current) return; if (!res.ok || data.error || !data.recommendation) { setCatalogState({ phase: "error", message: data.error ?? `HTTP ${res.status}` }); return; } const filled = fillEmptyModelFields(model, data.recommendation.preset); if (filled.appliedCount > 0) { catalogUndoRef.current = model; onChange(filled.model); } setCostEditing(false); setCatalogState({ phase: "success", recommendation: data.recommendation, appliedCount: filled.appliedCount, }); } catch (error) { if (requestId !== catalogRequestIdRef.current) return; setCatalogState({ phase: "error", message: error instanceof Error ? error.message : String(error) }); } }, [catalogState.phase, model, onChange, provider.baseUrl, providerName]); const undoCatalogFill = () => { const previous = catalogUndoRef.current; if (!previous) return; catalogUndoRef.current = null; onChange(previous); setCatalogState({ phase: "idle" }); }; const catalogResultSummary = (() => { if (catalogState.phase !== "success") return null; const { recommendation, appliedCount } = catalogState; const applied = appliedCount > 0 ? t("models.catalogFilled", { count: appliedCount }) : t("models.catalogNoEmptyFields"); if (recommendation.price.status === "unreliable") { const price = recommendation.price.reason === "no-exact-match" ? t("models.catalogNoExactMatch") : t("models.catalogPriceUnreliable"); return `${applied} · ${price}`; } const price = recommendation.price.method === "provider" ? t("models.catalogPriceProvider", { provider: recommendation.price.providerName ?? recommendation.price.providerId ?? providerName }) : recommendation.price.method === "base-url" ? t("models.catalogPriceBaseUrl", { provider: recommendation.price.providerName ?? recommendation.price.providerId ?? providerName }) : t("models.catalogPriceConsensus", { support: recommendation.price.support, total: recommendation.price.total, }); return `${applied} · ${price}`; })(); const catalogStatusText = catalogState.phase === "error" ? catalogState.message : catalogResultSummary; const catalogStatusColor = catalogState.phase === "error" ? "#ef4444" : catalogState.phase === "success" && catalogState.recommendation.price.status === "unreliable" ? "#d97706" : "var(--text-dim)"; const costFields = [ { key: "input", label: t("models.costInput") }, { key: "output", label: t("models.costOutput") }, { key: "cacheRead", label: t("models.costCacheRead") }, { key: "cacheWrite", label: t("models.costCacheWrite") }, ] as const; const formatCost = (key: ModelCostKey): string => { const value = model.cost?.[key]; return value === undefined ? t("models.notProvided") : `$${String(value)}`; }; const remainingCompatKeys = new Set(Object.keys(model.compat ?? {})); let compatibilityOverrideCount = 0; if (hasDeepseekCompat(model)) { compatibilityOverrideCount += 1; remainingCompatKeys.delete("thinkingFormat"); remainingCompatKeys.delete("requiresReasoningContentOnAssistantMessages"); } if (Object.prototype.hasOwnProperty.call(model.compat ?? {}, "supportsDeveloperRole")) { compatibilityOverrideCount += 1; remainingCompatKeys.delete("supportsDeveloperRole"); } compatibilityOverrideCount += remainingCompatKeys.size; const advancedSummaryParts = [ model.api ? `API: ${model.api}` : null, Object.keys(model.headers ?? {}).length ? t("models.headersSummary", { count: Object.keys(model.headers ?? {}).length }) : null, compatibilityOverrideCount ? t("models.compatSummary", { count: compatibilityOverrideCount }) : null, Object.keys(model.thinkingLevelMap ?? {}).length ? t("models.thinkingSummary", { count: Object.keys(model.thinkingLevelMap ?? {}).length }) : null, ].filter((part): part is string => Boolean(part)); const advancedSummary = advancedSummaryParts.length ? advancedSummaryParts.join(" · ") : t("models.providerDefaults"); return (
{t("i18n.model")}
{testSummary && ( {testSummary} )}
set("id", v)} placeholder="model-id" mono /> set("name", v || undefined)} placeholder="Display name" />
{t("models.catalogSource")}
{catalogStatusText && (
{catalogStatusText} {catalogUndoRef.current && ( )}
)}
{t("models.capabilities")}
set("reasoning", v || undefined)} /> set("input", v ? ["text", "image"] : undefined)} />
{t("models.modelSpecs")}
set("contextWindow", v ? parseInt(v) : undefined)} placeholder="128000" /> set("maxTokens", v ? parseInt(v) : undefined)} placeholder="16384" />
{t("models.costPerMillion")}
{costEditing ? (
{costFields.map(({ key, label }) => ( setCost(key, v)} placeholder="0" /> ))} {hasModelCostDraftValue(costDraft) && !parseCompleteModelCost(costDraft) && (
{t("models.costAllRequired")}
)}
) : (
{costFields.map(({ key, label }) => { const missing = model.cost?.[key] === undefined; return (
{label}
{formatCost(key)}
); })}
)}
{advancedOpen && (
setInputValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submitCode(loginState.token, inputValue); }} placeholder={loginState.phase === "auth" ? "http://localhost:1455/auth/callback?code=…" : (loginState.placeholder ?? "Enter value…")} style={{ flex: 1, padding: "6px 9px", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text)", fontSize: 12, outline: "none", fontFamily: "var(--font-mono)", boxSizing: "border-box" }} />
)} {loginState.phase === "device_code" && (

Open the verification page and enter this code:

{loginState.userCode}

{loginState.verificationUri} {loginState.expiresInSeconds ? ` Expires in ${Math.ceil(loginState.expiresInSeconds / 60)} minutes.` : ""}

)} {loginState.phase === "progress" && (

{loginState.message}

)} {loginState.phase === "success" && (

{t("i18n.connectedSuccessfully")}

)} {loginState.phase === "error" && (

{loginState.message}

)} {/* Actions */}
{isWorking ? ( ) : ( <> {provider.loggedIn && ( )} )}
); } // ── API Key detail ──────────────────────────────────────────────────────────── function ApiKeyDetail({ provider, onRefresh }: { provider: ApiKeyProvider; onRefresh: () => void }) { const [apiKey, setApiKey] = useState(""); const [saving, setSaving] = useState(false); const [removing, setRemoving] = useState(false); const [error, setError] = useState(null); const [savedOk, setSavedOk] = useState(false); const { t } = useI18n(); // Reset state when provider changes useEffect(() => { setApiKey(""); setError(null); setSavedOk(false); }, [provider.id]); const handleSave = useCallback(async () => { if (!apiKey.trim()) return; setSaving(true); setError(null); setSavedOk(false); try { const res = await fetch(`/api/auth/api-key/${encodeURIComponent(provider.id)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiKey: apiKey.trim() }), }); const d = await res.json() as { success?: boolean; error?: string }; if (!res.ok || d.error) { setError(d.error ?? `HTTP ${res.status}`); } else { setApiKey(""); setSavedOk(true); setTimeout(() => setSavedOk(false), 2000); onRefresh(); } } catch (e) { setError(String(e)); } finally { setSaving(false); } }, [apiKey, provider.id, onRefresh]); const handleRemove = useCallback(async () => { setRemoving(true); setError(null); try { const res = await fetch(`/api/auth/api-key/${encodeURIComponent(provider.id)}`, { method: "DELETE" }); const d = await res.json() as { success?: boolean; error?: string }; if (!res.ok || d.error) setError(d.error ?? `HTTP ${res.status}`); else onRefresh(); } catch (e) { setError(String(e)); } finally { setRemoving(false); } }, [provider.id, onRefresh]); return (
API Key
{provider.configured ? t("i18n.configured") : t("i18n.notConfigured")}

{provider.configured ? `API key is stored. Enter a new key below to replace it, or disconnect to remove it.` : `Enter your ${provider.displayName} API key to enable ${provider.modelCount} model${provider.modelCount !== 1 ? "s" : ""}.`}

{ if (e.key === "Enter" && apiKey.trim()) handleSave(); }} placeholder={provider.configured ? "Enter new key to replace…" : "sk-…"} style={{ flex: 1 }} autoComplete="off" spellCheck={false} mono />
{error &&

{error}

} {provider.configured && ( )}
); } // ── Provider icon ───────────────────────────────────────────────────────────── function ProviderIcon({ id, size }: { id: string; size: number }) { const pi = PROVIDER_ICONS[id]; if (!pi) { const label = id .split(/[-_]/) .filter(Boolean) .slice(0, 2) .map((part) => part[0]) .join("") .toUpperCase() || "?"; return ( ); } // Color icons: self-colored SVG, no wrapper needed if (pi.hasColor) return ; // Mono icons: use currentColor so they adapt to light/dark theme return ; } // ── Add provider picker ─────────────────────────────────────────────────────── interface AddProviderPickerProps { oauthProviders: OAuthProvider[]; apiKeyProviders: ApiKeyProvider[]; onSelectOAuth: (id: string) => void; onSelectApiKey: (id: string) => void; onAddCustom: () => void; onClose: () => void; } function AddProviderPicker({ oauthProviders, apiKeyProviders, onSelectOAuth, onSelectApiKey, onAddCustom, onClose, }: AddProviderPickerProps) { const [search, setSearch] = useState(""); const { t } = useI18n(); const inputRef = useRef(null); useEffect(() => { setTimeout(() => inputRef.current?.focus(), 30); }, []); const q = search.trim().toLowerCase(); const availableOAuth = oauthProviders.filter((p) => !p.loggedIn && (!q || p.name.toLowerCase().includes(q))); const availableApiKey = apiKeyProviders.filter((p) => !p.configured && (!q || p.displayName.toLowerCase().includes(q) || p.id.toLowerCase().includes(q))); const showCustom = !q || "custom".includes(q) || "openai-compatible".includes(q) || "anthropic-compatible".includes(q); const totalCount = availableOAuth.length + availableApiKey.length + (showCustom ? 1 : 0); const cardStyle: React.CSSProperties = { display: "flex", flexDirection: "row", alignItems: "center", gap: 8, padding: "10px 12px", background: "var(--bg-panel)", border: "1px solid var(--border)", borderRadius: 7, boxSizing: "border-box", cursor: "pointer", minWidth: 0, textAlign: "left", transition: "border-color 0.12s, background 0.12s", width: "100%", }; return (
{ if (e.target === e.currentTarget) onClose(); }} >
{/* Search */}
setSearch(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") onClose(); }} placeholder={t("i18n.searchProviders")} style={{ flex: 1, background: "none", border: "none", outline: "none", color: "var(--text)", fontSize: 13, boxSizing: "border-box" }} />
{/* Card grid */}
{totalCount === 0 ? (
{t("i18n.noProviders")}
) : (
{showCustom && (
{t("i18n.custom")}
)} {showCustom && ( )} {availableOAuth.length > 0 && (
{t("i18n.subscriptions")}
)} {availableOAuth.map((p) => ( ))} {availableApiKey.length > 0 && (
0 ? 6 : 0, fontSize: 10, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase", letterSpacing: "0.07em" }}>API Key
)} {availableApiKey.map((p) => ( ))}
)}
); } // ── Main component ──────────────────────────────────────────────────────────── export function ModelsConfig({ onClose }: { onClose: () => void }) { const isMobile = useIsMobile(); const { t } = useI18n(); const [config, setConfig] = useState({ providers: {} }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [savedOk, setSavedOk] = useState(false); const [selection, setSelection] = useState(null); const [oauthProviders, setOauthProviders] = useState([]); const [apiKeyProviders, setApiKeyProviders] = useState([]); const [pickerOpen, setPickerOpen] = useState(false); const loadOAuthProviders = useCallback(() => { fetch("/api/auth/providers") .then((r) => r.json()) .then((d: { providers: OAuthProvider[] }) => setOauthProviders(d.providers)) .catch(() => {}); }, []); const loadApiKeyProviders = useCallback(() => { fetch("/api/auth/all-providers") .then((r) => r.json()) .then((d: { providers: ApiKeyProvider[] }) => setApiKeyProviders(d.providers)) .catch(() => {}); }, []); // A dual-auth provider moves between the two lists when its credential type // changes, so any auth change has to reload both — refreshing only one leaves // the provider rendered twice, and disconnecting the stale row would delete // the credential that was just created (#309). const refreshAuthProviders = useCallback(() => { loadOAuthProviders(); loadApiKeyProviders(); }, [loadOAuthProviders, loadApiKeyProviders]); useEffect(() => { fetch("/api/models-config") .then((r) => r.json()) .then((d: ModelsJson) => { const normalized = d.providers ? d : { ...d, providers: {} }; setConfig(normalized); const keys = Object.keys(normalized.providers ?? {}); if (keys.length > 0) setSelection({ type: "provider", name: keys[0] }); }) .catch(() => setConfig({ providers: {} })) .finally(() => setLoading(false)); refreshAuthProviders(); }, [refreshAuthProviders]); const addCustomProvider = useCallback(() => { let finalName = "new-provider"; let n = 1; while (config.providers?.[finalName]) finalName = `new-provider-${n++}`; setConfig((prev) => ({ ...prev, providers: { ...(prev.providers ?? {}), [finalName]: { api: "openai-completions" } } })); setSelection({ type: "provider", name: finalName }); }, [config.providers]); const updateProvider = useCallback((name: string, p: ProviderEntry) => { setConfig((prev) => ({ ...prev, providers: { ...(prev.providers ?? {}), [name]: p } })); }, []); const renameProvider = useCallback((oldName: string, newName: string) => { setConfig((prev) => { const entries = Object.entries(prev.providers ?? {}); const idx = entries.findIndex(([k]) => k === oldName); if (idx === -1) return prev; entries[idx] = [newName, entries[idx][1]]; return { ...prev, providers: Object.fromEntries(entries) }; }); setSelection((prev) => { if (!prev) return prev; if (prev.type === "provider" && prev.name === oldName) return { type: "provider", name: newName }; if (prev.type === "model" && prev.providerName === oldName) return { ...prev, providerName: newName }; return prev; }); }, []); const deleteProvider = useCallback((name: string) => { setConfig((prev) => { const providers = { ...(prev.providers ?? {}) }; delete providers[name]; return { ...prev, providers }; }); setConfig((prev) => { const remaining = Object.keys(prev.providers ?? {}); setSelection(remaining.length > 0 ? { type: "provider", name: remaining[0] } : null); return prev; }); }, []); const addModel = useCallback((providerName: string) => { setConfig((prev) => { const provider = prev.providers?.[providerName] ?? {}; const models = [...(provider.models ?? []), { id: "" }]; return { ...prev, providers: { ...(prev.providers ?? {}), [providerName]: { ...provider, models } } }; }); setConfig((prev) => { const idx = (prev.providers?.[providerName]?.models?.length ?? 1) - 1; setSelection({ type: "model", providerName, index: idx }); return prev; }); }, []); const addDiscoveredModels = useCallback((providerName: string, discovered: DiscoveredModel[]) => { setConfig((prev) => { const provider = prev.providers?.[providerName] ?? {}; const models = [...(provider.models ?? [])]; const existingIds = new Set(models.map((model) => model.id)); for (const discoveredModel of discovered) { if (existingIds.has(discoveredModel.id)) continue; existingIds.add(discoveredModel.id); models.push({ id: discoveredModel.id, name: discoveredModel.name }); } return { ...prev, providers: { ...(prev.providers ?? {}), [providerName]: { ...provider, models } } }; }); }, []); const updateModel = useCallback((providerName: string, index: number, m: ModelEntry) => { setConfig((prev) => { const provider = prev.providers?.[providerName] ?? {}; const models = [...(provider.models ?? [])]; models[index] = m; return { ...prev, providers: { ...(prev.providers ?? {}), [providerName]: { ...provider, models } } }; }); }, []); const removeModel = useCallback((providerName: string, index: number) => { setConfig((prev) => { const provider = prev.providers?.[providerName] ?? {}; const models = [...(provider.models ?? [])]; models.splice(index, 1); return { ...prev, providers: { ...(prev.providers ?? {}), [providerName]: { ...provider, models: models.length ? models : undefined } } }; }); setSelection({ type: "provider", name: providerName }); }, []); const handleSave = useCallback(async () => { setSaving(true); setSaveError(null); setSavedOk(false); try { const res = await fetch("/api/models-config", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(config), }); const d = await res.json() as { success?: boolean; error?: string }; if (!res.ok || d.error) setSaveError(d.error ?? `HTTP ${res.status}`); else { setSavedOk(true); setTimeout(() => setSavedOk(false), 2000); } } catch (e) { setSaveError(String(e)); } finally { setSaving(false); } }, [config]); const providers = Object.entries(config.providers ?? {}); const activeOAuth = oauthProviders.filter((p) => p.loggedIn); const activeApiKey = apiKeyProviders.filter((p) => p.configured); // Resolve current detail const detailContent = (() => { if (!selection) return null; if (selection.type === "oauth") { const p = oauthProviders.find((p) => p.id === selection.providerId); if (!p) return null; return ; } if (selection.type === "apikey") { const p = apiKeyProviders.find((p) => p.id === selection.providerId); if (!p) return null; return ; } if (selection.type === "provider") { const provider = config.providers?.[selection.name]; if (!provider) return null; return ( updateProvider(selection.name, p)} onRename={(n) => renameProvider(selection.name, n)} onDelete={() => deleteProvider(selection.name)} onAddModels={(models) => addDiscoveredModels(selection.name, models)} /> ); } const provider = config.providers?.[selection.providerName]; const model = provider?.models?.[selection.index]; if (!model) return null; return ( updateModel(selection.providerName, selection.index, m)} onDelete={() => removeModel(selection.providerName, selection.index)} /> ); })(); return ( <>
{ if (e.target === e.currentTarget) onClose(); }}>
{/* Header */}
{t("common.models")} ~/.pi/agent/models.json
{/* Body */}
{/* Left: tree */}
{/* Active OAuth subscriptions */} {activeOAuth.map((p) => { const isSelected = selection?.type === "oauth" && selection.providerId === p.id; return (
setSelection({ type: "oauth", providerId: p.id })} style={{ display: "flex", alignItems: "center", gap: 7, padding: "5px 8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none" }} onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = "var(--bg-hover)"; }} onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = "none"; }} > {p.name}
); })} {/* Active API key providers */} {activeApiKey.map((p) => { const isSelected = selection?.type === "apikey" && selection.providerId === p.id; return (
setSelection({ type: "apikey", providerId: p.id })} style={{ display: "flex", alignItems: "center", gap: 7, padding: "5px 8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none" }} onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = "var(--bg-hover)"; }} onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = "none"; }} > {p.displayName}
); })} {/* Divider before custom providers, only when there are active managed providers */} {(activeOAuth.length > 0 || activeApiKey.length > 0) && providers.length > 0 && (
)} {/* Custom providers */} {loading ? (
{t("i18n.loading")}
) : providers.map(([pName, pData]) => { const isProviderSelected = selection?.type === "provider" && selection.name === pName; const models = pData.models ?? []; return (
{/* Provider row */}
setSelection({ type: "provider", name: pName })} style={{ display: "flex", alignItems: "center", gap: 6, padding: "7px 8px", borderRadius: 5, cursor: "pointer", background: isProviderSelected ? "var(--bg-selected)" : "none" }} onMouseEnter={(e) => { if (!isProviderSelected) e.currentTarget.style.background = "var(--bg-hover)"; }} onMouseLeave={(e) => { if (!isProviderSelected) e.currentTarget.style.background = "none"; }} > {pName}
{/* Model rows */} {models.map((m, i) => { const isModelSelected = selection?.type === "model" && selection.providerName === pName && selection.index === i; return (
setSelection({ type: "model", providerName: pName, index: i })} style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 8px 5px 26px", borderRadius: 5, cursor: "pointer", background: isModelSelected ? "var(--bg-selected)" : "none" }} onMouseEnter={(e) => { if (!isModelSelected) e.currentTarget.style.background = "var(--bg-hover)"; }} onMouseLeave={(e) => { if (!isModelSelected) e.currentTarget.style.background = "none"; }} > {m.id || t("i18n.newModel")} {m.reasoning && ( T )}
); })} {/* Add model button */}
{ e.stopPropagation(); addModel(pName); }} style={{ display: "flex", alignItems: "center", gap: 4, padding: "4px 8px 4px 26px", borderRadius: 5, cursor: "pointer", color: "var(--text-dim)" }} onMouseEnter={(e) => { e.currentTarget.style.color = "var(--accent)"; e.currentTarget.style.background = "var(--bg-hover)"; }} onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-dim)"; e.currentTarget.style.background = "none"; }} > + {t("i18n.model")}
); })}
{/* Add provider */}
{/* Right: detail */}
{loading ? null : detailContent ?? (
{t("i18n.selectProviderModel")}
)}
{/* Footer */}
{saveError && {saveError}}
{pickerOpen && ( setSelection({ type: "oauth", providerId: id })} onSelectApiKey={(id) => setSelection({ type: "apikey", providerId: id })} onAddCustom={addCustomProvider} onClose={() => setPickerOpen(false)} /> )} ); }