import { useEffect, useMemo, useRef, useState } from "react"; import { useConfigStore } from "@/store/config-store"; import { useTranslation } from "@/lib/i18n"; import { Badge } from "@/components/ui/Badge"; import { Modal } from "@/components/ui/Modal"; import { formatTokens, cn, formatCost, USD_TO_CNY } from "@/lib/utils"; import { useCurrency } from "@/lib/currency"; import { formatImportedApiKeys, parseImportedApiKeys, parseProviderImport, type ImportedApiKey, } from "@/lib/provider-import"; import type { ApiType, CustomProviderConfig, Model, PiModelsJson, Provider, ProviderApiKey } from "@/types"; import { MODEL_CATALOG, searchCatalog, catalogToModel, catalogEntryId, findCatalogEntry, guessModelMeta } from "@/data/model-catalog"; import { Plus, Trash2, Edit3, Eye, Square, SquareCheck, EyeOff, Lock, Unlock, Server, Shield, Box, Brain, Image as ImageIcon, Search, Check, X, Loader2, Zap, ClipboardPaste, Download, Copy, Sparkles, Mic, Wand2, } from "lucide-react"; // Selectable API types for the provider picker — chat APIs only. Agnes 3.0 // has its own dedicated model console in /generate, not here. // // Google / Bedrock / Mistral were dropped from this list on request. Their // values stay valid in `ApiType` because pi's builtin catalog still uses them, // so `ApiTypeOptions` re-adds whatever a provider currently has saved instead // of letting the setProviderName(e.target.value)} placeholder="My Provider" className="mt-1.5 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 text-sm text-white" /> )} {/* Base URL */}
setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" className={cn( "mt-1.5 w-full rounded-lg border bg-gray-800 px-3 py-2.5 text-sm text-white", urlInvalid ? "border-red-500" : "border-gray-700" )} /> {urlInvalid && (

{t("providers_models.invalid_url")}

)} {!isCustom && !urlInvalid && baseUrl !== (provider.baseUrl ?? "") && (

{t("providers_models.baseurl_override")}

)}
{/* API Type */}
{/* API Key — custom providers get a switchable pool, builtins a single key */} {isCustom ? (
{keys.length > 1 && ( {t("providers_models.api_key_count", String(keys.length))} )}
{keys.length === 0 && (

{t("providers_models.api_key_empty")}

)} {keys.length > 0 && (
{keys.map((k) => { const isActive = k.id === activeKeyId; const revealed = revealedKeys.has(k.id); return (
handleActivateKey(k.id)} className="text-blue-500" title={t("providers_models.api_key_use")} /> {revealed ? k.key : maskKey(k.key)} {isActive && {t("providers_models.api_key_active")}}
); })}
)}
{ setNewKeyValue(e.target.value); setKeyError(null); }} onKeyDown={(e) => { if (e.key === "Enter") handleAddKey(); }} placeholder="sk-... or $MY_API_KEY" className="min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 text-sm text-white" />
{keyError &&

{keyError}

} {newKeyValue.trim().startsWith("$") && (

{t("providers_models.api_key_env", newKeyValue.trim())}

)} {keys.length > 1 && (

{t("providers_models.api_key_switch_hint")}

)}

{t("providers_models.manual_key_switch_note")}

) : (
setApiKey(e.target.value)} placeholder="sk-... or $MY_API_KEY" className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 pr-10 text-sm text-white" />
{apiKey.trim().startsWith("$") && (

{t("providers_models.api_key_env", apiKey.trim())}

)}
)} {/* API compatibility — keep the controls visible, but make the safe defaults and the matching error strings actionable. */}

{t("compat.title")}

{t("compat.desc")}

{t("compat.helper_title")}

{t("compat.helper_desc")}

