import { useMemo, useState } from "react"; import type { CustomProviderConfig, CustomProviderModelInput } 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 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))); } 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} />
))}
{mergedError ?
{mergedError}
: null}
{onCancel ? : null}
); }