import { useCallback, useMemo, useState } from "react"; import { Loader2, Search } from "lucide-react"; import type { CustomProviderConfig, CustomProviderModelInput } from "../api"; import { probeProviderModels } from "../api"; import "./CustomProviderForm.css"; // Reserved built-in IDs (including hidden/deprecated aliases) to prevent custom-provider collisions. export const BUILT_IN_PROVIDER_IDS = new Set([ "anthropic", "claude-cli", "pi-claude-cli", "openai", "openai-codex", "google", "gemini", "google-antigravity", "antigravity", "google-vertex", "vertex", "google-cloud-code", "cloud-code", "google-gemini-cli", "google-generative-ai", "ollama", "github", "github-copilot", "openrouter", "minimax", "minimax-cn", "zai", "kimi", "moonshot", "kimi-coding", "bedrock", "amazon-bedrock", "xai", "grok", "opencode", "opencode-go", "qwen", "qwen-ai", "qwen-coder", "alibaba", "tongyi", "lmstudio", "lm-studio", "huggingface", "hugging-face", "hf", "mistral", "mistral-ai", "azure", "azure-openai", "azure-openai-responses", "fireworks", "fireworks-ai", "fireworksai", "cerebras", "groq", "vercel", "vercel-ai-gateway", "hermes", "hermes-agent", "hermesagent", "openclaw", "open-claw", "paperclip", "paperclipai", "paperclip-ai", ]); const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/; const API_TYPES: CustomProviderConfig["api"][] = [ "openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai", ]; type Props = { initialConfig?: CustomProviderConfig; onSave: (config: CustomProviderConfig) => void | Promise; onCancel?: () => void; saving?: boolean; error?: string; }; function emptyModel(): CustomProviderModelInput { return { id: "", name: "", reasoning: false }; } export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = false, error }: Props) { const editing = Boolean(initialConfig); const [id, setId] = useState(initialConfig?.id ?? ""); const [name, setName] = useState(initialConfig?.name ?? ""); const [baseUrl, setBaseUrl] = useState(initialConfig?.baseUrl ?? ""); const [api, setApi] = useState(initialConfig?.api ?? "openai-completions"); const [apiKey, setApiKey] = useState(initialConfig?.apiKey ?? ""); const [models, setModels] = useState(initialConfig?.models?.length ? initialConfig.models : [emptyModel()]); const [validationError, setValidationError] = useState(null); const [detecting, setDetecting] = useState(false); const [detectError, setDetectError] = useState(null); const canRemoveModel = models.length > 1; const mergedError = useMemo(() => validationError ?? error ?? null, [validationError, error]); function updateModel(index: number, patch: Partial) { setModels((prev) => prev.map((model, i) => (i === index ? { ...model, ...patch } : model))); } function removeModel(index: number) { setModels((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))); } // Detect Models is available for all API types that expose a /models endpoint: // - openai-completions / openai-responses → openai-compatible // - anthropic-messages → anthropic-compatible // - google-generative-ai → google-generative-ai const probeApiType = api === "anthropic-messages" ? "anthropic-compatible" : api === "google-generative-ai" ? "google-generative-ai" : "openai-compatible"; const handleDetectModels = useCallback(async () => { const trimmedBaseUrl = baseUrl.trim(); if (!trimmedBaseUrl) { setDetectError("Base URL is required to detect models."); return; } setDetecting(true); setDetectError(null); try { const result = await probeProviderModels({ baseUrl: trimmedBaseUrl, apiKey: apiKey.trim() || undefined, apiType: probeApiType, }); if (result.models.length === 0) { setDetectError("No models found. The provider may require an API key."); return; } // Merge discovered models, avoiding duplicates by ID const existingIds = new Set(models.map((m) => m.id.trim())); const newModels = result.models .filter((m) => !existingIds.has(m.id.trim())) .map((m) => ({ id: m.id, name: m.name || m.id, reasoning: Boolean(m.reasoning), contextWindow: m.contextWindow, maxTokens: m.maxTokens, })); if (newModels.length > 0) { // Replace empty default rows with discovered models setModels((prev) => { const nonEmpty = prev.filter((m) => m.id.trim().length > 0); return [...nonEmpty, ...newModels]; }); } else { setDetectError("All discovered models are already in the list."); } } catch (err) { setDetectError( err instanceof Error ? err.message : "Failed to detect models", ); } finally { setDetecting(false); } }, [baseUrl, apiKey, probeApiType, models]); function validate(): string | null { if (!id.trim()) return "Provider ID is required."; if (!PROVIDER_ID_PATTERN.test(id.trim())) return "Provider ID must be kebab-case."; if (!editing && BUILT_IN_PROVIDER_IDS.has(id.trim())) return "Provider ID conflicts with a built-in provider."; if (!baseUrl.trim()) return "Base URL is required."; try { const parsed = new URL(baseUrl.trim()); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return "Base URL must use http or https."; } } catch { return "Base URL must be a valid URL."; } if (!API_TYPES.includes(api)) return "API type is required."; if (models.length === 0) return "At least one model is required."; if (models.some((model) => !model.id?.trim())) return "Each model must have a model ID."; return null; } async function onSubmit(e: React.FormEvent) { e.preventDefault(); const message = validate(); setValidationError(message); if (message) return; await onSave({ id: id.trim(), name: name.trim() || undefined, baseUrl: baseUrl.trim(), api, apiKey: apiKey.trim() || undefined, models: models.map((model) => ({ id: model.id.trim(), name: model.name?.trim() || undefined, reasoning: Boolean(model.reasoning), contextWindow: model.contextWindow, maxTokens: model.maxTokens, })), }); } return (
setId(e.target.value)} disabled={editing || saving} />
setName(e.target.value)} disabled={saving} />
setBaseUrl(e.target.value)} disabled={saving} />
setApiKey(e.target.value)} disabled={saving} />
{models.map((model, index) => (
updateModel(index, { id: e.target.value })} disabled={saving} /> updateModel(index, { name: e.target.value })} disabled={saving} /> updateModel(index, { contextWindow: e.target.value ? Number(e.target.value) : undefined })} disabled={saving} /> updateModel(index, { maxTokens: e.target.value ? Number(e.target.value) : undefined })} disabled={saving} />
))}
{detectError ?
{detectError}
: null}
{mergedError ?
{mergedError}
: null}
{onCancel ? : null}
); }