import { useEffect, useId, useState } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { getDefaultMaxContextSize } from "../lib/model-defaults";
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
import { getIconMetadata } from "../icons/extracted/metadata";
import { AgentSettingsPanel } from "./AgentSettingsPanel";
import { ProviderIcon } from "./ProviderIcon";
import { IconPicker } from "./IconPicker";
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
const KNOWN_CAPABILITIES = [
"thinking",
"always_thinking",
"image_in",
"video_in",
"tool_use",
] as const;
const CAPABILITY_LABELS: Record<
(typeof KNOWN_CAPABILITIES)[number],
TranslationKey
> = {
thinking: "capThinking",
always_thinking: "capAlwaysThinking",
image_in: "capImageIn",
video_in: "capVideoIn",
tool_use: "capToolUse",
};
const PROVIDER_TYPES: ProviderType[] = [
"openai",
"openai_responses",
"anthropic",
"google-genai",
"vertexai",
"kimi",
];
function defaultBaseUrl(agent: Agent, type: ProviderType): string {
if (agent === "kimi_code") {
switch (type) {
case "kimi":
return "https://api.kimi.com/coding/v1";
case "openai":
case "openai_responses":
return "https://api.openai.com/v1";
case "google-genai":
return "https://generativelanguage.googleapis.com";
case "anthropic":
case "vertexai":
return "";
}
}
// Pi defaults
switch (type) {
case "kimi":
return "https://api.moonshot.ai/v1";
case "openai":
case "openai_responses":
return "https://api.openai.com/v1";
case "google-genai":
return "https://generativelanguage.googleapis.com";
case "anthropic":
case "vertexai":
return "";
}
}
interface ProviderEditProps {
agent: Agent;
provider: Provider;
models: Model[];
defaultModel: string | null;
rawOther: unknown;
onRawOtherChange: (nextRawOther: unknown) => void;
onBack: () => void;
onChange: (provider: Provider) => void;
onDelete: () => void;
onModelChange: (model: Model) => void;
onModelDelete: (alias: string) => void;
onModelAdd: () => void;
onBulkAdd: (models: Model[]) => void;
onSetDefault: (alias: string) => void;
onApplyJson: (provider: Provider, models: Model[]) => void;
onSave: () => void;
}
export function ProviderEdit({
agent,
provider,
models,
defaultModel,
rawOther,
onRawOtherChange,
onBack,
onChange,
onDelete,
onModelChange,
onModelDelete,
onModelAdd,
onBulkAdd,
onSetDefault,
onApplyJson,
onSave,
}: ProviderEditProps) {
const { t } = useTranslation();
const nameId = useId();
const noteId = useId();
const officialUrlId = useId();
const apiKeyId = useId();
const baseUrlId = useId();
const [activeTab, setActiveTab] = useState<"basic" | "models" | "json">("basic");
const [showApiKey, setShowApiKey] = useState(false);
const [showIconPicker, setShowIconPicker] = useState(false);
useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type);
if (!def) return;
if (!provider.base_url) {
onChange({ ...provider, base_url: def });
}
}, [provider.provider_type, agent]);
return (
{/* Header */}
{t("editProvider")}
{/* Tabs */}
{[
{ key: "basic", label: t("basicInfo") },
{ key: "models", label: t("modelMapping") },
{ key: "json", label: t("configJson") },
].map((tab) => (
))}
{/* Content */}
{activeTab === "basic" && (
{/* Basic info */}
{t("basicInfo")}
{/* Icon selector */}
{provider.icon && (
)}
{/* API settings */}
{t("apiSettings")}
{!provider.managed && (
)}
)}
{activeTab === "models" && (
<>
{agent === "kimi_code" && (
)}
>
)}
{activeTab === "json" && (
)}
{showIconPicker &&
createPortal(
setShowIconPicker(false)}
role="dialog"
aria-modal="true"
>
e.stopPropagation()}
>
{t("selectIcon")}
{
const meta = getIconMetadata(iconName);
onChange({
...provider,
icon: iconName,
icon_color: meta?.defaultColor ?? null,
});
setShowIconPicker(false);
}}
/>
,
document.body
)}
);
}
function ModelMapping({
agent,
provider,
models,
defaultModel,
onModelChange,
onModelDelete,
onModelAdd,
onBulkAdd,
onSetDefault,
}: {
agent: Agent;
provider: Provider;
models: Model[];
defaultModel: string | null;
onModelChange: (model: Model) => void;
onModelDelete: (alias: string) => void;
onModelAdd: () => void;
onBulkAdd: (models: Model[]) => void;
onSetDefault: (alias: string) => void;
}) {
const { t } = useTranslation();
const [discovering, setDiscovering] = useState(false);
const [discovered, setDiscovered] = useState(null);
const [selected, setSelected] = useState>(new Set());
const [discoverError, setDiscoverError] = useState(null);
const [fetchThinking, setFetchThinking] = useState(true);
const handleDiscover = async () => {
setDiscovering(true);
setDiscoverError(null);
setDiscovered(null);
setSelected(new Set());
try {
const result = await invoke("list_provider_models", {
provider,
});
setDiscovered(result);
} catch (err) {
setDiscoverError(err instanceof Error ? err.message : String(err));
} finally {
setDiscovering(false);
}
};
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleAddSelected = () => {
if (!discovered || selected.size === 0) return;
const toAdd: Model[] = [];
for (const dm of discovered) {
if (!selected.has(dm.id)) continue;
const safeProvider = provider.name.replace(/\//g, "-");
const safeModelId = dm.id.replace(/\//g, "-");
const alias = `${safeProvider}/${safeModelId}`;
const ref = getModelRef(dm.id);
// Priority: API-provided value > models.dev reference > regex fallback
const max_context_size =
dm.max_context_size ?? ref?.context ?? getDefaultMaxContextSize(dm.id);
toAdd.push({
alias,
provider: provider.name,
model: dm.id,
max_context_size,
display_name: dm.display_name ?? ref?.name ?? null,
supports_1m: max_context_size >= 1_000_000,
// Reference data wins when available; otherwise keep the old
// fetchThinking-checkbox behavior as the fallback.
capabilities: ref
? capabilitiesFromRef(ref)
: fetchThinking
? ["thinking"]
: [],
});
}
onBulkAdd(toAdd);
setDiscovered(null);
setSelected(new Set());
};
return (
{t("modelMapping")}
{t("modelMappingDesc")}
{discoverError && (
{discoverError}
)}
{discovered && (
)}
{models.length === 0 && (
{t("noModelMappings")}
)}
);
}
function JsonPreview({
provider,
models,
onApply,
}: {
provider: Provider;
models: Model[];
onApply: (provider: Provider, models: Model[]) => void;
}) {
const { t } = useTranslation();
const [text, setText] = useState(() =>
JSON.stringify({ provider, models }, null, 2)
);
const [error, setError] = useState(null);
useEffect(() => {
setText(JSON.stringify({ provider, models }, null, 2));
setError(null);
}, [provider, models]);
const handleApply = () => {
try {
const parsed = JSON.parse(text);
if (!parsed.provider || typeof parsed.provider !== "object") {
throw new Error("missing provider object");
}
if (!Array.isArray(parsed.models)) {
throw new Error("models must be an array");
}
onApply(parsed.provider as Provider, parsed.models as Model[]);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
return (
{t("configJson")}
{t("readOnlyPreview")}
{error && (
{t("invalidJson", { message: error })}
)}
);
}
function CapabilitiesCell({
capabilities,
onChange,
}: {
capabilities: string[];
onChange: (next: string[]) => void;
}) {
const { t } = useTranslation();
const known = new Set(KNOWN_CAPABILITIES);
const customs = capabilities.filter((c) => !known.has(c));
const toggle = (cap: string, on: boolean) => {
if (on) {
onChange([...capabilities.filter((c) => c !== cap), cap]);
} else {
onChange(capabilities.filter((c) => c !== cap));
}
};
return (
{KNOWN_CAPABILITIES.map((cap) => (
))}
{
const newCustoms = e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const kept = capabilities.filter((c) => known.has(c));
onChange([...kept, ...newCustoms]);
}}
/>
);
}