{compatSuggestion && (

{t("compat.helper_detected")}

{compatSuggestion.startsWith("developer") ? t("compat.developer_error") : t("compat.finish_error")}
)} {/* Developer Role Support */}
setSupportsDeveloperRole(e.target.checked)} className="rounded border-gray-600 bg-gray-800 text-blue-500" />
{/* Finish Reason Support */}
setSupportsFinishReason(e.target.checked)} className="rounded border-gray-600 bg-gray-800 text-blue-500" />
{/* Save / Test / Feedback row */}
{dirty && ( )} {saveState === "saved" && ( {t("providers_models.saved")} )} {saveState === "error" && ( {t("providers_models.save_failed")} )} {isCustom && baseUrl.trim() !== "" && ( )}
{/* Model List */}
{provider.models.length > 0 && ( )}
{provider.models.length > 0 && (
{selectedModels.size > 0 && ( )}
)}
{provider.models.length === 0 && (

{t("models.no_models")}

)} {provider.models.length > 0 && visibleModels.length === 0 && (

{t("models.no_models")}

)} {visibleModels.map((m) => { const enabled = isModelEnabled(m.id); const isSelected = selectedModels.has(m.id); return (
{m.id} {m.reasoning && ( )} {m.input?.includes("image") && ( )} {m.input?.includes("audio") && ( )} {m.cost && (m.cost.input || m.cost.output) ? `${formatCost(m.cost.input, currency)}/${formatCost(m.cost.output, currency)}` : t("models.free")} {m.contextWindow ? formatTokens(m.contextWindow) : "—"} {/* Test model */} {(() => { const test = getModelTest(m.id); if (test.status === "testing") return null; return ( ); })()} {/* Model test result */} {(() => { const test = getModelTest(m.id); if (test.status === "ok") return ( {t("providers_models.test_ok", String(test.latencyMs))} ); if (test.status === "fail") return ( {t("providers_models.test_fail", test.message)} ); if (test.status === "testing") return ( {t("providers_models.testing")} ); return null; })()} {copiedParams === m.id ? ( ) : ( )}
); })}
{/* Quick-add input */}
setQuickId(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleQuickAdd(); } }} placeholder={t("models.quick_add_placeholder")} className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-9 pr-3 text-sm text-white" />
{quickHint && (

{quickHint}

)}
{/* Fetch Models Modal */} { setFetchOpen(false); setFetchedModels([]); setFetchSelected(new Set()); setFetchQuery(""); setFetchError(null); setFetchImported(null); }} title={t("providers_models.fetch_title")} size="xl" >

{t("providers_models.fetch_desc", provider.name)}

