diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 7409f61..f9d0a83 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -13,6 +13,11 @@ export class DefaultExecutor extends BaseExecutor { const path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; return `${normalized}${path}`; } + if (this.provider?.startsWith?.("anthropic-compatible-")) { + const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.anthropic.com/v1"; + const normalized = baseUrl.replace(/\/$/, ""); + return `${normalized}/messages`; + } switch (this.provider) { case "claude": case "glm": @@ -42,7 +47,18 @@ export class DefaultExecutor extends BaseExecutor { headers["x-api-key"] = credentials.apiKey; break; default: - headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; + if (this.provider?.startsWith?.("anthropic-compatible-")) { + if (credentials.apiKey) { + headers["x-api-key"] = credentials.apiKey; + } else if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } + if (!headers["anthropic-version"]) { + headers["anthropic-version"] = "2023-06-01"; + } + } else { + headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; + } } if (stream) headers["Accept"] = "text/event-stream"; diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js index 828537a..f6e527b 100644 --- a/open-sse/services/provider.js +++ b/open-sse/services/provider.js @@ -5,10 +5,19 @@ const OPENAI_COMPATIBLE_DEFAULTS = { baseUrl: "https://api.openai.com/v1", }; +const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-"; +const ANTHROPIC_COMPATIBLE_DEFAULTS = { + baseUrl: "https://api.anthropic.com/v1", +}; + function isOpenAICompatible(provider) { return typeof provider === "string" && provider.startsWith(OPENAI_COMPATIBLE_PREFIX); } +function isAnthropicCompatible(provider) { + return typeof provider === "string" && provider.startsWith(ANTHROPIC_COMPATIBLE_PREFIX); +} + function getOpenAICompatibleType(provider) { if (!isOpenAICompatible(provider)) return "chat"; return provider.includes("responses") ? "responses" : "chat"; @@ -20,6 +29,11 @@ function buildOpenAICompatibleUrl(baseUrl, apiType) { return `${normalized}${path}`; } +function buildAnthropicCompatibleUrl(baseUrl) { + const normalized = baseUrl.replace(/\/$/, ""); + return `${normalized}/messages`; +} + // Detect request format from body structure export function detectFormat(body) { // OpenAI Responses API: has input[] array instead of messages[] @@ -104,6 +118,13 @@ export function getProviderConfig(provider) { baseUrl: OPENAI_COMPATIBLE_DEFAULTS.baseUrl, }; } + if (isAnthropicCompatible(provider)) { + return { + ...PROVIDERS.anthropic, // Use Anthropic defaults (header: x-api-key) + format: "claude", + baseUrl: ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl, + }; + } return PROVIDERS[provider] || PROVIDERS.openai; } @@ -120,6 +141,10 @@ export function buildProviderUrl(provider, model, stream = true, options = {}) { const baseUrl = options?.baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl; return buildOpenAICompatibleUrl(baseUrl, apiType); } + if (isAnthropicCompatible(provider)) { + const baseUrl = options?.baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl; + return buildAnthropicCompatibleUrl(baseUrl); + } const config = getProviderConfig(provider); switch (provider) { @@ -170,72 +195,87 @@ export function buildProviderHeaders(provider, credentials, stream = true, body }; // Add auth header - switch (provider) { - case "gemini": - if (credentials.apiKey) { - headers["x-goog-api-key"] = credentials.apiKey; - } else if (credentials.accessToken) { - headers["Authorization"] = `Bearer ${credentials.accessToken}`; - } - break; - - case "antigravity": - case "gemini-cli": - // Antigravity and Gemini CLI use OAuth access token - headers["Authorization"] = `Bearer ${credentials.accessToken}`; - break; - - case "claude": - // Claude uses x-api-key header for API key, or Authorization for OAuth - if (credentials.apiKey) { - headers["x-api-key"] = credentials.apiKey; - } else if (credentials.accessToken) { - headers["Authorization"] = `Bearer ${credentials.accessToken}`; - } - break; - - case "github": - // GitHub Copilot requires special headers to mimic VSCode - // Prioritize copilotToken from providerSpecificData, fallback to accessToken - const githubToken = credentials.copilotToken || credentials.accessToken; - // Add headers in exact same order as test endpoint - headers["Authorization"] = `Bearer ${githubToken}`; - headers["Content-Type"] = "application/json"; - headers["copilot-integration-id"] = "vscode-chat"; - headers["editor-version"] = "vscode/1.107.1"; - headers["editor-plugin-version"] = "copilot-chat/0.26.7"; - headers["user-agent"] = "GitHubCopilotChat/0.26.7"; - headers["openai-intent"] = "conversation-panel"; - headers["x-github-api-version"] = "2025-04-01"; - // Generate a UUID for x-request-id (Cloudflare Workers compatible) - headers["x-request-id"] = crypto.randomUUID ? crypto.randomUUID() : - 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c == 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - headers["x-vscode-user-agent-library-version"] = "electron-fetch"; - headers["X-Initiator"] = "user"; - headers["Accept"] = "application/json"; - break; - - case "codex": - case "qwen": - case "openai": - case "openrouter": - headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; - break; - - case "glm": - case "kimi": - case "minimax": - // Claude-compatible API providers use x-api-key + // Specific override for Anthropic Compatible + if (isAnthropicCompatible(provider)) { + if (credentials.apiKey) { headers["x-api-key"] = credentials.apiKey; - break; - - default: - headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; - break; + // Do NOT send Authorization header when apiKey is present for Anthropic Compatible + // as it causes issues with some providers (e.g. opencode.ai) + } else if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } + // Add default Anthropic version if not present (some proxies require it) + if (!headers["anthropic-version"]) { + headers["anthropic-version"] = "2023-06-01"; + } + } else { + switch (provider) { + case "gemini": + if (credentials.apiKey) { + headers["x-goog-api-key"] = credentials.apiKey; + } else if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } + break; + + case "antigravity": + case "gemini-cli": + // Antigravity and Gemini CLI use OAuth access token + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + break; + + case "claude": + // Claude uses x-api-key header for API key, or Authorization for OAuth + if (credentials.apiKey) { + headers["x-api-key"] = credentials.apiKey; + } else if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } + break; + + case "github": + // GitHub Copilot requires special headers to mimic VSCode + // Prioritize copilotToken from providerSpecificData, fallback to accessToken + const githubToken = credentials.copilotToken || credentials.accessToken; + // Add headers in exact same order as test endpoint + headers["Authorization"] = `Bearer ${githubToken}`; + headers["Content-Type"] = "application/json"; + headers["copilot-integration-id"] = "vscode-chat"; + headers["editor-version"] = "vscode/1.107.1"; + headers["editor-plugin-version"] = "copilot-chat/0.26.7"; + headers["user-agent"] = "GitHubCopilotChat/0.26.7"; + headers["openai-intent"] = "conversation-panel"; + headers["x-github-api-version"] = "2025-04-01"; + // Generate a UUID for x-request-id (Cloudflare Workers compatible) + headers["x-request-id"] = crypto.randomUUID ? crypto.randomUUID() : + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + headers["x-vscode-user-agent-library-version"] = "electron-fetch"; + headers["X-Initiator"] = "user"; + headers["Accept"] = "application/json"; + break; + + case "codex": + case "qwen": + case "openai": + case "openrouter": + headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; + break; + + case "glm": + case "kimi": + case "minimax": + // Claude-compatible API providers use x-api-key + headers["x-api-key"] = credentials.apiKey; + break; + + default: + headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; + break; + } } // Stream accept header @@ -251,6 +291,9 @@ export function getTargetFormat(provider) { if (isOpenAICompatible(provider)) { return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai"; } + if (isAnthropicCompatible(provider)) { + return "claude"; + } const config = getProviderConfig(provider); return config.format || "openai"; } diff --git a/public/providers/anthropic-m.png b/public/providers/anthropic-m.png new file mode 100644 index 0000000..59807db Binary files /dev/null and b/public/providers/anthropic-m.png differ diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 2af51c1..4bf067e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -6,7 +6,7 @@ import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, Toggle, Select } from "@/shared/components"; -import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider } from "@/shared/constants/providers"; +import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; @@ -29,19 +29,24 @@ export default function ProviderDetailPage() { const providerInfo = providerNode ? { id: providerNode.id, - name: providerNode.name || "OpenAI Compatible", - color: "#10A37F", - textIcon: "OC", + name: providerNode.name || (providerNode.type === "anthropic-compatible" ? "Anthropic Compatible" : "OpenAI Compatible"), + color: providerNode.type === "anthropic-compatible" ? "#D97757" : "#10A37F", + textIcon: providerNode.type === "anthropic-compatible" ? "AC" : "OC", apiType: providerNode.apiType, baseUrl: providerNode.baseUrl, + type: providerNode.type, } : (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId]); const isOAuth = !!OAUTH_PROVIDERS[providerId]; const models = getModelsByProviderId(providerId); const providerAlias = getProviderAlias(providerId); + const isOpenAICompatible = isOpenAICompatibleProvider(providerId); - const providerStorageAlias = isOpenAICompatible ? providerId : providerAlias; - const providerDisplayAlias = isOpenAICompatible + const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId); + const isCompatible = isOpenAICompatible || isAnthropicCompatible; + + const providerStorageAlias = isCompatible ? providerId : providerAlias; + const providerDisplayAlias = isCompatible ? (providerNode?.prefix || providerId) : providerAlias; @@ -238,9 +243,9 @@ export default function ProviderDetailPage() { }; const renderModelsSection = () => { - if (isOpenAICompatible) { + if (isCompatible) { return ( - ); } @@ -317,6 +323,9 @@ export default function ProviderDetailPage() { if (isOpenAICompatible && providerInfo.apiType) { return providerInfo.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; } + if (isAnthropicCompatible) { + return "/providers/anthropic-m.png"; + } return `/providers/${providerInfo.id}.png`; }; @@ -361,14 +370,14 @@ export default function ProviderDetailPage() { - {isOpenAICompatible && providerNode && ( + {isCompatible && providerNode && (
-

OpenAI Compatible Details

+

{isAnthropicCompatible ? "Anthropic Compatible Details" : "OpenAI Compatible Details"}

- {providerNode.apiType === "responses" ? "Responses API" : "Chat Completions"} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/ - {providerNode.apiType === "responses" ? "responses" : "chat/completions"} + {isAnthropicCompatible ? "Messages API" : (providerNode.apiType === "responses" ? "Responses API" : "Chat Completions")} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/ + {isAnthropicCompatible ? "messages" : (providerNode.apiType === "responses" ? "responses" : "chat/completions")}

@@ -393,7 +402,7 @@ export default function ProviderDetailPage() { variant="secondary" icon="delete" onClick={async () => { - if (!confirm("Delete this OpenAI Compatible node?")) return; + if (!confirm(`Delete this ${isAnthropicCompatible ? "Anthropic" : "OpenAI"} Compatible node?`)) return; try { const res = await fetch(`/api/provider-nodes/${providerId}`, { method: "DELETE" }); if (res.ok) { @@ -410,7 +419,7 @@ export default function ProviderDetailPage() {
{connections.length > 0 && (

- Only one connection is allowed per OpenAI Compatible node. Add another node if you need more connections. + Only one connection is allowed per compatible node. Add another node if you need more connections.

)} @@ -420,7 +429,7 @@ export default function ProviderDetailPage() {

Connections

- {!isOpenAICompatible && ( + {!isCompatible && ( @@ -499,7 +508,8 @@ export default function ProviderDetailPage() { isOpen={showAddApiKeyModal} provider={providerId} providerName={providerInfo.name} - isOpenAICompatible={isOpenAICompatible} + isCompatible={isCompatible} + isAnthropic={isAnthropicCompatible} onSave={handleSaveApiKey} onClose={() => setShowAddApiKeyModal(false)} /> @@ -509,12 +519,15 @@ export default function ProviderDetailPage() { onSave={handleUpdateConnection} onClose={() => setShowEditModal(false)} /> - setShowEditNodeModal(false)} - /> + {isCompatible && ( + setShowEditNodeModal(false)} + isAnthropic={isAnthropicCompatible} + /> + )}
); } @@ -685,7 +698,7 @@ PassthroughModelRow.propTypes = { onDeleteAlias: PropTypes.func.isRequired, }; -function OpenAICompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias, connections }) { +function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias, connections, isAnthropic }) { const [newModel, setNewModel] = useState(""); const [adding, setAdding] = useState(false); const [importing, setImporting] = useState(false); @@ -775,7 +788,7 @@ function OpenAICompatibleModelsSection({ providerStorageAlias, providerDisplayAl return (

- Add OpenAI-compatible models manually or import them from the /models endpoint. + Add {isAnthropic ? "Anthropic" : "OpenAI"}-compatible models manually or import them from the /models endpoint.

@@ -787,7 +800,7 @@ function OpenAICompatibleModelsSection({ providerStorageAlias, providerDisplayAl value={newModel} onChange={(e) => setNewModel(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleAdd()} - placeholder="gpt-4o" + placeholder={isAnthropic ? "claude-3-opus-20240229" : "gpt-4o"} className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" />
@@ -823,7 +836,7 @@ function OpenAICompatibleModelsSection({ providerStorageAlias, providerDisplayAl ); } -OpenAICompatibleModelsSection.propTypes = { +CompatibleModelsSection.propTypes = { providerStorageAlias: PropTypes.string.isRequired, providerDisplayAlias: PropTypes.string.isRequired, modelAliases: PropTypes.object.isRequired, @@ -835,6 +848,7 @@ OpenAICompatibleModelsSection.propTypes = { id: PropTypes.string, isActive: PropTypes.bool, })).isRequired, + isAnthropic: PropTypes.bool, }; function CooldownTimer({ until }) { @@ -997,7 +1011,7 @@ ConnectionRow.propTypes = { onDelete: PropTypes.func.isRequired, }; -function AddApiKeyModal({ isOpen, provider, providerName, isOpenAICompatible, onSave, onClose }) { +function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, onSave, onClose }) { const [formData, setFormData] = useState({ name: "", apiKey: "", @@ -1062,9 +1076,12 @@ function AddApiKeyModal({ isOpen, provider, providerName, isOpenAICompatible, on {validationResult === "success" ? "Valid" : "Invalid"} )} - {isOpenAICompatible && ( + {isCompatible && (

- Validation checks {providerName || "OpenAI Compatible"} via /models on your base URL. + {isAnthropic + ? `Validation checks ${providerName || "Anthropic Compatible"} by verifying the API key.` + : `Validation checks ${providerName || "OpenAI Compatible"} via /models on your base URL.` + }

)} @@ -1254,7 +1272,7 @@ EditConnectionModal.propTypes = { onClose: PropTypes.func.isRequired, }; -function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) { +function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) { const [formData, setFormData] = useState({ name: "", prefix: "", @@ -1272,10 +1290,10 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) { name: node.name || "", prefix: node.prefix || "", apiType: node.apiType || "chat", - baseUrl: node.baseUrl || "https://api.openai.com/v1", + baseUrl: node.baseUrl || (isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"), }); } - }, [node]); + }, [node, isAnthropic]); const apiTypeOptions = [ { value: "chat", label: "Chat Completions" }, @@ -1286,12 +1304,15 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) { if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; setSaving(true); try { - await onSave({ + const payload = { name: formData.name, prefix: formData.prefix, - apiType: formData.apiType, baseUrl: formData.baseUrl, - }); + }; + if (!isAnthropic) { + payload.apiType = formData.apiType; + } + await onSave(payload); } finally { setSaving(false); } @@ -1303,7 +1324,11 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) { const res = await fetch("/api/provider-nodes/validate", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey }), + body: JSON.stringify({ + baseUrl: formData.baseUrl, + apiKey: checkKey, + type: isAnthropic ? "anthropic-compatible" : "openai-compatible" + }), }); const data = await res.json(); setValidationResult(data.valid ? "success" : "failed"); @@ -1317,34 +1342,36 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) { if (!node) return null; return ( - +
setFormData({ ...formData, name: e.target.value })} - placeholder="OpenAI Compatible (Prod)" + placeholder={`${isAnthropic ? "Anthropic" : "OpenAI"} Compatible (Prod)`} hint="Required. A friendly label for this node." /> setFormData({ ...formData, prefix: e.target.value })} - placeholder="oc-prod" + placeholder={isAnthropic ? "ac-prod" : "oc-prod"} hint="Required. Used as the provider prefix for model IDs." /> - setFormData({ ...formData, apiType: e.target.value })} + /> + )} setFormData({ ...formData, baseUrl: e.target.value })} - placeholder="https://api.openai.com/v1" - hint="Use the base URL (ending in /v1) for your OpenAI-compatible API." + placeholder={isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"} + hint={`Use the base URL (ending in /v1) for your ${isAnthropic ? "Anthropic" : "OpenAI"}-compatible API.`} />
{ const fetchData = async () => { @@ -103,12 +104,25 @@ export default function ProvidersPage() { apiType: node.apiType, })); + const anthropicCompatibleProviders = providerNodes + .filter((node) => node.type === "anthropic-compatible") + .map((node) => ({ + id: node.id, + name: node.name || "Anthropic Compatible", + color: "#D97757", + textIcon: "AC", + })); + const apiKeyProviders = { ...APIKEY_PROVIDERS, ...compatibleProviders.reduce((acc, provider) => { acc[provider.id] = provider; return acc; }, {}), + ...anthropicCompatibleProviders.reduce((acc, provider) => { + acc[provider.id] = provider; + return acc; + }, {}), }; if (loading) { @@ -141,9 +155,20 @@ export default function ProvidersPage() {

API Key Providers

- +
+ + +
{Object.entries(apiKeyProviders).map(([key, info]) => ( @@ -164,6 +189,14 @@ export default function ProvidersPage() { setShowAddCompatibleModal(false); }} /> + setShowAddAnthropicCompatibleModal(false)} + onCreated={(node) => { + setProviderNodes((prev) => [...prev, node]); + setShowAddAnthropicCompatibleModal(false); + }} + />
); } @@ -237,6 +270,7 @@ ProviderCard.propTypes = { function ApiKeyProviderCard({ providerId, provider, stats }) { const { connected, error, errorCode, errorTime } = stats; const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX); + const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX); const [imgError, setImgError] = useState(false); // Determine icon path: OpenAI Compatible providers use specialized icons @@ -244,6 +278,9 @@ function ApiKeyProviderCard({ providerId, provider, stats }) { if (isCompatible) { return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; } + if (isAnthropicCompatible) { + return "/providers/anthropic-m.png"; // Use Anthropic icon as base + } return `/providers/${provider.id}.png`; }; @@ -284,6 +321,11 @@ function ApiKeyProviderCard({ providerId, provider, stats }) { {provider.apiType === "responses" ? "Responses" : "Chat"} )} + {isAnthropicCompatible && ( + + Messages + + )} {errorTime && • {errorTime}}
@@ -351,6 +393,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { prefix: formData.prefix, apiType: formData.apiType, baseUrl: formData.baseUrl, + type: "openai-compatible", }), }); const data = await res.json(); @@ -378,7 +421,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { const res = await fetch("/api/provider-nodes/validate", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey }), + body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey, type: "openai-compatible" }), }); const data = await res.json(); setValidationResult(data.valid ? "success" : "failed"); @@ -456,3 +499,137 @@ AddOpenAICompatibleModal.propTypes = { onClose: PropTypes.func.isRequired, onCreated: PropTypes.func.isRequired, }; + +function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { + const [formData, setFormData] = useState({ + name: "", + prefix: "", + baseUrl: "https://api.anthropic.com/v1", + }); + const [submitting, setSubmitting] = useState(false); + const [checkKey, setCheckKey] = useState(""); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + + useEffect(() => { + // Reset validation when modal opens + if (isOpen) { + setValidationResult(null); + setCheckKey(""); + } + }, [isOpen]); + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; + setSubmitting(true); + try { + const res = await fetch("/api/provider-nodes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: formData.name, + prefix: formData.prefix, + baseUrl: formData.baseUrl, + type: "anthropic-compatible", + }), + }); + const data = await res.json(); + if (res.ok) { + onCreated(data.node); + setFormData({ + name: "", + prefix: "", + baseUrl: "https://api.anthropic.com/v1", + }); + setCheckKey(""); + setValidationResult(null); + } + } catch (error) { + console.log("Error creating Anthropic Compatible node:", error); + } finally { + setSubmitting(false); + } + }; + + const handleValidate = async () => { + setValidating(true); + try { + const res = await fetch("/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: formData.baseUrl, + apiKey: checkKey, + type: "anthropic-compatible" + }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="Anthropic Compatible (Prod)" + hint="Required. A friendly label for this node." + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder="ac-prod" + hint="Required. Used as the provider prefix for model IDs." + /> + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://api.anthropic.com/v1" + hint="Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages." + /> +
+ setCheckKey(e.target.value)} + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} +
+ + +
+
+
+ ); +} + +AddAnthropicCompatibleModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onCreated: PropTypes.func.isRequired, +}; diff --git a/src/app/api/provider-nodes/[id]/route.js b/src/app/api/provider-nodes/[id]/route.js index 7d5027a..35ea6a0 100644 --- a/src/app/api/provider-nodes/[id]/route.js +++ b/src/app/api/provider-nodes/[id]/route.js @@ -21,7 +21,8 @@ export async function PUT(request, { params }) { return NextResponse.json({ error: "Prefix is required" }, { status: 400 }); } - if (!apiType || !["chat", "responses"].includes(apiType)) { + // Only validate apiType for OpenAI Compatible nodes + if (node.type === "openai-compatible" && (!apiType || !["chat", "responses"].includes(apiType))) { return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 }); } @@ -29,12 +30,27 @@ export async function PUT(request, { params }) { return NextResponse.json({ error: "Base URL is required" }, { status: 400 }); } - const updated = await updateProviderNode(id, { + let sanitizedBaseUrl = baseUrl.trim(); + + // Sanitize Base URL for Anthropic Compatible + if (node.type === "anthropic-compatible") { + sanitizedBaseUrl = sanitizedBaseUrl.replace(/\/$/, ""); + if (sanitizedBaseUrl.endsWith("/messages")) { + sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages + } + } + + const updates = { name: name.trim(), prefix: prefix.trim(), - apiType, - baseUrl: baseUrl.trim(), - }); + baseUrl: sanitizedBaseUrl, + }; + + if (node.type === "openai-compatible") { + updates.apiType = apiType; + } + + const updated = await updateProviderNode(id, updates); const connections = await getProviderConnections({ provider: id }); await Promise.all(connections.map((connection) => ( @@ -42,8 +58,8 @@ export async function PUT(request, { params }) { providerSpecificData: { ...(connection.providerSpecificData || {}), prefix: prefix.trim(), - apiType, - baseUrl: baseUrl.trim(), + apiType: node.type === "openai-compatible" ? apiType : undefined, + baseUrl: sanitizedBaseUrl, nodeName: updated.name, } }) diff --git a/src/app/api/provider-nodes/route.js b/src/app/api/provider-nodes/route.js index de02431..cba1516 100644 --- a/src/app/api/provider-nodes/route.js +++ b/src/app/api/provider-nodes/route.js @@ -1,11 +1,15 @@ import { NextResponse } from "next/server"; import { createProviderNode, getProviderNodes } from "@/models"; -import { OPENAI_COMPATIBLE_PREFIX } from "@/shared/constants/providers"; +import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers"; const OPENAI_COMPATIBLE_DEFAULTS = { baseUrl: "https://api.openai.com/v1", }; +const ANTHROPIC_COMPATIBLE_DEFAULTS = { + baseUrl: "https://api.anthropic.com/v1", +}; + // GET /api/provider-nodes - List all provider nodes export async function GET() { try { @@ -21,7 +25,7 @@ export async function GET() { export async function POST(request) { try { const body = await request.json(); - const { name, prefix, apiType, baseUrl } = body; + const { name, prefix, apiType, baseUrl, type } = body; if (!name?.trim()) { return NextResponse.json({ error: "Name is required" }, { status: 400 }); @@ -31,20 +35,44 @@ export async function POST(request) { return NextResponse.json({ error: "Prefix is required" }, { status: 400 }); } - if (!apiType || !["chat", "responses"].includes(apiType)) { - return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 }); + // Determine type + const nodeType = type || "openai-compatible"; + + if (nodeType === "openai-compatible") { + if (!apiType || !["chat", "responses"].includes(apiType)) { + return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 }); + } + + const node = await createProviderNode({ + id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${crypto.randomUUID()}`, + type: "openai-compatible", + prefix: prefix.trim(), + apiType, + baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(), + name: name.trim(), + }); + return NextResponse.json({ node }, { status: 201 }); } - const node = await createProviderNode({ - id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${crypto.randomUUID()}`, - type: "openai-compatible", - prefix: prefix.trim(), - apiType, - baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(), - name: name.trim(), - }); + if (nodeType === "anthropic-compatible") { + // Sanitize Base URL: remove trailing slash, and remove trailing /messages if user added it + // This prevents double-appending /messages at runtime + let sanitizedBaseUrl = (baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl).trim().replace(/\/$/, ""); + if (sanitizedBaseUrl.endsWith("/messages")) { + sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages + } - return NextResponse.json({ node }, { status: 201 }); + const node = await createProviderNode({ + id: `${ANTHROPIC_COMPATIBLE_PREFIX}${crypto.randomUUID()}`, + type: "anthropic-compatible", + prefix: prefix.trim(), + baseUrl: sanitizedBaseUrl, + name: name.trim(), + }); + return NextResponse.json({ node }, { status: 201 }); + } + + return NextResponse.json({ error: "Invalid provider node type" }, { status: 400 }); } catch (error) { console.log("Error creating provider node:", error); return NextResponse.json({ error: "Failed to create provider node" }, { status: 500 }); diff --git a/src/app/api/provider-nodes/validate/route.js b/src/app/api/provider-nodes/validate/route.js index fdc12df..6d70a37 100644 --- a/src/app/api/provider-nodes/validate/route.js +++ b/src/app/api/provider-nodes/validate/route.js @@ -1,15 +1,39 @@ import { NextResponse } from "next/server"; -// POST /api/provider-nodes/validate - Validate API key against base URL /models +// POST /api/provider-nodes/validate - Validate API key against base URL export async function POST(request) { try { const body = await request.json(); - const { baseUrl, apiKey } = body; + const { baseUrl, apiKey, type } = body; if (!baseUrl || !apiKey) { return NextResponse.json({ error: "Base URL and API key required" }, { status: 400 }); } + // Anthropic Compatible Validation + if (type === "anthropic-compatible") { + // Robustly construct URL: remove trailing slash, and remove trailing /messages if user added it + let normalizedBase = baseUrl.trim().replace(/\/$/, ""); + if (normalizedBase.endsWith("/messages")) { + normalizedBase = normalizedBase.slice(0, -9); // remove /messages + } + + // Use /models endpoint for validation as many compatible providers support it (like OpenAI) + const modelsUrl = `${normalizedBase}/models`; + + const res = await fetch(modelsUrl, { + method: "GET", + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "Authorization": `Bearer ${apiKey}` // Add Bearer token for hybrid proxies + } + }); + + return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" }); + } + + // OpenAI Compatible Validation (Default) const modelsUrl = `${baseUrl.replace(/\/$/, "")}/models`; const res = await fetch(modelsUrl, { headers: { "Authorization": `Bearer ${apiKey}` }, @@ -17,7 +41,7 @@ export async function POST(request) { return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" }); } catch (error) { - console.log("Error validating OpenAI compatible base URL:", error); + console.log("Error validating provider node:", error); return NextResponse.json({ error: "Validation failed" }, { status: 500 }); } } diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 7745b91..b1dff57 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById } from "@/models"; -import { isOpenAICompatibleProvider } from "@/shared/constants/providers"; +import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; // Provider models endpoints configuration const PROVIDER_MODELS_CONFIG = { @@ -119,6 +119,47 @@ export async function GET(request, { params }) { }); } + if (isAnthropicCompatibleProvider(connection.provider)) { + let baseUrl = connection.providerSpecificData?.baseUrl; + if (!baseUrl) { + return NextResponse.json({ error: "No base URL configured for Anthropic compatible provider" }, { status: 400 }); + } + + baseUrl = baseUrl.replace(/\/$/, ""); + if (baseUrl.endsWith("/messages")) { + baseUrl = baseUrl.slice(0, -9); + } + + const url = `${baseUrl}/models`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-api-key": connection.apiKey, + "anthropic-version": "2023-06-01", + "Authorization": `Bearer ${connection.apiKey}` + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.log(`Error fetching models from ${connection.provider}:`, errorText); + return NextResponse.json( + { error: `Failed to fetch models: ${response.status}` }, + { status: response.status } + ); + } + + const data = await response.json(); + const models = data.data || data.models || []; + + return NextResponse.json({ + provider: connection.provider, + connectionId: connection.id, + models + }); + } + const config = PROVIDER_MODELS_CONFIG[connection.provider]; if (!config) { return NextResponse.json( diff --git a/src/app/api/providers/[id]/test/route.js b/src/app/api/providers/[id]/test/route.js index 77abde0..21bc6de 100644 --- a/src/app/api/providers/[id]/test/route.js +++ b/src/app/api/providers/[id]/test/route.js @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/app/api/sync/cloud/route"; -import { isOpenAICompatibleProvider } from "@/shared/constants/providers"; +import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { GEMINI_CONFIG, ANTIGRAVITY_CONFIG, @@ -322,6 +322,32 @@ async function testApiKeyConnection(connection) { } } + // Anthropic Compatible providers - test via /models endpoint + if (isAnthropicCompatibleProvider(connection.provider)) { + let modelsBase = connection.providerSpecificData?.baseUrl; + if (!modelsBase) { + return { valid: false, error: "Missing base URL" }; + } + try { + modelsBase = modelsBase.replace(/\/$/, ""); + if (modelsBase.endsWith("/messages")) { + modelsBase = modelsBase.slice(0, -9); + } + + const modelsUrl = `${modelsBase}/models`; + const res = await fetch(modelsUrl, { + headers: { + "x-api-key": connection.apiKey, + "anthropic-version": "2023-06-01", + "Authorization": `Bearer ${connection.apiKey}` + }, + }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" }; + } catch (err) { + return { valid: false, error: err.message }; + } + } + try { switch (connection.provider) { case "openai": { diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 8f621d2..bb95719 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { getProviderConnections, createProviderConnection, getProviderNodeById, isCloudEnabled } from "@/models"; import { APIKEY_PROVIDERS } from "@/shared/constants/config"; -import { isOpenAICompatibleProvider } from "@/shared/constants/providers"; +import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/app/api/sync/cloud/route"; @@ -33,7 +33,11 @@ export async function POST(request) { const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = body; // Validation - if (!provider || (!APIKEY_PROVIDERS[provider] && !isOpenAICompatibleProvider(provider))) { + const isValidProvider = APIKEY_PROVIDERS[provider] || + isOpenAICompatibleProvider(provider) || + isAnthropicCompatibleProvider(provider); + + if (!provider || !isValidProvider) { return NextResponse.json({ error: "Invalid provider" }, { status: 400 }); } if (!apiKey) { @@ -62,6 +66,22 @@ export async function POST(request) { baseUrl: node.baseUrl, nodeName: node.name, }; + } else if (isAnthropicCompatibleProvider(provider)) { + const node = await getProviderNodeById(provider); + if (!node) { + return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 }); + } + + const existingConnections = await getProviderConnections({ provider }); + if (existingConnections.length > 0) { + return NextResponse.json({ error: "Only one connection is allowed for this Anthropic Compatible node" }, { status: 400 }); + } + + providerSpecificData = { + prefix: node.prefix, + baseUrl: node.baseUrl, + nodeName: node.name, + }; } const newConnection = await createProviderConnection({ diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index 008db0b..d6c94ac 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { getProviderNodeById } from "@/models"; -import { isOpenAICompatibleProvider } from "@/shared/constants/providers"; +import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; // POST /api/providers/validate - Validate API key with provider export async function POST(request) { @@ -33,6 +33,34 @@ export async function POST(request) { }); } + if (isAnthropicCompatibleProvider(provider)) { + const node = await getProviderNodeById(provider); + if (!node) { + return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 }); + } + + let normalizedBase = node.baseUrl?.trim().replace(/\/$/, "") || ""; + if (normalizedBase.endsWith("/messages")) { + normalizedBase = normalizedBase.slice(0, -9); // remove /messages + } + + const modelsUrl = `${normalizedBase}/models`; + + const res = await fetch(modelsUrl, { + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "Authorization": `Bearer ${apiKey}` + }, + }); + + isValid = res.ok; + return NextResponse.json({ + valid: isValid, + error: isValid ? null : "Invalid API key", + }); + } + switch (provider) { case "openai": const openaiRes = await fetch("https://api.openai.com/v1/models", { diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 32247be..ebb0def 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -23,11 +23,16 @@ export const APIKEY_PROVIDERS = { }; export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; +export const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-"; export function isOpenAICompatibleProvider(providerId) { return typeof providerId === "string" && providerId.startsWith(OPENAI_COMPATIBLE_PREFIX); } +export function isAnthropicCompatibleProvider(providerId) { + return typeof providerId === "string" && providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX); +} + // All providers (combined) export const AI_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS }; diff --git a/src/sse/services/model.js b/src/sse/services/model.js index e93d906..4cec31d 100644 --- a/src/sse/services/model.js +++ b/src/sse/services/model.js @@ -20,10 +20,18 @@ export async function getModelInfo(modelStr) { if (!parsed.isAlias) { if (parsed.provider === parsed.providerAlias) { - const providerNodes = await getProviderNodes({ type: "openai-compatible" }); - const matchedNode = providerNodes.find((node) => node.prefix === parsed.providerAlias); - if (matchedNode) { - return { provider: matchedNode.id, model: parsed.model }; + // Check OpenAI Compatible nodes + const openaiNodes = await getProviderNodes({ type: "openai-compatible" }); + const matchedOpenAI = openaiNodes.find((node) => node.prefix === parsed.providerAlias); + if (matchedOpenAI) { + return { provider: matchedOpenAI.id, model: parsed.model }; + } + + // Check Anthropic Compatible nodes + const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" }); + const matchedAnthropic = anthropicNodes.find((node) => node.prefix === parsed.providerAlias); + if (matchedAnthropic) { + return { provider: matchedAnthropic.id, model: parsed.model }; } } return {