Files
pi-agent-integrated/pi-web/components/ModelsConfig.tsx
T

2037 lines
93 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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";
// 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<string, { Icon: IconComponent; hasColor: boolean }> = {
"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;
}
interface ApiKeyProvider {
id: string;
displayName: string;
configured: boolean;
source?: string;
modelCount: number;
}
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<string, string | null>;
input?: string[];
contextWindow?: number;
maxTokens?: number;
cost?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
compat?: Record<string, unknown>;
}
interface ProviderEntry {
baseUrl?: string;
api?: string;
apiKey?: string;
headers?: Record<string, string>;
compat?: Record<string, unknown>;
models?: ModelEntry[];
modelOverrides?: Record<string, unknown>;
}
interface ModelsJson {
providers?: Record<string, ProviderEntry>;
}
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 (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<label style={{ fontSize: 11, color: "var(--text-muted)", fontWeight: 500 }}>{label}</label>
{children}
</div>
);
}
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 <input value={value} onChange={(e) => 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<HTMLInputElement>;
autoComplete?: string;
spellCheck?: boolean;
style?: React.CSSProperties;
}) {
const [visible, setVisible] = useState(false);
const { t } = useI18n();
useEffect(() => {
if (!value) setVisible(false);
}, [value]);
return (
<div style={{ position: "relative", width: "100%", ...style }}>
<input
type={visible ? "text" : "password"}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
style={{ ...inputStyle, paddingRight: 34, fontFamily: mono ? "var(--font-mono)" : "inherit" }}
autoComplete={autoComplete}
spellCheck={spellCheck}
/>
<button
type="button"
onClick={() => setVisible((v) => !v)}
aria-label={visible ? t("i18n.hideDetails") : t("i18n.showDetails")}
title={visible ? t("i18n.hideDetails") : t("i18n.showDetails")}
style={{
position: "absolute",
right: 5,
top: "50%",
transform: "translateY(-50%)",
width: 24,
height: 24,
padding: 0,
border: "none",
background: "transparent",
color: "var(--text-dim)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{visible ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.94 10.94 0 0 1 12 20C7 20 2.73 16.89 1 12a18.45 18.45 0 0 1 5.06-6.94" />
<path d="M9.9 4.24A10.94 10.94 0 0 1 12 4c5 0 9.27 3.11 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<path d="M14.12 14.12A3 3 0 0 1 9.88 9.88" />
<path d="M1 1l22 22" />
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12Z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
</div>
);
}
function NumInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
return <input type="number" value={value} onChange={(e) => 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 (
<select value={value} onChange={(e) => onChange(e.target.value)}
style={{ ...inputStyle, color: value ? "var(--text)" : "var(--text-dim)" }}>
{!required && <option value="">— {t("i18n.default")} / none —</option>}
{options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
);
}
function Check({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", fontSize: 12, color: "var(--text-muted)" }}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)}
style={{ width: 13, height: 13, accentColor: "var(--accent)", cursor: "pointer" }} />
{label}
</label>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return <div style={{ fontSize: 11, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 2 }}>{children}</div>;
}
// ── 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<ModelDiscoveryState>({ phase: "idle" });
const [discoveryQuery, setDiscoveryQuery] = useState("");
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([]);
const discoveryRequestIdRef = useRef(0);
const selectShownRef = useRef<HTMLInputElement>(null);
useEffect(() => setEditingName(name), [name]);
const set = <K extends keyof ProviderEntry>(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 (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<SectionTitle>{t("i18n.provider")}</SectionTitle>
<button onClick={onDelete}
style={{ padding: "3px 8px", background: "none", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 4, color: "#ef4444", cursor: "pointer", fontSize: 11 }}>
{t("i18n.delete")}
</button>
</div>
<Field label={t("i18n.providerName")}>
<TextInput value={editingName} onChange={setEditingName} placeholder="provider-name" mono />
{editingName !== name && editingName.trim() && (
<button onClick={() => onRename(editingName.trim())}
style={{ marginTop: 4, padding: "3px 10px", background: "var(--accent)", border: "none", borderRadius: 4, color: "#fff", cursor: "pointer", fontSize: 11, alignSelf: "flex-start" }}>
{t("i18n.rename")}
</button>
)}
</Field>
<Field label="Base URL">
<TextInput value={provider.baseUrl ?? ""} onChange={(v) => set("baseUrl", v || undefined)}
placeholder="https://api.example.com/v1" mono />
</Field>
<Field label="API Key">
<SecretTextInput value={provider.apiKey ?? ""} onChange={(v) => set("apiKey", v || undefined)}
placeholder="ENV_VAR_NAME, !shell-command, or literal key" mono />
<span style={{ fontSize: 10, color: "var(--text-dim)", marginTop: 2 }}>
Prefix with <code style={{ fontFamily: "var(--font-mono)" }}>!</code> to run a shell command, or use an env var name
</span>
</Field>
<Field label="API">
<Select value={provider.api ?? "openai-completions"} onChange={(v) => set("api", v)} options={API_OPTIONS} required />
</Field>
<div style={{ borderTop: "1px solid var(--border)", paddingTop: 14, display: "flex", flexDirection: "column", gap: 10 }}>
{discoveryState.phase !== "success" && (
<button
onClick={handleDiscoverModels}
disabled={!provider.baseUrl?.trim() || discoveryState.phase === "loading"}
style={{
alignSelf: "flex-start", height: 30, padding: "0 12px", border: "1px solid var(--border)", borderRadius: 5,
background: "var(--bg-panel)", color: !provider.baseUrl?.trim() || discoveryState.phase === "loading" ? "var(--text-dim)" : "var(--text-muted)",
cursor: !provider.baseUrl?.trim() || discoveryState.phase === "loading" ? "not-allowed" : "pointer", fontSize: 11,
}}
>
{discoveryState.phase === "loading" ? t("models.discoveryFetching") : t("models.discoveryFetch")}
</button>
)}
{discoveryState.phase === "error" && (
<div style={{ padding: "7px 9px", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 5, color: "#ef4444", fontSize: 11, lineHeight: 1.4 }}>
{discoveryState.message}
</div>
)}
{discoveryState.phase === "success" && (
<>
<input
value={discoveryQuery}
onChange={(event) => setDiscoveryQuery(event.target.value)}
placeholder={t("models.discoveryFilterPlaceholder", { count: discoveryState.models.length })}
aria-label={t("models.discoveryFilter")}
style={{ ...inputStyle, width: "100%", minWidth: 0 }}
/>
<div style={{ maxHeight: 220, overflowY: "auto", border: "1px solid var(--border)", borderRadius: 6, background: "var(--bg-panel)" }}>
<label
style={{
minHeight: 32, padding: "5px 9px", display: "flex", alignItems: "center", gap: 8,
position: "sticky", top: 0, zIndex: 1, borderBottom: "1px solid var(--border)",
background: "var(--bg)", cursor: selectableShownIds.length ? "pointer" : "default",
color: "var(--text-muted)", fontSize: 10, fontWeight: 600,
}}
>
<input
ref={selectShownRef}
type="checkbox"
checked={allShownSelected}
disabled={selectableShownIds.length === 0}
onChange={toggleShownModels}
style={{ width: 13, height: 13, accentColor: "var(--accent)", flexShrink: 0 }}
/>
{t("models.discoverySelectShown")}
</label>
{shownDiscoveredModels.length === 0 ? (
<div style={{ padding: 12, color: "var(--text-dim)", fontSize: 11 }}>{t("models.discoveryNoMatches")}</div>
) : shownDiscoveredModels.map((model, index) => {
const alreadyAdded = existingModelIds.has(model.id);
const checked = selectedModelIds.includes(model.id);
return (
<label
key={model.id}
style={{
minHeight: 36, padding: "6px 9px", display: "flex", alignItems: "center", gap: 8,
borderTop: index === 0 ? "none" : "1px solid var(--border)", cursor: alreadyAdded ? "default" : "pointer",
opacity: alreadyAdded ? 0.65 : 1,
}}
>
<input
type="checkbox"
checked={checked || alreadyAdded}
disabled={alreadyAdded}
onChange={() => toggleDiscoveredModel(model.id)}
style={{ width: 13, height: 13, accentColor: "var(--accent)", flexShrink: 0 }}
/>
<span style={{ minWidth: 0, flex: 1 }}>
<span style={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--text)", fontSize: 11 }}>{model.name ?? model.id}</span>
{model.name && <code style={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--text-dim)", fontSize: 10, fontFamily: "var(--font-mono)" }}>{model.id}</code>}
</span>
{alreadyAdded && <span style={{ color: "var(--text-dim)", fontSize: 10 }}>{t("models.discoveryAdded")}</span>}
</label>
);
})}
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10 }}>
<span title={discoveryState.endpoint} style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--text-dim)", fontSize: 10 }}>
{filteredDiscoveredModels.length > shownDiscoveredModels.length
? t("models.discoveryShowing", { shown: shownDiscoveredModels.length, total: filteredDiscoveredModels.length })
: t("models.discoveryFetched", { count: discoveryState.models.length })}
</span>
<button
onClick={addSelectedModels}
disabled={selectedCount === 0}
style={{ height: 28, padding: "0 11px", border: "none", borderRadius: 5, background: selectedCount ? "var(--accent)" : "var(--bg-panel)", color: selectedCount ? "#fff" : "var(--text-dim)", cursor: selectedCount ? "pointer" : "not-allowed", fontSize: 11, fontWeight: 600, whiteSpace: "nowrap" }}
>
{selectedCount
? t("models.discoveryAddSelectedCount", { count: selectedCount })
: t("models.discoveryAddSelected")}
</button>
</div>
</>
)}
</div>
</div>
);
}
// ── ThinkingLevelMap editor ───────────────────────────────────────────────────
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
type ThinkingLevel = typeof THINKING_LEVELS[number];
const LEVEL_COLORS: Record<ThinkingLevel, string> = {
off: "var(--text-dim)",
minimal: "#6b7280",
low: "#60a5fa",
medium: "#a78bfa",
high: "#f472b6",
xhigh: "#fb923c",
max: "#ef4444",
};
function ThinkingLevelMapEditor({
value,
onChange,
}: {
value: Record<string, string | null> | undefined;
onChange: (v: Record<string, string | null> | 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 (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{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 (
<div
key={level}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "5px 4px",
borderRadius: 6,
background: "transparent",
border: "1px solid transparent",
}}
>
{/* Level badge */}
<div style={{ display: "flex", alignItems: "center", gap: 5, width: 68, flexShrink: 0 }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: color, flexShrink: 0, opacity: state === "null" ? 0.3 : 1 }} />
<span style={{
fontSize: 11,
fontFamily: "var(--font-mono)",
color: state === "null" ? "var(--text-dim)" : "var(--text-muted)",
textDecoration: state === "null" ? "line-through" : "none",
}}>
{level}
</span>
</div>
{/* Default + Disabled buttons */}
<div style={{ display: "flex", borderRadius: 5, border: "1px solid var(--border)", overflow: "hidden", flexShrink: 0 }}>
<button
onClick={() => setLevel(level, "omit")}
style={{ ...btnBase, ...(state === "omit" ? btnActive : {}) }}
>
Default
</button>
<button
onClick={() => setLevel(level, null)}
style={{ ...btnBase, borderLeft: "1px solid var(--border)", ...(state === "null" ? btnActiveDisabled : {}) }}
>
Disabled
</button>
</div>
{/* Custom button + input fused */}
<div style={{ display: "flex", borderRadius: 5, border: `1px solid ${state === "string" ? "var(--accent)" : "var(--border)"}`, overflow: "hidden", transition: "border-color 0.1s" }}>
<button
onClick={() => setLevel(level, strVal || level)}
style={{ ...btnBase, ...(state === "string" ? btnActive : {}), borderRight: "1px solid var(--border)", flexShrink: 0 }}
>
Custom
</button>
<input
value={strVal}
onChange={(e) => 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",
}}
/>
</div>
</div>
);
})}
</div>
);
}
// ── 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 };
}
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 costChanged = false;
for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) {
if (cost[key] === undefined && preset.cost[key] !== undefined) {
cost[key] = preset.cost[key];
costChanged = true;
appliedCount += 1;
}
}
if (costChanged) next.cost = cost;
}
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<ModelTestState>({ phase: "idle" });
const { t } = useI18n();
const [catalogState, setCatalogState] = useState<ModelCatalogState>({ phase: "idle" });
const catalogRequestIdRef = useRef(0);
const catalogUndoRef = useRef<ModelEntry | null>(null);
const set = <K extends keyof ModelEntry>(k: K, v: ModelEntry[K]) => onChange({ ...model, [k]: v });
const costVal = (k: keyof NonNullable<ModelEntry["cost"]>) => model.cost?.[k] !== undefined ? String(model.cost[k]) : "";
const setCost = (k: keyof NonNullable<ModelEntry["cost"]>, v: string) => {
const n = parseFloat(v);
onChange({ ...model, cost: { ...(model.cost ?? {}), [k]: isNaN(n) ? undefined : n } });
};
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);
}
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)";
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<SectionTitle>{t("i18n.model")}</SectionTitle>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{testSummary && (
<span
title={testSummary}
style={{
maxWidth: 260,
height: 24,
padding: "0 8px",
border: `1px solid ${testState.phase === "error" ? "#fecaca" : testState.phase === "success" ? "#bbf7d0" : "var(--border)"}`,
borderRadius: 4,
background: testState.phase === "error" ? "#fee2e2" : testState.phase === "success" ? "#dcfce7" : "#e5e7eb",
color: "#111827",
fontSize: 11,
display: "inline-flex",
alignItems: "center",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
boxSizing: "border-box",
}}
>
{testSummary}
</span>
)}
<button
onClick={handleTest}
disabled={!model.id.trim() || testState.phase === "testing"}
title={t("i18n.testConnection")}
style={{
height: 24,
padding: "0 8px",
background: testState.phase === "success" ? "#16a34a" : "none",
border: `1px solid ${testState.phase === "success" ? "#16a34a" : "var(--border)"}`,
borderRadius: 4,
color: testState.phase === "success" ? "#fff" : (!model.id.trim() || testState.phase === "testing") ? "var(--text-dim)" : "var(--text-muted)",
cursor: (!model.id.trim() || testState.phase === "testing") ? "not-allowed" : "pointer",
fontSize: 11,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxSizing: "border-box",
gap: 5,
}}
>
{testState.phase === "success" && (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
{testState.phase === "testing" ? t("i18n.checking") : testState.phase === "success" ? t("common.ok") : t("i18n.test")}
</button>
<button onClick={onDelete}
style={{ height: 24, padding: "0 8px", background: "none", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 4, color: "#ef4444", cursor: "pointer", fontSize: 11, boxSizing: "border-box" }}>
{t("i18n.remove")}
</button>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<Field label="ID *"><TextInput value={model.id} onChange={(v) => set("id", v)} placeholder="model-id" mono /></Field>
<Field label="Name"><TextInput value={model.name ?? ""} onChange={(v) => set("name", v || undefined)} placeholder="Display name" /></Field>
</div>
<div style={{ padding: "10px 0", borderTop: "1px solid var(--border)", borderBottom: "1px solid var(--border)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<button
onClick={() => void handleCatalogFill()}
disabled={!model.id.trim() || catalogState.phase === "loading"}
style={{
height: 28, padding: "0 10px", border: "1px solid var(--border)", borderRadius: 5,
background: "var(--bg-panel)",
color: !model.id.trim() || catalogState.phase === "loading" ? "var(--text-dim)" : "var(--text-muted)",
cursor: !model.id.trim() || catalogState.phase === "loading" ? "not-allowed" : "pointer",
fontSize: 11,
}}
>
{catalogState.phase === "loading" ? t("models.catalogFilling") : t("models.catalogFill")}
</button>
<a
href="https://github.com/anomalyco/models.dev"
target="_blank"
rel="noreferrer"
style={{ marginLeft: "auto", color: "var(--text-dim)", fontSize: 10, textDecoration: "none" }}
>
{t("models.catalogSource")}
</a>
</div>
<div
aria-live="polite"
style={{
marginTop: 6, height: 20, display: "flex", alignItems: "center",
justifyContent: "space-between", gap: 8, color: catalogStatusColor, fontSize: 10,
}}
>
<span
title={catalogStatusText ?? undefined}
style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{catalogStatusText}
</span>
{catalogUndoRef.current && (
<button
onClick={undoCatalogFill}
style={{ flexShrink: 0, padding: "0 2px", border: "none", background: "none", color: "var(--accent)", cursor: "pointer", fontSize: 10 }}
>
{t("models.catalogUndo")}
</button>
)}
</div>
</div>
<Field label="API override">
<Select value={model.api ?? ""} onChange={(v) => set("api", v || undefined)} options={API_OPTIONS} />
</Field>
<div style={{ display: "flex", gap: 20, flexWrap: "wrap" }}>
<Check label="Reasoning / thinking" checked={model.reasoning ?? false} onChange={(v) => set("reasoning", v || undefined)} />
<Check label="Image input" checked={model.input?.includes("image") ?? false}
onChange={(v) => set("input", v ? ["text", "image"] : undefined)} />
</div>
{model.reasoning && (
<>
<Check
label="DeepSeek thinking compat"
checked={hasDeepseekCompat(model)}
onChange={(v) => onChange(setDeepseekCompat(model, v))}
/>
<div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
<SectionTitle>Thinking level map</SectionTitle>
{model.thinkingLevelMap && (
<button
onClick={() => set("thinkingLevelMap", undefined)}
style={{ fontSize: 10, padding: "2px 7px", background: "none", border: "1px solid var(--border)", borderRadius: 4, color: "var(--text-dim)", cursor: "pointer" }}
>
clear all
</button>
)}
</div>
<ThinkingLevelMapEditor
value={model.thinkingLevelMap}
onChange={(v) => set("thinkingLevelMap", v)}
/>
</div>
</>
)}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<Field label="Context window (tokens)">
<NumInput value={model.contextWindow !== undefined ? String(model.contextWindow) : ""}
onChange={(v) => set("contextWindow", v ? parseInt(v) : undefined)} placeholder="128000" />
</Field>
<Field label="Max output tokens">
<NumInput value={model.maxTokens !== undefined ? String(model.maxTokens) : ""}
onChange={(v) => set("maxTokens", v ? parseInt(v) : undefined)} placeholder="16384" />
</Field>
</div>
<div>
<SectionTitle>Cost (per million tokens)</SectionTitle>
<div style={{ marginTop: 8, display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: 8 }}>
{(["input", "output", "cacheRead", "cacheWrite"] as const).map((k) => (
<Field key={k} label={k}>
<NumInput value={costVal(k)} onChange={(v) => setCost(k, v)} placeholder="0" />
</Field>
))}
</div>
</div>
</div>
);
}
// ── OAuth detail ──────────────────────────────────────────────────────────────
function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefresh: () => void }) {
const [loginState, setLoginState] = useState<OAuthLoginState>({ phase: "idle" });
const { t } = useI18n();
const [inputValue, setInputValue] = useState("");
const eventSourceRef = useRef<EventSource | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (loginState.phase === "auth" || loginState.phase === "prompt") {
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [loginState.phase]);
// Reset state when provider changes
useEffect(() => {
setLoginState({ phase: "idle" });
setInputValue("");
eventSourceRef.current?.close();
eventSourceRef.current = null;
}, [provider.id]);
useEffect(() => {
return () => { eventSourceRef.current?.close(); };
}, []);
const handleLogin = useCallback(() => {
eventSourceRef.current?.close();
setLoginState({ phase: "connecting" });
setInputValue("");
const es = new EventSource(`/api/auth/login/${encodeURIComponent(provider.id)}`);
eventSourceRef.current = es;
es.onmessage = (e) => {
const data = JSON.parse(e.data) as {
type: string; url?: string; instructions?: string | null;
token?: string; message?: string; placeholder?: string | null;
userCode?: string; verificationUri?: string; intervalSeconds?: number | null; expiresInSeconds?: number | null;
options?: { id: string; label: string }[];
};
if (data.type === "auth") {
setLoginState({ phase: "auth", url: data.url!, instructions: data.instructions ?? null, token: data.token! });
window.open(data.url!, "_blank", "noopener,noreferrer");
} else if (data.type === "device_code") {
setLoginState({
phase: "device_code",
userCode: data.userCode!,
verificationUri: data.verificationUri!,
intervalSeconds: data.intervalSeconds ?? null,
expiresInSeconds: data.expiresInSeconds ?? null,
});
window.open(data.verificationUri!, "_blank", "noopener,noreferrer");
} else if (data.type === "prompt_request") {
setLoginState({ phase: "prompt", message: data.message!, placeholder: data.placeholder ?? null, token: data.token! });
} else if (data.type === "select_request") {
setLoginState({ phase: "select", message: data.message!, options: data.options ?? [], token: data.token! });
} else if (data.type === "progress") {
setLoginState({ phase: "progress", message: data.message! });
} else if (data.type === "success") {
es.close();
setLoginState({ phase: "success" });
onRefresh();
} else if (data.type === "error") {
es.close();
setLoginState({ phase: "error", message: data.message! });
} else if (data.type === "cancelled") {
es.close();
setLoginState({ phase: "idle" });
}
};
es.onerror = () => {
es.close();
setLoginState((prev) => prev.phase === "success" ? prev : { phase: "error", message: "Connection lost" });
};
}, [provider.id, onRefresh]);
const handleLogout = useCallback(async () => {
await fetch(`/api/auth/logout/${encodeURIComponent(provider.id)}`, { method: "POST" });
setLoginState({ phase: "idle" });
onRefresh();
}, [provider.id, onRefresh]);
const submitCode = useCallback(async (token: string, code: string) => {
if (!code.trim()) return;
setLoginState({ phase: "progress", message: "Verifying…" });
try {
const res = await fetch(`/api/auth/login/${encodeURIComponent(provider.id)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, code: code.trim() }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({})) as { error?: string };
setLoginState({ phase: "error", message: d.error ?? `Server error ${res.status}` });
return;
}
setInputValue("");
// Success path: SSE stream will emit "success" and update state
} catch (e) {
setLoginState({ phase: "error", message: e instanceof Error ? e.message : "Network error" });
}
}, [provider.id]);
const submitSelection = useCallback(async (token: string, value: string) => {
setLoginState({ phase: "progress", message: "Continuing…" });
try {
const res = await fetch(`/api/auth/login/${encodeURIComponent(provider.id)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, code: value }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({})) as { error?: string };
setLoginState({ phase: "error", message: d.error ?? `Server error ${res.status}` });
}
} catch (e) {
setLoginState({ phase: "error", message: e instanceof Error ? e.message : "Network error" });
}
}, [provider.id]);
const isWorking = loginState.phase === "connecting" || loginState.phase === "progress" ||
loginState.phase === "auth" || loginState.phase === "device_code" ||
loginState.phase === "prompt" || loginState.phase === "select";
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<SectionTitle>{t("i18n.subscription")}</SectionTitle>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: provider.loggedIn ? "#4ade80" : "var(--border)", display: "inline-block" }} />
<span style={{ fontSize: 11, color: provider.loggedIn ? "#4ade80" : "var(--text-dim)" }}>
{provider.loggedIn ? t("i18n.connected") : t("i18n.notConnected")}
</span>
</div>
</div>
{/* Status */}
<div style={{ minHeight: 48 }}>
{loginState.phase === "idle" && (
<p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)", lineHeight: 1.5 }}>
{provider.loggedIn ? "Already connected. You can re-login or disconnect." : `Connect your ${provider.name} account.`}
</p>
)}
{loginState.phase === "connecting" && (
<p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)" }}>{t("i18n.openingBrowser")}</p>
)}
{loginState.phase === "select" && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)", lineHeight: 1.5 }}>
{loginState.message}
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{loginState.options.map((option) => (
<button
key={option.id}
onClick={() => submitSelection(loginState.token, option.id)}
style={{ padding: "6px 9px", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text)", cursor: "pointer", fontSize: 12, textAlign: "left" }}
>
{option.label}
</button>
))}
</div>
</div>
)}
{(loginState.phase === "auth" || loginState.phase === "prompt") && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)", lineHeight: 1.5 }}>
{loginState.phase === "auth"
? "Complete sign-in in the browser, then copy the redirect URL from the address bar and paste it below."
: loginState.message}
</p>
{loginState.phase === "auth" && (
<p style={{ margin: 0, fontSize: 11, color: "var(--text-dim)", lineHeight: 1.5 }}>
If the browser window did not open,{" "}
<a href={loginState.url} target="_blank" rel="noopener noreferrer" style={{ color: "var(--accent)", wordBreak: "break-all" }}>
click here to open the login page
</a>
.
</p>
)}
<div style={{ display: "flex", gap: 6 }}>
<input
ref={inputRef}
value={inputValue}
onChange={(e) => 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" }}
/>
<button
onClick={() => submitCode(loginState.token, inputValue)}
disabled={!inputValue.trim()}
style={{ padding: "6px 12px", background: inputValue.trim() ? "var(--accent)" : "var(--bg-panel)", border: "none", borderRadius: 5, color: inputValue.trim() ? "#fff" : "var(--text-dim)", cursor: inputValue.trim() ? "pointer" : "not-allowed", fontSize: 12, fontWeight: 600, flexShrink: 0 }}
>
{t("i18n.submit")}
</button>
</div>
</div>
)}
{loginState.phase === "device_code" && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)", lineHeight: 1.5 }}>
Open the verification page and enter this code:
</p>
<div style={{ padding: "8px 10px", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text)", fontSize: 16, fontWeight: 700, fontFamily: "var(--font-mono)", letterSpacing: 0 }}>
{loginState.userCode}
</div>
<p style={{ margin: 0, fontSize: 11, color: "var(--text-dim)", lineHeight: 1.5 }}>
<a href={loginState.verificationUri} target="_blank" rel="noopener noreferrer" style={{ color: "var(--accent)", wordBreak: "break-all" }}>
{loginState.verificationUri}
</a>
{loginState.expiresInSeconds ? ` Expires in ${Math.ceil(loginState.expiresInSeconds / 60)} minutes.` : ""}
</p>
</div>
)}
{loginState.phase === "progress" && (
<p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)" }}>{loginState.message}</p>
)}
{loginState.phase === "success" && (
<p style={{ margin: 0, fontSize: 12, color: "#4ade80" }}>{t("i18n.connectedSuccessfully")}</p>
)}
{loginState.phase === "error" && (
<p style={{ margin: 0, fontSize: 12, color: "#f87171" }}>{loginState.message}</p>
)}
</div>
{/* Actions */}
<div style={{ display: "flex", gap: 8 }}>
{isWorking ? (
<button
onClick={() => { eventSourceRef.current?.close(); setLoginState({ phase: "idle" }); }}
style={{ padding: "5px 12px", background: "none", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text-muted)", cursor: "pointer", fontSize: 12 }}
>
{t("i18n.cancel")}
</button>
) : (
<>
<button
onClick={handleLogin}
style={{ padding: "5px 14px", background: "var(--accent)", border: "none", borderRadius: 5, color: "#fff", cursor: "pointer", fontSize: 12, fontWeight: 600 }}
>
{provider.loggedIn ? t("i18n.relogin") : t("i18n.login")}
</button>
{provider.loggedIn && (
<button
onClick={handleLogout}
style={{ padding: "5px 12px", background: "none", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 5, color: "#ef4444", cursor: "pointer", fontSize: 12 }}
>
{t("i18n.disconnect")}
</button>
)}
</>
)}
</div>
</div>
);
}
// ── 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<string | null>(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 (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<SectionTitle>API Key</SectionTitle>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: provider.configured ? "#4ade80" : "var(--border)", display: "inline-block" }} />
<span style={{ fontSize: 11, color: provider.configured ? "#4ade80" : "var(--text-dim)" }}>
{provider.configured ? t("i18n.configured") : t("i18n.notConfigured")}
</span>
</div>
</div>
<p style={{ margin: 0, fontSize: 12, color: "var(--text-muted)", lineHeight: 1.5 }}>
{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" : ""}.`}
</p>
<Field label="API Key">
<div style={{ display: "flex", gap: 6 }}>
<SecretTextInput
value={apiKey}
onChange={setApiKey}
onKeyDown={(e) => { if (e.key === "Enter" && apiKey.trim()) handleSave(); }}
placeholder={provider.configured ? "Enter new key to replace…" : "sk-…"}
style={{ flex: 1 }}
autoComplete="off"
spellCheck={false}
mono
/>
<button
onClick={handleSave}
disabled={saving || !apiKey.trim() || savedOk}
style={{
padding: "6px 12px",
background: savedOk ? "#16a34a" : apiKey.trim() ? "var(--accent)" : "var(--bg-panel)",
border: "none", borderRadius: 5,
color: (apiKey.trim() || savedOk) ? "#fff" : "var(--text-dim)",
cursor: (saving || !apiKey.trim() || savedOk) ? "not-allowed" : "pointer",
fontSize: 12, fontWeight: 600, flexShrink: 0,
display: "flex", alignItems: "center", gap: 5,
}}
>
{savedOk && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
{savedOk ? t("i18n.saved") : saving ? t("i18n.saving") : t("i18n.save")}
</button>
</div>
</Field>
{error && <p style={{ margin: 0, fontSize: 12, color: "#f87171" }}>{error}</p>}
{provider.configured && (
<button
onClick={handleRemove}
disabled={removing}
style={{
alignSelf: "flex-start", padding: "5px 12px",
background: "none", border: "1px solid rgba(239,68,68,0.3)",
borderRadius: 5, color: "#ef4444",
cursor: removing ? "not-allowed" : "pointer", fontSize: 12,
}}
>
{removing ? t("i18n.removing") : t("i18n.disconnect")}
</button>
)}
</div>
);
}
// ── 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 (
<span
aria-hidden="true"
style={{
width: size,
height: size,
border: "1px solid var(--border)",
borderRadius: 4,
color: "var(--text-dim)",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
fontSize: Math.max(8, Math.floor(size * 0.42)),
fontWeight: 700,
lineHeight: 1,
}}
>
{label}
</span>
);
}
// Color icons: self-colored SVG, no wrapper needed
if (pi.hasColor) return <pi.Icon size={size} />;
// Mono icons: use currentColor so they adapt to light/dark theme
return <pi.Icon size={size} style={{ color: "var(--text-muted)" }} />;
}
// ── 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<HTMLInputElement>(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 (
<div
style={{ position: "fixed", inset: 0, zIndex: 1100, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center" }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div style={{ width: 820, maxWidth: "calc(100vw - 32px)", maxHeight: "min(72vh, calc(100vh - 32px))", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 10, display: "flex", flexDirection: "column", boxShadow: "0 8px 32px rgba(0,0,0,0.22)", overflow: "hidden" }}>
{/* Search */}
<div style={{ padding: "10px 14px", borderBottom: "1px solid var(--border)", flexShrink: 0, display: "flex", alignItems: "center", gap: 8 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--text-dim)", flexShrink: 0 }}>
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
ref={inputRef}
value={search}
onChange={(e) => 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" }}
/>
</div>
{/* Card grid */}
<div style={{ flex: 1, overflowY: "auto", padding: 14 }}>
{totalCount === 0 ? (
<div style={{ padding: "20px 0", fontSize: 12, color: "var(--text-dim)", textAlign: "center" }}>{t("i18n.noProviders")}</div>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(240px, 100%), 1fr))", gap: 8 }}>
{showCustom && (
<div style={{ gridColumn: "1 / -1", fontSize: 10, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase", letterSpacing: "0.07em" }}>{t("i18n.custom")}</div>
)}
{showCustom && (
<button
onClick={() => { onAddCustom(); onClose(); }}
style={cardStyle}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; e.currentTarget.style.background = "var(--bg-hover)"; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.background = "var(--bg-panel)"; }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text)", lineHeight: 1.3, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>OpenAI / Anthropic compatible</div>
<div style={{ fontSize: 10, color: "var(--text-dim)", marginTop: 2 }}>{t("i18n.customEndpoint")}</div>
</div>
<span style={{ width: 26, height: 26, borderRadius: 5, background: "var(--bg-hover)", border: "1px dashed var(--border)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--text-dim)" }}>
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</span>
</button>
)}
{availableOAuth.length > 0 && (
<div style={{ gridColumn: "1 / -1", paddingTop: showCustom ? 6 : 0, fontSize: 10, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase", letterSpacing: "0.07em" }}>{t("i18n.subscriptions")}</div>
)}
{availableOAuth.map((p) => (
<button key={p.id} onClick={() => { onSelectOAuth(p.id); onClose(); }}
style={cardStyle}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; e.currentTarget.style.background = "var(--bg-hover)"; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.background = "var(--bg-panel)"; }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text)", lineHeight: 1.3, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.name}</div>
<div style={{ fontSize: 10, color: "var(--text-dim)", marginTop: 2 }}>OAuth</div>
</div>
<ProviderIcon id={p.id} size={28} />
</button>
))}
{availableApiKey.length > 0 && (
<div style={{ gridColumn: "1 / -1", paddingTop: availableOAuth.length > 0 ? 6 : 0, fontSize: 10, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase", letterSpacing: "0.07em" }}>API Key</div>
)}
{availableApiKey.map((p) => (
<button key={p.id} onClick={() => { onSelectApiKey(p.id); onClose(); }}
style={cardStyle}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; e.currentTarget.style.background = "var(--bg-hover)"; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.background = "var(--bg-panel)"; }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text)", lineHeight: 1.3, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.displayName}</div>
<div style={{ fontSize: 10, color: "var(--text-dim)", marginTop: 2 }}>{p.modelCount} models</div>
</div>
<ProviderIcon id={p.id} size={28} />
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export function ModelsConfig({ onClose }: { onClose: () => void }) {
const isMobile = useIsMobile();
const { t } = useI18n();
const [config, setConfig] = useState<ModelsJson>({ providers: {} });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [savedOk, setSavedOk] = useState(false);
const [selection, setSelection] = useState<Selection | null>(null);
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([]);
const [apiKeyProviders, setApiKeyProviders] = useState<ApiKeyProvider[]>([]);
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(() => {});
}, []);
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));
loadOAuthProviders();
loadApiKeyProviders();
}, [loadOAuthProviders, loadApiKeyProviders]);
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 <OAuthDetail key={p.id} provider={p} onRefresh={loadOAuthProviders} />;
}
if (selection.type === "apikey") {
const p = apiKeyProviders.find((p) => p.id === selection.providerId);
if (!p) return null;
return <ApiKeyDetail key={p.id} provider={p} onRefresh={loadApiKeyProviders} />;
}
if (selection.type === "provider") {
const provider = config.providers?.[selection.name];
if (!provider) return null;
return (
<ProviderDetail
key={selection.name}
name={selection.name}
provider={provider}
onChange={(p) => 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 (
<ModelDetail
key={`${selection.providerName}-${selection.index}`}
providerName={selection.providerName}
provider={provider}
model={model}
onChange={(m) => updateModel(selection.providerName, selection.index, m)}
onDelete={() => removeModel(selection.providerName, selection.index)}
/>
);
})();
return (
<>
<div style={{ position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,0.35)", display: "flex", alignItems: "center", justifyContent: "center" }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div style={{ width: isMobile ? "calc(100vw - 16px)" : 860, maxWidth: "calc(100vw - 16px)", height: isMobile ? "calc(100dvh - 16px)" : "78vh", maxHeight: "calc(100dvh - 16px)", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 10, display: "flex", flexDirection: "column", boxShadow: "0 8px 32px rgba(0,0,0,0.18)", overflow: "hidden" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 18px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}>{t("common.models")}</span>
<code style={{ fontSize: 11, color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>~/.pi/agent/models.json</code>
</div>
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 20, lineHeight: 1, padding: "2px 6px" }}>×</button>
</div>
{/* Body */}
<div style={{ flex: 1, display: "flex", flexDirection: isMobile ? "column" : "row", overflow: "hidden" }}>
{/* Left: tree */}
<div style={{
width: isMobile ? "100%" : 210,
maxHeight: isMobile ? "40vh" : undefined,
borderRight: isMobile ? "none" : "1px solid var(--border)",
borderBottom: isMobile ? "1px solid var(--border)" : "none",
display: "flex", flexDirection: "column", flexShrink: 0, background: "var(--bg-panel)",
}}>
<div style={{ flex: 1, overflowY: "auto", padding: "8px 6px" }}>
{/* Active OAuth subscriptions */}
{activeOAuth.map((p) => {
const isSelected = selection?.type === "oauth" && selection.providerId === p.id;
return (
<div
key={p.id}
onClick={() => 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"; }}
>
<ProviderIcon id={p.id} size={16} />
<span style={{ fontSize: 12, color: "var(--text)", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.name}</span>
</div>
);
})}
{/* Active API key providers */}
{activeApiKey.map((p) => {
const isSelected = selection?.type === "apikey" && selection.providerId === p.id;
return (
<div
key={p.id}
onClick={() => 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"; }}
>
<ProviderIcon id={p.id} size={16} />
<span style={{ fontSize: 12, color: "var(--text)", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.displayName}</span>
</div>
);
})}
{/* Divider before custom providers, only when there are active managed providers */}
{(activeOAuth.length > 0 || activeApiKey.length > 0) && providers.length > 0 && (
<div style={{ margin: "4px 8px", borderTop: "1px solid var(--border)" }} />
)}
{/* Custom providers */}
{loading ? (
<div style={{ padding: "10px 8px", fontSize: 12, color: "var(--text-muted)" }}>{t("i18n.loading")}</div>
) : providers.map(([pName, pData]) => {
const isProviderSelected = selection?.type === "provider" && selection.name === pName;
const models = pData.models ?? [];
return (
<div key={pName} style={{ marginBottom: 2 }}>
{/* Provider row */}
<div
onClick={() => 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"; }}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--text-dim)", flexShrink: 0 }}>
<rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" />
<line x1="9" y1="1" x2="9" y2="4" /><line x1="15" y1="1" x2="15" y2="4" />
<line x1="9" y1="20" x2="9" y2="23" /><line x1="15" y1="20" x2="15" y2="23" />
<line x1="20" y1="9" x2="23" y2="9" /><line x1="20" y1="14" x2="23" y2="14" />
<line x1="1" y1="9" x2="4" y2="9" /><line x1="1" y1="14" x2="4" y2="14" />
</svg>
<span style={{ fontSize: 12, fontWeight: isProviderSelected ? 600 : 400, color: "var(--text)", fontFamily: "var(--font-mono)", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{pName}
</span>
</div>
{/* Model rows */}
{models.map((m, i) => {
const isModelSelected = selection?.type === "model" && selection.providerName === pName && selection.index === i;
return (
<div
key={i}
onClick={() => 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"; }}
>
<span style={{ fontSize: 11, fontFamily: "var(--font-mono)", color: m.id ? "var(--text-muted)" : "var(--text-dim)", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{m.id || t("i18n.newModel")}
</span>
{m.reasoning && (
<span style={{ fontSize: 9, padding: "1px 4px", background: "rgba(99,102,241,0.12)", color: "rgba(99,102,241,0.8)", borderRadius: 3, flexShrink: 0 }}>T</span>
)}
</div>
);
})}
{/* Add model button */}
<div
onClick={(e) => { 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"; }}
>
<span style={{ fontSize: 11 }}>+ {t("i18n.model")}</span>
</div>
</div>
);
})}
</div>
{/* Add provider */}
<div style={{ borderTop: "1px solid var(--border)", padding: "8px 6px" }}>
<button onClick={() => setPickerOpen(true)} style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: 5,
width: "100%", padding: "6px 0", background: "none", border: "1px dashed var(--border)", borderRadius: 5,
color: "var(--text-muted)", cursor: "pointer", fontSize: 12,
}}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; e.currentTarget.style.color = "var(--accent)"; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.color = "var(--text-muted)"; }}
>
+ {t("i18n.addProvider")}
</button>
</div>
</div>
{/* Right: detail */}
<div style={{ flex: 1, overflowY: "auto", padding: 20 }}>
{loading ? null : detailContent ?? (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 13 }}>
{t("i18n.selectProviderModel")}
</div>
)}
</div>
</div>
{/* Footer */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 10, padding: "10px 18px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>
{saveError && <span style={{ fontSize: 12, color: "#f87171", flex: 1 }}>{saveError}</span>}
<button onClick={onClose} style={{ padding: "6px 14px", background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", fontSize: 13 }}>
{t("i18n.cancel")}
</button>
<button onClick={handleSave} disabled={saving || savedOk} style={{
position: "relative",
padding: "6px 16px",
minWidth: 92,
background: savedOk ? "#16a34a" : saving ? "var(--bg-panel)" : "var(--accent)",
border: "none", borderRadius: 6,
color: savedOk ? "#fff" : saving ? "var(--text-muted)" : "#fff",
cursor: (saving || savedOk) ? "default" : "pointer", fontSize: 13, fontWeight: 600,
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6,
transition: "background-color 0.2s ease, color 0.2s ease",
animation: savedOk ? "saved-pop 0.45s ease" : undefined,
}}>
{savedOk && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"
style={{ strokeDasharray: 18, animation: "saved-check-draw 0.35s ease forwards", flexShrink: 0 }}>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
<span>{savedOk ? t("i18n.saved") : saving ? t("i18n.saving") : t("i18n.save")}</span>
</button>
</div>
</div>
</div>
{pickerOpen && (
<AddProviderPicker
oauthProviders={oauthProviders}
apiKeyProviders={apiKeyProviders}
onSelectOAuth={(id) => setSelection({ type: "oauth", providerId: id })}
onSelectApiKey={(id) => setSelection({ type: "apikey", providerId: id })}
onAddCustom={addCustomProvider}
onClose={() => setPickerOpen(false)}
/>
)}
</>
);
}