{fetching && (
{t("providers_models.fetching")}
)} {fetchError && !fetching && (

{t("providers_models.fetch_error", fetchError)}

)} {!fetching && !fetchError && availableModels.length === 0 && fetchedModels.length === 0 && (

{t("providers_models.fetch_empty")}

)} {!fetching && !fetchError && availableModels.length > 0 && ( <>
setFetchQuery(e.target.value)} placeholder={t("models.search_placeholder")} aria-label={t("models.search_placeholder")} className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-9 pr-3 text-sm text-white placeholder:text-gray-500 outline-none transition-colors focus:border-blue-500" />
{t("providers_models.selected_count", String(fetchSelected.size), String(availableModels.length))}
{filteredAvailableModels.length === 0 ? (

{t("providers_models.no_match")}

) : filteredAvailableModels.map((m) => (
toggleSelect(m.id)}>
{isSelected(m.id) && }
{m.id} {m.reasoning && ( )} {m.vision ? : } {m.vision ? t("providers_models.modality_vision") : t("providers_models.modality_text")} {m.audio && ( )} {m.contextWindow && {formatTokens(m.contextWindow)}} {m.maxTokens && {formatTokens(m.maxTokens)}} {m.cost && (m.cost.input || m.cost.output) ? ( ${m.cost.input}/${m.cost.output} ) : null}
))}
)}
{fetchImported !== null && ( {t("providers_models.import_done", String(fetchImported))} )}
{/* Edit Model Modal */} setEditModel(null)} title={`${t("models.edit_model")}: ${editModel?.name || editModel?.id}`} size="xl" > {editModel && ( { // Apply default maxTokens if left empty const patched: Partial = { ...form }; if (patched.maxTokens === undefined) patched.maxTokens = DEFAULT_MAX_TOKENS; updateModel(provider.id, editModel.id, patched); setEditModel(null); }} onCancel={() => setEditModel(null)} /> )} {/* Add Model Modal */} setShowAddModel(false)} title={`${t("models.add_model")} — ${provider.name}`} size="xl" > { if (!form.id) return; addModel(provider.id, form as Model); setShowAddModel(false); }} onCancel={() => setShowAddModel(false)} /> {/* Delete Model Confirmation Modal */} setDeleteModel(null)} title={t("models.delete_model")} >

{deleteModel?.id} {" — "} {t("models.delete_confirm")}

{/* Batch Delete Confirmation Modal */} setBatchDeleteConfirm(false)} title={t("models.batch_delete")} >

{t("models.batch_delete_confirm", selectedModels.size.toString())}

); } // ─── Model Form (add & edit) ────────────────────────────── interface ModelFormProps { initial?: Model; onSubmit: (form: Partial) => void; onCancel: () => void; } function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) { const { t } = useTranslation(); const isEdit = !!initial; const [form, setForm] = useState>( initial ? { ...initial } : { id: "", name: "", reasoning: false, input: ["text"], contextWindow: DEFAULT_CONTEXT_WINDOW, maxTokens: DEFAULT_MAX_TOKENS, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, } ); // Which fields the user has manually changed (so auto-detect doesn't clobber them) const touchedRef = useRef>(new Set()); const touch = (field: string) => { touchedRef.current.add(field); }; const wasTouched = (field: string) => touchedRef.current.has(field); // Auto-detect from model id when not editing and id changes const [detectHint, setDetectHint] = useState(null); useEffect(() => { if (isEdit) return; const id = (form.id ?? "").trim(); if (!id) { setDetectHint(null); return; } const guess = guessModelMeta(id); if (guess.source === "default") { setDetectHint(null); return; } // The catalog is authoritative when it has an entry: maxTokens there is the // vendor-documented output cap. Resolving it via findCatalogEntry (best // score) instead of a loose searchCatalog+includes scan avoids a broad // family prefix winning over the exact model. const match = findCatalogEntry(id); setForm((prev) => { const next = { ...prev }; if (guess.contextWindow && !wasTouched("contextWindow")) next.contextWindow = guess.contextWindow; if (!wasTouched("maxTokens") || next.maxTokens === undefined) { next.maxTokens = match?.maxTokens ?? guess.maxTokens ?? fallbackMaxTokens(guess.contextWindow); } if (guess.reasoning !== undefined && !wasTouched("reasoning")) next.reasoning = guess.reasoning; if (guess.input && !wasTouched("input")) next.input = [...guess.input]; if (match) { if (!wasTouched("name")) next.name = match.name; if (match.cost && !wasTouched("cost")) next.cost = { ...match.cost }; } return next; }); setDetectHint( guess.source === "catalog" && guess.matched ? t("models.detected_catalog", guess.matched) : t("models.detected_heuristic") ); }, [form.id, isEdit, t]); // ── Catalog picker (add-mode only) ── const [pickerOpen, setPickerOpen] = useState(false); const [pickerQuery, setPickerQuery] = useState(""); // No limit: an empty query must list the whole catalog, otherwise families // ordered late in the array (GLM, Kimi, Doubao, Llama, Grok) are unreachable // without typing. const pickerResults = useMemo( () => searchCatalog(pickerQuery, MODEL_CATALOG.length), [pickerQuery] ); const applyPreset = (entry: ReturnType[number]) => { const preset = catalogToModel(entry); // Apply all fields as if they were default (no touch-marking) touchedRef.current = new Set(); setForm((prev) => ({ ...prev, ...preset })); setPickerOpen(false); setDetectHint(t("models.detected_catalog", entry.name ?? catalogEntryId(entry))); }; // Apply a quick template (Claude-style, GPT-style, Reasoning, Local small) const applyTemplate = (kind: "claude" | "gpt" | "reasoning" | "small") => { touchedRef.current = new Set(); setForm((prev) => { switch (kind) { case "claude": return { ...prev, reasoning: true, input: ["text", "image"], contextWindow: 200_000, maxTokens: 8192 }; case "gpt": return { ...prev, reasoning: false, input: ["text", "image"], contextWindow: 128_000, maxTokens: 16_384 }; case "reasoning": return { ...prev, reasoning: true, input: ["text"], contextWindow: 128_000, maxTokens: 65_536 }; case "small": return { ...prev, reasoning: false, input: ["text"], contextWindow: 32_768, maxTokens: 4096 }; } }); }; const setId = (v: string) => { touch("id"); setForm((p) => ({ ...p, id: v })); }; const setName = (v: string) => { touch("name"); setForm((p) => ({ ...p, name: v })); }; const setContextWindow = (v: number) => { touch("contextWindow"); setForm((p) => ({ ...p, contextWindow: v })); }; const setMaxTokens = (v: number | undefined) => { touch("maxTokens"); setForm((p) => ({ ...p, maxTokens: v })); }; const setReasoning = (v: boolean) => { touch("reasoning"); setForm((p) => ({ ...p, reasoning: v })); }; const setImage = (v: boolean) => { touch("input"); setForm((p) => ({ ...p, input: v ? ["text", "image"] : ["text"] })); }; const setAudio = (v: boolean) => { touch("input"); setForm((p) => { const hasText = p.input?.includes("text") ?? true; const hasImage = p.input?.includes("image") ?? false; const next: ("text" | "image" | "audio")[] = []; if (hasText) next.push("text"); if (hasImage) next.push("image"); if (v) next.push("audio"); return { ...p, input: next }; }); }; const setCostField = (field: keyof NonNullable, v: number) => { touch("cost"); setForm((p) => ({ ...p, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, ...p.cost, [field]: v }, })); }; return (
{/* ── Catalog picker / templates (add-mode) ── */} {!isEdit && (
{t("models.manual_fill")}: {([ ["claude", "models.preset_claude"], ["gpt", "models.preset_gpt"], ["reasoning", "models.preset_reasoning"], ["small", "models.preset_small_local"], ] as const).map(([kind, key]) => ( ))}
{pickerOpen && (
setPickerQuery(e.target.value)} placeholder={t("models.pick_preset_placeholder")} className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-9 pr-3 text-sm text-white" />
{pickerResults.length === 0 && (

)} {pickerResults.map((e) => ( ))}
)}
)} {/* ── Core fields ── */}
setId(e.target.value)} placeholder="my-model-id" className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white disabled:opacity-50" /> {!isEdit && detectHint && (

