From e3dfa48994ee58c26569c5461120c8ef806f487a Mon Sep 17 00:00:00 2001 From: OpenClaw Assistant Date: Sat, 9 May 2026 18:22:16 +0200 Subject: [PATCH] feat(ai): add OpenAI Compatible and Anthropic Compatible providers with custom endpoint support - Add OpenAI Compatible provider (openai-compatible) with custom baseURL + API key - Add Anthropic Compatible provider (anthropic-compatible) with custom baseURL + API key - Both support model loading via /models endpoint after API key + URL are set - Model selector dropdown appears after clicking 'Load Models' button - Extends AiProviderId type and getActiveProviderId whitelist - Updates AI_PROVIDERS in TopTabsBar with hasCustomUrl flag - ProviderSelector now shows all 4 providers with keyId mapping --- src/components/TopTabsBar.tsx | 138 +++++++++++++++++-- src/components/chat/ProviderSelector.tsx | 6 +- src/lib/ai/index.ts | 4 + src/lib/ai/providers/anthropic-compatible.ts | 113 +++++++++++++++ src/lib/ai/providers/openai-compatible.ts | 115 ++++++++++++++++ src/lib/ai/settings.ts | 66 ++++++++- src/lib/ai/types.ts | 2 +- 7 files changed, 432 insertions(+), 12 deletions(-) create mode 100644 src/lib/ai/providers/anthropic-compatible.ts create mode 100644 src/lib/ai/providers/openai-compatible.ts diff --git a/src/components/TopTabsBar.tsx b/src/components/TopTabsBar.tsx index 8f77523..9dc2416 100644 --- a/src/components/TopTabsBar.tsx +++ b/src/components/TopTabsBar.tsx @@ -6,11 +6,12 @@ import { useProjectContext } from "../lib/project/ProjectContext"; import { getActiveProviderId, setActiveProviderId } from "../lib/ai/settings"; import type { AiProviderId } from "../lib/ai/types"; -const AI_PROVIDERS: Array<{ id: string; name: string; providerId?: AiProviderId }> = [ - { id: "google", name: "Google Gemini", providerId: "gemini" }, - { id: "anthropic", name: "Anthropic Claude", providerId: "anthropic" }, - { id: "xai", name: "xAI" }, - { id: "openai", name: "OpenAI" }, +const AI_PROVIDERS: Array<{ id: string; name: string; providerId?: AiProviderId; hasCustomUrl?: boolean }> = [ + { id: "google", name: "Google Gemini", providerId: "gemini" }, + { id: "anthropic", name: "Anthropic Claude", providerId: "anthropic" }, + { id: "xai", name: "xAI" }, + { id: "openai", name: "OpenAI Compatible", providerId: "openai", hasCustomUrl: true }, + { id: "anthropic-compatible", name: "Anthropic Compatible", providerId: "anthropic-compatible", hasCustomUrl: true }, ]; const SEARCH_PROVIDERS: Array<{ id: string; name: string; hint: string }> = [ @@ -43,6 +44,8 @@ function ApiKeyModal({ onClose }: { onClose: () => void }) { const [visibleId, setVisibleId] = useState(null); const [expandedId, setExpandedId] = useState(null); const [activeProvider, setActiveProvider] = useState(getActiveProviderId); + const [models, setModels] = useState>({}); + const [loadingModels, setLoadingModels] = useState>({}); useEffect(() => { function handleClick(e: MouseEvent) { @@ -58,6 +61,43 @@ function ApiKeyModal({ onClose }: { onClose: () => void }) { localStorage.setItem("raincast-api-keys", JSON.stringify(next)); } + async function loadModels(providerId: string) { + const baseUrl = (keys[`${providerId}-url`] || "").trim(); + const apiKey = keys[providerId] || ""; + if (!apiKey || !baseUrl) return; + + setLoadingModels((prev) => ({ ...prev, [providerId]: true })); + try { + const res = await fetch(`${baseUrl}/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const modelList: string[] = Array.isArray(data.data) + ? data.data.map((m: { id: string }) => m.id) + : Array.isArray(data) + ? data.map((m: string) => m) + : []; + setModels((prev) => ({ ...prev, [providerId]: modelList })); + } catch (err) { + console.warn("[loadModels]", err); + } finally { + setLoadingModels((prev) => ({ ...prev, [providerId]: false })); + } + } + + function updateCustomUrl(providerId: string, value: string) { + const next = { ...keys, [`${providerId}-url`]: value }; + setKeys(next); + localStorage.setItem("raincast-api-keys", JSON.stringify(next)); + } + + function updateCustomModel(providerId: string, value: string) { + const next = { ...keys, [`${providerId}-model`]: value }; + setKeys(next); + localStorage.setItem("raincast-api-keys", JSON.stringify(next)); + } + return (
void }) { {isOpen && ( -
+
+ {p.hasCustomUrl && ( + <> + + updateCustomUrl(p.id, e.target.value)} + placeholder={p.id === "openai" ? "https://api.openai.com/v1" : "https://api.anthropic.com/v1"} + style={{ + width: "100%", + padding: "6px 10px", + fontSize: 12, + borderRadius: 8, + border: "1px solid var(--input-border)", + background: "var(--input-bg)", + color: "var(--text-input)", + outline: "none", + boxSizing: "border-box", + }} + onFocus={(e) => { e.currentTarget.style.borderColor = "var(--slider-thumb)"; }} + onBlur={(e) => { e.currentTarget.style.borderColor = "var(--input-border)"; }} + /> + + + + {(models[p.id] || []).length > 0 && ( + + )} + + )} + {p.providerId && hasKey && !isActive && (