From f2545c6de909c19a61614ec4f46f5b1654db13e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 10:39:10 +0000 Subject: [PATCH] feat: custom LLM provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a UI-configurable Custom (OpenAI-compatible) LLM provider so users can point an agent at any OpenAI-compatible endpoint (e.g. vLLM, LM Studio, a self-hosted gateway) by entering a Base URL, API key, and model name — no code changes or redeploys required. - Backend: route models with the `custom/` prefix through CustomProvider in `clawbot/core/config/schema.py`, and strip that prefix before calling the upstream endpoint in `clawbot/providers/custom_provider.py`. - API: extend `ListModelsRequest` with `api_base` and add a `custom` branch to `/api/providers/models` in `clawforce/apis/providers.py` that lists models from the configured base URL with optional bearer auth. - UI: register the provider in `ModelProviderSection.tsx`, add the Base URL input, and thread `api_base` through `listModels` in `lib/api.ts` and the supporting agent-detail components. - Rebuilt static assets under `clawforce/static/`. --- clawbot/core/config/schema.py | 10 + clawbot/providers/custom_provider.py | 8 +- .../src/components/OnboardingWizardModal.tsx | 4 +- .../src/components/agent-detail/constants.ts | 1 + .../agent-detail/settings/GeneralTab.tsx | 4 +- .../settings/ModelProviderSection.tsx | 106 ++++++++-- clawforce-ui/src/lib/api.ts | 4 +- clawforce/apis/providers.py | 52 +++++ clawforce/static/assets/index-DPv8Ue-n.css | 33 +++ clawforce/static/assets/index-xDtyJd6w.js | 193 ++++++++++++++++++ clawforce/static/index.html | 4 +- 11 files changed, 391 insertions(+), 28 deletions(-) create mode 100644 clawforce/static/assets/index-DPv8Ue-n.css create mode 100644 clawforce/static/assets/index-xDtyJd6w.js diff --git a/clawbot/core/config/schema.py b/clawbot/core/config/schema.py index a6688c8..8ab25c6 100644 --- a/clawbot/core/config/schema.py +++ b/clawbot/core/config/schema.py @@ -31,6 +31,16 @@ def _match_provider( """Match provider config and its registry name. Returns (config, spec_name).""" model_lower = (model or self.agents.defaults.model).lower() + # Explicit routing by "/..." prefix — deterministic and works + # for providers with no keywords (e.g. "custom/my-model"). + if "/" in model_lower: + prefix = model_lower.split("/", 1)[0] + spec = find_by_name(prefix) + if spec: + p = getattr(self.providers, spec.name, None) + if p is not None: + return p, spec.name + # First pass: match by keyword in model name + has API key for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) diff --git a/clawbot/providers/custom_provider.py b/clawbot/providers/custom_provider.py index 2676edc..22385b3 100644 --- a/clawbot/providers/custom_provider.py +++ b/clawbot/providers/custom_provider.py @@ -29,8 +29,14 @@ async def chat( max_tokens: int = 4096, temperature: float = 0.7, ) -> LLMResponse: + # The config stores models as "custom/" so provider routing + # can find us, but the upstream OpenAI-compatible endpoint only knows + # the bare id — strip the routing prefix before sending. + resolved_model = model or self.default_model + if resolved_model.startswith("custom/"): + resolved_model = resolved_model[len("custom/") :] kwargs: dict[str, Any] = { - "model": model or self.default_model, + "model": resolved_model, "messages": messages, "max_tokens": max(1, max_tokens), "temperature": temperature, diff --git a/clawforce-ui/src/components/OnboardingWizardModal.tsx b/clawforce-ui/src/components/OnboardingWizardModal.tsx index c5a8cbb..98f7873 100644 --- a/clawforce-ui/src/components/OnboardingWizardModal.tsx +++ b/clawforce-ui/src/components/OnboardingWizardModal.tsx @@ -231,11 +231,11 @@ export function OnboardingWizardModal({ model={wizardAgent.model} savedProviders={wizardAgent.providers as Record> | undefined} onModelChange={(v) => update({ model: v })} - onProviderKeyChange={(provider, key) => { + onProviderChange={(provider, patch) => { update({ providers: { ...wizardAgent.providers, - [provider]: { ...(wizardAgent.providers?.[provider] || {}), apiKey: key }, + [provider]: { ...(wizardAgent.providers?.[provider] || {}), ...patch }, }, }); }} diff --git a/clawforce-ui/src/components/agent-detail/constants.ts b/clawforce-ui/src/components/agent-detail/constants.ts index d722fbb..b9a9538 100644 --- a/clawforce-ui/src/components/agent-detail/constants.ts +++ b/clawforce-ui/src/components/agent-detail/constants.ts @@ -132,6 +132,7 @@ export const PROVIDER_DEFS: ProviderDef[] = [ { field: "chatgpt", label: "ChatGPT Plus", keywords: ["chatgpt"], oauth: true }, { field: "openai_codex", label: "OpenAI Codex", keywords: ["openai-codex", "codex"], oauth: true }, // API key / token providers + { field: "custom", label: "Custom (OpenAI-compatible)", keywords: ["custom/"] }, { field: "anthropic", label: "Anthropic", keywords: ["anthropic", "claude"] }, { field: "openai", label: "OpenAI", keywords: ["openai", "gpt", "o1", "o3", "o4"] }, { field: "openrouter", label: "OpenRouter", keywords: ["openrouter"] }, diff --git a/clawforce-ui/src/components/agent-detail/settings/GeneralTab.tsx b/clawforce-ui/src/components/agent-detail/settings/GeneralTab.tsx index cc18c06..374b6ef 100644 --- a/clawforce-ui/src/components/agent-detail/settings/GeneralTab.tsx +++ b/clawforce-ui/src/components/agent-detail/settings/GeneralTab.tsx @@ -233,11 +233,11 @@ export function GeneralTab({ agentId, agent, update, updateTools }: { agentId: s model={agent.model} savedProviders={agent.providers as Record> | undefined} onModelChange={(v) => update({ model: v })} - onProviderKeyChange={(provider, key) => { + onProviderChange={(provider, patch) => { update({ providers: { ...agent.providers, - [provider]: { ...(agent.providers?.[provider] || {}), apiKey: key }, + [provider]: { ...(agent.providers?.[provider] || {}), ...patch }, }, }); }} diff --git a/clawforce-ui/src/components/agent-detail/settings/ModelProviderSection.tsx b/clawforce-ui/src/components/agent-detail/settings/ModelProviderSection.tsx index 59d6b4d..654947f 100644 --- a/clawforce-ui/src/components/agent-detail/settings/ModelProviderSection.tsx +++ b/clawforce-ui/src/components/agent-detail/settings/ModelProviderSection.tsx @@ -10,13 +10,13 @@ export function ModelProviderSection({ model, savedProviders, onModelChange, - onProviderKeyChange, + onProviderChange, }: { agentId: string; model: string; savedProviders?: Record>; onModelChange: (model: string) => void; - onProviderKeyChange: (provider: string, apiKey: string) => void; + onProviderChange: (provider: string, patch: { apiKey?: string; apiBase?: string }) => void; }) { // Derive initial provider from current model const detected = detectProvider(model); @@ -49,6 +49,20 @@ export function ModelProviderSection({ } }, [savedKey]); + // Custom provider: OpenAI-compatible base URL (snake/camel tolerant) + const savedBase = selectedProvider && savedProviders?.[selectedProvider] + ? ((savedProviders[selectedProvider].apiBase ?? savedProviders[selectedProvider].api_base ?? "") as string) + : ""; + const [apiBase, setApiBase] = useState(savedBase); + + const prevSavedBaseRef = useRef(savedBase); + useEffect(() => { + if (savedBase !== prevSavedBaseRef.current) { + prevSavedBaseRef.current = savedBase; + setApiBase(savedBase); + } + }, [savedBase]); + const [models, setModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); const [modelError, setModelError] = useState(""); @@ -157,17 +171,27 @@ export function ModelProviderSection({ if (providerDef?.oauth) return; // handled by the OAuth effect above const isStatic = ["bedrock", "azure"].includes(selectedProvider); const hasSavedKey = !!(savedKey && savedKey.length > 0); - if (isStatic || hasSavedKey) { + const isCustom = selectedProvider === "custom"; + const hasSavedBase = !!(savedBase && savedBase.length > 0); + // Custom needs a base URL; API key is optional (self-hosted endpoints). + const canAutoFetch = isStatic || (isCustom ? hasSavedBase : hasSavedKey); + if (canAutoFetch) { setLoadingModels(true); setModelError(""); - // For saved (redacted) keys, pass agentId so backend uses stored key + // For saved (redacted) keys/bases, pass agentId so backend uses stored values const keyToSend = savedKey.startsWith("***") ? "" : savedKey; - api.providers.listModels(selectedProvider, keyToSend, keyToSend ? undefined : agentId) + const baseToSend = savedBase.startsWith("***") ? "" : savedBase; + api.providers.listModels( + selectedProvider, + keyToSend, + keyToSend && (!isCustom || baseToSend) ? undefined : agentId, + isCustom ? baseToSend : undefined, + ) .then((r) => setModels(r.models)) .catch(() => {}) .finally(() => setLoadingModels(false)); } - }, [selectedProvider, agentId, savedKey, providerDef?.oauth]); + }, [selectedProvider, agentId, savedKey, savedBase, providerDef?.oauth]); async function handleOAuthAuthorize() { setOauthLoading(true); @@ -187,13 +211,24 @@ export function ModelProviderSection({ function doFetch() { if (!selectedProvider) return; - // Use explicit key if available, otherwise fall back to stored key via agentId + const isCustom = selectedProvider === "custom"; const hasExplicitKey = apiKey.length > 0 && !apiKey.startsWith("***"); - if (!hasExplicitKey && !savedKey) return; + const hasExplicitBase = apiBase.length > 0 && !apiBase.startsWith("***"); + // Custom requires a base URL; other providers require a key. + if (isCustom) { + if (!hasExplicitBase && !savedBase) return; + } else if (!hasExplicitKey && !savedKey) { + return; + } setLoadingModels(true); setModelError(""); setModels([]); - api.providers.listModels(selectedProvider, hasExplicitKey ? apiKey : "", agentId) + api.providers.listModels( + selectedProvider, + hasExplicitKey ? apiKey : "", + agentId, + isCustom ? (hasExplicitBase ? apiBase : "") : undefined, + ) .then((r) => setModels(r.models)) .catch((err) => { const msg = err instanceof Error ? err.message : String(err); @@ -213,6 +248,10 @@ export function ModelProviderSection({ ? ((savedProviders[field].apiKey ?? savedProviders[field].api_key ?? "") as string) : ""; setApiKey(typeof saved === "string" && !saved.startsWith("***") ? saved : ""); + const savedBaseForField = field && savedProviders?.[field] + ? ((savedProviders[field].apiBase ?? savedProviders[field].api_base ?? "") as string) + : ""; + setApiBase(typeof savedBaseForField === "string" && !savedBaseForField.startsWith("***") ? savedBaseForField : ""); setModels([]); setModelError(""); setModelSearch(""); @@ -220,9 +259,12 @@ export function ModelProviderSection({ function handleModelSelect(modelId: string) { const fullModel = `${selectedProvider}/${modelId}`; - // Save the provider API key into agent.providers so it's included in "Save Changes" - if (needsKey && apiKey) { - onProviderKeyChange(selectedProvider, apiKey); + // Persist API key / base URL into agent.providers so they're included in "Save Changes" + if (needsKey) { + const patch: { apiKey?: string; apiBase?: string } = {}; + if (apiKey && !apiKey.startsWith("***")) patch.apiKey = apiKey; + if (selectedProvider === "custom" && apiBase && !apiBase.startsWith("***")) patch.apiBase = apiBase; + if (Object.keys(patch).length > 0) onProviderChange(selectedProvider, patch); } // OAuth providers: no key to propagate — credentials live in the OS credential store onModelChange(fullModel); @@ -235,6 +277,9 @@ export function ModelProviderSection({ : ""; const hasUsableKey = apiKey.length > 0 || !!(savedKey && savedKey.length > 0); + const hasUsableBase = apiBase.length > 0 || !!(savedBase && savedBase.length > 0); + // Custom only needs a base URL to fetch models; API key is optional. + const canFetchModels = selectedProvider === "custom" ? hasUsableBase : hasUsableKey; const filteredModels = modelSearch ? models.filter((m) => @@ -343,6 +388,24 @@ export function ModelProviderSection({ )} + {/* Base URL input — only for the Custom (OpenAI-compatible) provider */} + {selectedProvider === "custom" && ( +
+ + setApiBase(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") doFetch(); }} + placeholder="https://api.example.com/v1" + /> +

+ Must be an OpenAI API compatible endpoint (e.g. vLLM, LM Studio, a private gateway). +

+
+ )} + {/* Row 2: Model + Fetch models */} {selectedProvider && (
@@ -368,7 +431,9 @@ export function ModelProviderSection({ ? "Loading models…" : models.length === 0 ? (needsKey - ? (savedKey ? "Click Fetch models to load" : "Enter API key and fetch models") + ? (selectedProvider === "custom" + ? (hasUsableBase ? "Click Fetch models to load" : "Enter base URL and fetch models") + : (savedKey ? "Click Fetch models to load" : "Enter API key and fetch models")) : providerDef?.oauth ? (oauthAuthorized ? "Select a model…" : "Connect first to browse models") : "Select a provider first") @@ -438,7 +503,7 @@ export function ModelProviderSection({