{detectHint}

)}
setName(e.target.value)} placeholder="My Custom Model" className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" />
setContextWindow(parseInt(e.target.value) || DEFAULT_CONTEXT_WINDOW)} className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" />
{[32_768, 65_536, 131_072, 262_144, 524_288, 1_000_000].map((v) => ( ))}
{ const v = e.target.value; setMaxTokens(v === "" ? undefined : parseInt(v) || undefined); }} placeholder={String(DEFAULT_MAX_TOKENS)} className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" />

{t("models.max_tokens_hint", String(DEFAULT_MAX_TOKENS))}

{[4096, 8192, 16_384, 32_768, 65_536].map((v) => ( ))}
{/* Capabilities */}
{/* Cost */}
{( [ ["input", "models.cost_input"], ["output", "models.cost_output"], ["cacheRead", "models.cost_cache_read"], ["cacheWrite", "models.cost_cache_write"], ] as const ).map(([field, labelKey]) => (
setCostField(field, parseFloat(e.target.value) || 0)} className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" />
))}
{!isEdit && (

{t("models.catalog_hint")}

)}
); } // ─── Add Provider Form ──────────────────────────────────── function AddProviderForm({ onSubmit, onCancel, }: { onSubmit: (id: string, cfg: CustomProviderConfig) => Promise; onCancel: () => void; }) { const { t } = useTranslation(); const { allProviders } = useConfigStore(); const [name, setName] = useState(""); const [baseUrl, setBaseUrl] = useState(""); const [apiKey, setApiKey] = useState(""); const [api, setApi] = useState("openai-completions"); const [models, setModels] = useState([]); const [showAddModel, setShowAddModel] = useState(false); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(false); const id = deriveProviderId(name, baseUrl); const idExists = !!id && allProviders.some((p) => p.id === id); const urlInvalid = baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim()); const handleSubmit = async () => { if (!id || !baseUrl || idExists || urlInvalid) return; setSubmitting(true); setSubmitError(false); // Seed the key pool so the detail panel can switch keys right away. const seedKey = apiKey.trim() ? { id: newKeyId(), key: apiKey.trim() } : null; const ok = await onSubmit(id, { name: name.trim() || undefined, baseUrl, api, apiKey: seedKey?.key, apiKeys: seedKey ? [seedKey] : undefined, activeKeyId: seedKey?.id, models, }); setSubmitting(false); if (!ok) setSubmitError(true); }; return (

{t("providers_models.add_provider_title")}

{t("providers_models.add_provider_desc")}

{/* Name */}
setName(e.target.value)} placeholder={t("providers_models.name_placeholder")} className={cn( "mt-1.5 w-full rounded-lg border bg-gray-800 px-3 py-2.5 text-sm text-white", idExists ? "border-red-500" : "border-gray-700" )} /> {idExists ? (

{t("providers_models.id_exists", id)}

) : id ? (

{t("providers_models.id_preview", id)}

) : name.trim() ? (

{t("providers_models.id_invalid")}

) : null}
{/* Base URL */}
setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" className={cn( "mt-1.5 w-full rounded-lg border bg-gray-800 px-3 py-2.5 text-sm text-white", urlInvalid ? "border-red-500" : "border-gray-700" )} /> {urlInvalid && (

{t("providers_models.invalid_url")}

)}
{/* API Key */}
setApiKey(e.target.value)} placeholder="sk-... or $MY_API_KEY" className="mt-1.5 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 text-sm text-white" />
{/* API Type */}
{/* Connection test with the values entered above */} {baseUrl.trim() !== "" && !urlInvalid && ( )} {/* Initial Model List */}
{models.length > 0 && (
{models.map((m) => (
{m.id} {m.contextWindow ? formatTokens(m.contextWindow) : "—"}
))}
)}
{submitError && ( {t("providers_models.save_failed")} )}
{/* Add Initial Model Modal */} setShowAddModel(false)} title={t("models.add_model")} size="xl" > { if (!form.id) return; setModels([...models.filter((x) => x.id !== form.id), form as Model]); setShowAddModel(false); }} onCancel={() => setShowAddModel(false)} />
); } // ─── Import Provider Modal ───────────────────────────────── function ImportProviderModal({ open, onClose, onImported, }: { open: boolean; onClose: () => void; onImported: (id: string) => void; }) { const { t } = useTranslation(); const { allProviders } = useConfigStore(); const [text, setText] = useState(""); const [name, setName] = useState(""); const [baseUrl, setBaseUrl] = useState(""); const [apiKeys, setApiKeys] = useState([]); const [apiKeysText, setApiKeysText] = useState(""); const [api, setApi] = useState("openai-completions"); const [modelIds, setModelIds] = useState([]); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(false); // ─── Fetch models from the endpoint (one-shot import) ─── const [fetching, setFetching] = useState(false); const [fetchErr, setFetchErr] = useState(null); const [fetchDone, setFetchDone] = useState(false); const [fetchedModels, setFetchedModels] = useState([]); const [fetchSel, setFetchSel] = useState>(new Set()); // Re-parse on every paste/edit of the raw text; fields below stay editable const handleText = (value: string) => { setText(value); const parsed = parseProviderImport(value); setName(parsed.name); setBaseUrl(parsed.baseUrl); setApiKeys(parsed.apiKeys); setApiKeysText(formatImportedApiKeys(parsed.apiKeys)); setModelIds(parsed.modelIds); }; const handleApiKeysText = (value: string) => { setApiKeysText(value); setApiKeys(parseImportedApiKeys(value)); }; const reset = () => { setText(""); setName(""); setBaseUrl(""); setApiKeys([]); setApiKeysText(""); setApi("openai-completions"); setModelIds([]); setSubmitting(false); setSubmitError(false); setFetching(false); setFetchErr(null); setFetchDone(false); setFetchedModels([]); setFetchSel(new Set()); }; const id = deriveProviderId(name, baseUrl); const existing = allProviders.find((p) => p.id === id); const builtinConflict = existing?.type === "builtin"; const mergeTarget = existing?.type === "custom" ? existing : null; const urlInvalid = baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim()); const parsedEmpty = text.trim() !== "" && !name && !baseUrl && apiKeys.length === 0 && modelIds.length === 0; const canSubmit = !!id && (!!baseUrl.trim() || !!mergeTarget) && !urlInvalid && !builtinConflict && !submitting; // Fetched models not already covered by the parsed model chips const availableFetched = fetchedModels.filter((m) => !modelIds.includes(m.id)); const allFetchedSelected = availableFetched.length > 0 && availableFetched.every((m) => fetchSel.has(m.id)); const handleFetchModels = async () => { if (!isValidHttpUrl(baseUrl.trim()) || fetching) return; setFetching(true); setFetchErr(null); setFetchDone(false); try { const res = await fetch("/api/pi/provider-models", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ baseUrl: baseUrl.trim(), apiKey: apiKeys[0]?.key ?? "", providerId: id || undefined }), }); const data = await res.json(); if (data.error) { setFetchErr(data.error); } else { const models = (data.models ?? []) as FetchedModel[]; setFetchedModels(models); // Select everything by default so import is a single click setFetchSel(new Set(models.map((m) => m.id))); } } catch { setFetchErr("network error"); } finally { setFetching(false); setFetchDone(true); } }; const toggleFetched = (mid: string) => { setFetchSel((prev) => { const next = new Set(prev); next.has(mid) ? next.delete(mid) : next.add(mid); return next; }); }; const toggleAllFetched = () => { setFetchSel(allFetchedSelected ? new Set() : new Set(availableFetched.map((m) => m.id))); }; const handleImport = async () => { if (!canSubmit) return; setSubmitting(true); setSubmitError(false); const store = useConfigStore.getState(); const defaults = { input: ["text"] as Model["input"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, }; const selectedFetched = availableFetched.filter((m) => fetchSel.has(m.id)); const newModels: Model[] = [ ...modelIds.map((mid) => ({ id: mid, name: mid.split("/").pop() || mid, contextWindow: DEFAULT_CONTEXT_WINDOW, maxTokens: DEFAULT_MAX_TOKENS, ...defaults, })), // Fetched models carry real context/output/reasoning/cost when the endpoint provides them ...selectedFetched.map((m) => { const input: Model["input"] = ["text"]; if (m.vision) input.push("image"); if (m.audio) input.push("audio"); const cost = m.cost ? { input: m.cost.input ?? 0, output: m.cost.output ?? 0, cacheRead: m.cost.cacheRead ?? 0, cacheWrite: m.cost.cacheWrite ?? 0, } : defaults.cost; return { ...defaults, id: m.id, name: m.name ?? (m.id.split("/").pop() || m.id), reasoning: m.reasoning ?? false, input, contextWindow: m.contextWindow ?? DEFAULT_CONTEXT_WINDOW, maxTokens: m.maxTokens ?? DEFAULT_MAX_TOKENS, cost, }; }), ]; const newIds = newModels.map((m) => m.id); let ok: boolean; if (mergeTarget) { // Merge into the existing custom provider (dedupe models by id) const existingCfg = store.modelsJson?.providers[id]; const existingModels = existingCfg?.models ?? []; const merged = [ ...existingModels, ...newModels.filter((m) => !existingModels.some((e) => e.id === m.id)), ]; // An imported key joins the existing pool instead of replacing it; the // active key only changes when the provider had none. const pool = normalizeKeyPool(existingCfg?.apiKeys, existingCfg?.apiKey); const incoming = apiKeys.filter((entry) => !pool.some((key) => key.key === entry.key)); const nextPool = [ ...pool, ...incoming.map((entry) => ({ id: newKeyId(), key: entry.key, ...(entry.label ? { label: entry.label } : {}), })), ]; const activeId = nextPool.find((k) => k.id === existingCfg?.activeKeyId)?.id ?? nextPool[0]?.id; ok = await store.updateCustomProvider(id, { baseUrl: baseUrl.trim() || existingCfg?.baseUrl, apiKeys: nextPool.length > 0 ? nextPool : undefined, activeKeyId: activeId, apiKey: nextPool.find((k) => k.id === activeId)?.key, models: merged, }); } else { const seedKeys = apiKeys.map((entry) => ({ id: newKeyId(), key: entry.key, ...(entry.label ? { label: entry.label } : {}), })); ok = await store.addCustomProvider(id, { name: name.trim() || undefined, baseUrl: baseUrl.trim(), api, apiKey: seedKeys[0]?.key, apiKeys: seedKeys.length > 0 ? seedKeys : undefined, activeKeyId: seedKeys[0]?.id, models: newModels, }); } // Imported models are enabled by default (settings.enabledModels refs) if (ok && newIds.length > 0) { const refs = newIds.map((m) => `${id}/${m}`); const list = store.settings?.enabledModels ?? []; await store.updateSettings({ enabledModels: Array.from(new Set([...list, ...refs])) }); } setSubmitting(false); if (ok) { reset(); onImported(id); } else { setSubmitError(true); } }; return ( { reset(); onClose(); }} title={t("providers_models.import_title")} size="xl" >

{t("providers_models.import_desc")}

{/* Paste area */}