Merge branch 'pr-44'

This commit is contained in:
decolua
2026-02-04 10:00:46 +07:00
14 changed files with 614 additions and 154 deletions

View File

@@ -13,6 +13,11 @@ export class DefaultExecutor extends BaseExecutor {
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
return `${normalized}${path}`; 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) { switch (this.provider) {
case "claude": case "claude":
case "glm": case "glm":
@@ -42,7 +47,18 @@ export class DefaultExecutor extends BaseExecutor {
headers["x-api-key"] = credentials.apiKey; headers["x-api-key"] = credentials.apiKey;
break; break;
default: 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"; if (stream) headers["Accept"] = "text/event-stream";

View File

@@ -5,10 +5,19 @@ const OPENAI_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.openai.com/v1", 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) { function isOpenAICompatible(provider) {
return typeof provider === "string" && provider.startsWith(OPENAI_COMPATIBLE_PREFIX); return typeof provider === "string" && provider.startsWith(OPENAI_COMPATIBLE_PREFIX);
} }
function isAnthropicCompatible(provider) {
return typeof provider === "string" && provider.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
}
function getOpenAICompatibleType(provider) { function getOpenAICompatibleType(provider) {
if (!isOpenAICompatible(provider)) return "chat"; if (!isOpenAICompatible(provider)) return "chat";
return provider.includes("responses") ? "responses" : "chat"; return provider.includes("responses") ? "responses" : "chat";
@@ -20,6 +29,11 @@ function buildOpenAICompatibleUrl(baseUrl, apiType) {
return `${normalized}${path}`; return `${normalized}${path}`;
} }
function buildAnthropicCompatibleUrl(baseUrl) {
const normalized = baseUrl.replace(/\/$/, "");
return `${normalized}/messages`;
}
// Detect request format from body structure // Detect request format from body structure
export function detectFormat(body) { export function detectFormat(body) {
// OpenAI Responses API: has input[] array instead of messages[] // OpenAI Responses API: has input[] array instead of messages[]
@@ -104,6 +118,13 @@ export function getProviderConfig(provider) {
baseUrl: OPENAI_COMPATIBLE_DEFAULTS.baseUrl, 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; 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; const baseUrl = options?.baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl;
return buildOpenAICompatibleUrl(baseUrl, apiType); return buildOpenAICompatibleUrl(baseUrl, apiType);
} }
if (isAnthropicCompatible(provider)) {
const baseUrl = options?.baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl;
return buildAnthropicCompatibleUrl(baseUrl);
}
const config = getProviderConfig(provider); const config = getProviderConfig(provider);
switch (provider) { switch (provider) {
@@ -170,72 +195,87 @@ export function buildProviderHeaders(provider, credentials, stream = true, body
}; };
// Add auth header // Add auth header
switch (provider) { // Specific override for Anthropic Compatible
case "gemini": if (isAnthropicCompatible(provider)) {
if (credentials.apiKey) { 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; headers["x-api-key"] = credentials.apiKey;
break; // Do NOT send Authorization header when apiKey is present for Anthropic Compatible
// as it causes issues with some providers (e.g. opencode.ai)
default: } else if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; headers["Authorization"] = `Bearer ${credentials.accessToken}`;
break; }
// 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 // Stream accept header
@@ -251,6 +291,9 @@ export function getTargetFormat(provider) {
if (isOpenAICompatible(provider)) { if (isOpenAICompatible(provider)) {
return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai"; return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai";
} }
if (isAnthropicCompatible(provider)) {
return "claude";
}
const config = getProviderConfig(provider); const config = getProviderConfig(provider);
return config.format || "openai"; return config.format || "openai";
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -6,7 +6,7 @@ import { useParams, useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, Toggle, Select } from "@/shared/components"; 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 { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
@@ -29,19 +29,24 @@ export default function ProviderDetailPage() {
const providerInfo = providerNode const providerInfo = providerNode
? { ? {
id: providerNode.id, id: providerNode.id,
name: providerNode.name || "OpenAI Compatible", name: providerNode.name || (providerNode.type === "anthropic-compatible" ? "Anthropic Compatible" : "OpenAI Compatible"),
color: "#10A37F", color: providerNode.type === "anthropic-compatible" ? "#D97757" : "#10A37F",
textIcon: "OC", textIcon: providerNode.type === "anthropic-compatible" ? "AC" : "OC",
apiType: providerNode.apiType, apiType: providerNode.apiType,
baseUrl: providerNode.baseUrl, baseUrl: providerNode.baseUrl,
type: providerNode.type,
} }
: (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId]); : (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId]);
const isOAuth = !!OAUTH_PROVIDERS[providerId]; const isOAuth = !!OAUTH_PROVIDERS[providerId];
const models = getModelsByProviderId(providerId); const models = getModelsByProviderId(providerId);
const providerAlias = getProviderAlias(providerId); const providerAlias = getProviderAlias(providerId);
const isOpenAICompatible = isOpenAICompatibleProvider(providerId); const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
const providerStorageAlias = isOpenAICompatible ? providerId : providerAlias; const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
const providerDisplayAlias = isOpenAICompatible const isCompatible = isOpenAICompatible || isAnthropicCompatible;
const providerStorageAlias = isCompatible ? providerId : providerAlias;
const providerDisplayAlias = isCompatible
? (providerNode?.prefix || providerId) ? (providerNode?.prefix || providerId)
: providerAlias; : providerAlias;
@@ -238,9 +243,9 @@ export default function ProviderDetailPage() {
}; };
const renderModelsSection = () => { const renderModelsSection = () => {
if (isOpenAICompatible) { if (isCompatible) {
return ( return (
<OpenAICompatibleModelsSection <CompatibleModelsSection
providerStorageAlias={providerStorageAlias} providerStorageAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias} providerDisplayAlias={providerDisplayAlias}
modelAliases={modelAliases} modelAliases={modelAliases}
@@ -249,6 +254,7 @@ export default function ProviderDetailPage() {
onSetAlias={handleSetAlias} onSetAlias={handleSetAlias}
onDeleteAlias={handleDeleteAlias} onDeleteAlias={handleDeleteAlias}
connections={connections} connections={connections}
isAnthropic={isAnthropicCompatible}
/> />
); );
} }
@@ -317,6 +323,9 @@ export default function ProviderDetailPage() {
if (isOpenAICompatible && providerInfo.apiType) { if (isOpenAICompatible && providerInfo.apiType) {
return providerInfo.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; return providerInfo.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
} }
if (isAnthropicCompatible) {
return "/providers/anthropic-m.png";
}
return `/providers/${providerInfo.id}.png`; return `/providers/${providerInfo.id}.png`;
}; };
@@ -361,14 +370,14 @@ export default function ProviderDetailPage() {
</div> </div>
</div> </div>
{isOpenAICompatible && providerNode && ( {isCompatible && providerNode && (
<Card> <Card>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<h2 className="text-lg font-semibold">OpenAI Compatible Details</h2> <h2 className="text-lg font-semibold">{isAnthropicCompatible ? "Anthropic Compatible Details" : "OpenAI Compatible Details"}</h2>
<p className="text-sm text-text-muted"> <p className="text-sm text-text-muted">
{providerNode.apiType === "responses" ? "Responses API" : "Chat Completions"} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/ {isAnthropicCompatible ? "Messages API" : (providerNode.apiType === "responses" ? "Responses API" : "Chat Completions")} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/
{providerNode.apiType === "responses" ? "responses" : "chat/completions"} {isAnthropicCompatible ? "messages" : (providerNode.apiType === "responses" ? "responses" : "chat/completions")}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -393,7 +402,7 @@ export default function ProviderDetailPage() {
variant="secondary" variant="secondary"
icon="delete" icon="delete"
onClick={async () => { onClick={async () => {
if (!confirm("Delete this OpenAI Compatible node?")) return; if (!confirm(`Delete this ${isAnthropicCompatible ? "Anthropic" : "OpenAI"} Compatible node?`)) return;
try { try {
const res = await fetch(`/api/provider-nodes/${providerId}`, { method: "DELETE" }); const res = await fetch(`/api/provider-nodes/${providerId}`, { method: "DELETE" });
if (res.ok) { if (res.ok) {
@@ -410,7 +419,7 @@ export default function ProviderDetailPage() {
</div> </div>
{connections.length > 0 && ( {connections.length > 0 && (
<p className="text-sm text-text-muted"> <p className="text-sm text-text-muted">
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.
</p> </p>
)} )}
</Card> </Card>
@@ -420,7 +429,7 @@ export default function ProviderDetailPage() {
<Card> <Card>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Connections</h2> <h2 className="text-lg font-semibold">Connections</h2>
{!isOpenAICompatible && ( {!isCompatible && (
<Button <Button
size="sm" size="sm"
icon="add" icon="add"
@@ -438,7 +447,7 @@ export default function ProviderDetailPage() {
</div> </div>
<p className="text-text-main font-medium mb-1">No connections yet</p> <p className="text-text-main font-medium mb-1">No connections yet</p>
<p className="text-sm text-text-muted mb-4">Add your first connection to get started</p> <p className="text-sm text-text-muted mb-4">Add your first connection to get started</p>
{!isOpenAICompatible && ( {!isCompatible && (
<Button icon="add" onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}> <Button icon="add" onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}>
Add Connection Add Connection
</Button> </Button>
@@ -499,7 +508,8 @@ export default function ProviderDetailPage() {
isOpen={showAddApiKeyModal} isOpen={showAddApiKeyModal}
provider={providerId} provider={providerId}
providerName={providerInfo.name} providerName={providerInfo.name}
isOpenAICompatible={isOpenAICompatible} isCompatible={isCompatible}
isAnthropic={isAnthropicCompatible}
onSave={handleSaveApiKey} onSave={handleSaveApiKey}
onClose={() => setShowAddApiKeyModal(false)} onClose={() => setShowAddApiKeyModal(false)}
/> />
@@ -509,12 +519,15 @@ export default function ProviderDetailPage() {
onSave={handleUpdateConnection} onSave={handleUpdateConnection}
onClose={() => setShowEditModal(false)} onClose={() => setShowEditModal(false)}
/> />
<EditOpenAICompatibleModal {isCompatible && (
isOpen={showEditNodeModal} <EditCompatibleNodeModal
node={providerNode} isOpen={showEditNodeModal}
onSave={handleUpdateNode} node={providerNode}
onClose={() => setShowEditNodeModal(false)} onSave={handleUpdateNode}
/> onClose={() => setShowEditNodeModal(false)}
isAnthropic={isAnthropicCompatible}
/>
)}
</div> </div>
); );
} }
@@ -685,7 +698,7 @@ PassthroughModelRow.propTypes = {
onDeleteAlias: PropTypes.func.isRequired, 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 [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
@@ -775,7 +788,7 @@ function OpenAICompatibleModelsSection({ providerStorageAlias, providerDisplayAl
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="text-sm text-text-muted"> <p className="text-sm text-text-muted">
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.
</p> </p>
<div className="flex items-end gap-2 flex-wrap"> <div className="flex items-end gap-2 flex-wrap">
@@ -787,7 +800,7 @@ function OpenAICompatibleModelsSection({ providerStorageAlias, providerDisplayAl
value={newModel} value={newModel}
onChange={(e) => setNewModel(e.target.value)} onChange={(e) => setNewModel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()} 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" className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/> />
</div> </div>
@@ -823,7 +836,7 @@ function OpenAICompatibleModelsSection({ providerStorageAlias, providerDisplayAl
); );
} }
OpenAICompatibleModelsSection.propTypes = { CompatibleModelsSection.propTypes = {
providerStorageAlias: PropTypes.string.isRequired, providerStorageAlias: PropTypes.string.isRequired,
providerDisplayAlias: PropTypes.string.isRequired, providerDisplayAlias: PropTypes.string.isRequired,
modelAliases: PropTypes.object.isRequired, modelAliases: PropTypes.object.isRequired,
@@ -835,6 +848,7 @@ OpenAICompatibleModelsSection.propTypes = {
id: PropTypes.string, id: PropTypes.string,
isActive: PropTypes.bool, isActive: PropTypes.bool,
})).isRequired, })).isRequired,
isAnthropic: PropTypes.bool,
}; };
function CooldownTimer({ until }) { function CooldownTimer({ until }) {
@@ -997,7 +1011,7 @@ ConnectionRow.propTypes = {
onDelete: PropTypes.func.isRequired, 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({ const [formData, setFormData] = useState({
name: "", name: "",
apiKey: "", apiKey: "",
@@ -1062,9 +1076,12 @@ function AddApiKeyModal({ isOpen, provider, providerName, isOpenAICompatible, on
{validationResult === "success" ? "Valid" : "Invalid"} {validationResult === "success" ? "Valid" : "Invalid"}
</Badge> </Badge>
)} )}
{isOpenAICompatible && ( {isCompatible && (
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
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.`
}
</p> </p>
)} )}
<Input <Input
@@ -1090,7 +1107,8 @@ AddApiKeyModal.propTypes = {
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
provider: PropTypes.string, provider: PropTypes.string,
providerName: PropTypes.string, providerName: PropTypes.string,
isOpenAICompatible: PropTypes.bool, isCompatible: PropTypes.bool,
isAnthropic: PropTypes.bool,
onSave: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
}; };
@@ -1168,7 +1186,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
if (!connection) return null; if (!connection) return null;
const isOAuth = connection.authType === "oauth"; const isOAuth = connection.authType === "oauth";
const isCompatible = isOpenAICompatibleProvider(connection.provider); const isCompatible = isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider);
return ( return (
<Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}> <Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}>
@@ -1254,7 +1272,7 @@ EditConnectionModal.propTypes = {
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
}; };
function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) { function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
prefix: "", prefix: "",
@@ -1272,10 +1290,10 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) {
name: node.name || "", name: node.name || "",
prefix: node.prefix || "", prefix: node.prefix || "",
apiType: node.apiType || "chat", 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 = [ const apiTypeOptions = [
{ value: "chat", label: "Chat Completions" }, { 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; if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
setSaving(true); setSaving(true);
try { try {
await onSave({ const payload = {
name: formData.name, name: formData.name,
prefix: formData.prefix, prefix: formData.prefix,
apiType: formData.apiType,
baseUrl: formData.baseUrl, baseUrl: formData.baseUrl,
}); };
if (!isAnthropic) {
payload.apiType = formData.apiType;
}
await onSave(payload);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -1303,7 +1324,11 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) {
const res = await fetch("/api/provider-nodes/validate", { const res = await fetch("/api/provider-nodes/validate", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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(); const data = await res.json();
setValidationResult(data.valid ? "success" : "failed"); setValidationResult(data.valid ? "success" : "failed");
@@ -1317,34 +1342,36 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) {
if (!node) return null; if (!node) return null;
return ( return (
<Modal isOpen={isOpen} title="Edit OpenAI Compatible" onClose={onClose}> <Modal isOpen={isOpen} title={`Edit ${isAnthropic ? "Anthropic" : "OpenAI"} Compatible`} onClose={onClose}>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Input <Input
label="Name" label="Name"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="OpenAI Compatible (Prod)" placeholder={`${isAnthropic ? "Anthropic" : "OpenAI"} Compatible (Prod)`}
hint="Required. A friendly label for this node." hint="Required. A friendly label for this node."
/> />
<Input <Input
label="Prefix" label="Prefix"
value={formData.prefix} value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })} onChange={(e) => 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." hint="Required. Used as the provider prefix for model IDs."
/> />
<Select {!isAnthropic && (
label="API Type" <Select
options={apiTypeOptions} label="API Type"
value={formData.apiType} options={apiTypeOptions}
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })} value={formData.apiType}
/> onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
/>
)}
<Input <Input
label="Base URL" label="Base URL"
value={formData.baseUrl} value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })} onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.openai.com/v1" placeholder={isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"}
hint="Use the base URL (ending in /v1) for your OpenAI-compatible API." hint={`Use the base URL (ending in /v1) for your ${isAnthropic ? "Anthropic" : "OpenAI"}-compatible API.`}
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
@@ -1378,7 +1405,7 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) {
); );
} }
EditOpenAICompatibleModal.propTypes = { EditCompatibleNodeModal.propTypes = {
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
node: PropTypes.shape({ node: PropTypes.shape({
id: PropTypes.string, id: PropTypes.string,
@@ -1389,4 +1416,5 @@ EditOpenAICompatibleModal.propTypes = {
}), }),
onSave: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
isAnthropic: PropTypes.bool,
}; };

View File

@@ -5,7 +5,7 @@ import Image from "next/image";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Card, CardSkeleton, Badge, Button, Input, Modal, Select } from "@/shared/components"; import { Card, CardSkeleton, Badge, Button, Input, Modal, Select } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import { OPENAI_COMPATIBLE_PREFIX } from "@/shared/constants/providers"; import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
import Link from "next/link"; import Link from "next/link";
import { getErrorCode, getRelativeTime } from "@/shared/utils"; import { getErrorCode, getRelativeTime } from "@/shared/utils";
@@ -38,6 +38,7 @@ export default function ProvidersPage() {
const [providerNodes, setProviderNodes] = useState([]); const [providerNodes, setProviderNodes] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false); const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -103,12 +104,25 @@ export default function ProvidersPage() {
apiType: node.apiType, 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 = { const apiKeyProviders = {
...APIKEY_PROVIDERS, ...APIKEY_PROVIDERS,
...compatibleProviders.reduce((acc, provider) => { ...compatibleProviders.reduce((acc, provider) => {
acc[provider.id] = provider; acc[provider.id] = provider;
return acc; return acc;
}, {}), }, {}),
...anthropicCompatibleProviders.reduce((acc, provider) => {
acc[provider.id] = provider;
return acc;
}, {}),
}; };
if (loading) { if (loading) {
@@ -141,9 +155,20 @@ export default function ProvidersPage() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">API Key Providers</h2> <h2 className="text-xl font-semibold">API Key Providers</h2>
<Button size="sm" icon="add" onClick={() => setShowAddCompatibleModal(true)}> <div className="flex gap-2">
Add OpenAI Compatible <Button size="sm" icon="add" onClick={() => setShowAddAnthropicCompatibleModal(true)}>
</Button> Add Anthropic Compatible
</Button>
<Button
size="sm"
variant="secondary"
icon="add"
onClick={() => setShowAddCompatibleModal(true)}
className="!bg-white !text-black hover:!bg-gray-100"
>
Add OpenAI Compatible
</Button>
</div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Object.entries(apiKeyProviders).map(([key, info]) => ( {Object.entries(apiKeyProviders).map(([key, info]) => (
@@ -164,6 +189,14 @@ export default function ProvidersPage() {
setShowAddCompatibleModal(false); setShowAddCompatibleModal(false);
}} }}
/> />
<AddAnthropicCompatibleModal
isOpen={showAddAnthropicCompatibleModal}
onClose={() => setShowAddAnthropicCompatibleModal(false)}
onCreated={(node) => {
setProviderNodes((prev) => [...prev, node]);
setShowAddAnthropicCompatibleModal(false);
}}
/>
</div> </div>
); );
} }
@@ -237,6 +270,7 @@ ProviderCard.propTypes = {
function ApiKeyProviderCard({ providerId, provider, stats }) { function ApiKeyProviderCard({ providerId, provider, stats }) {
const { connected, error, errorCode, errorTime } = stats; const { connected, error, errorCode, errorTime } = stats;
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX); const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
// Determine icon path: OpenAI Compatible providers use specialized icons // Determine icon path: OpenAI Compatible providers use specialized icons
@@ -244,6 +278,9 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
if (isCompatible) { if (isCompatible) {
return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; 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`; return `/providers/${provider.id}.png`;
}; };
@@ -284,6 +321,11 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
{provider.apiType === "responses" ? "Responses" : "Chat"} {provider.apiType === "responses" ? "Responses" : "Chat"}
</Badge> </Badge>
)} )}
{isAnthropicCompatible && (
<Badge variant="default" size="sm">
Messages
</Badge>
)}
{errorTime && <span className="text-text-muted">• {errorTime}</span>} {errorTime && <span className="text-text-muted">• {errorTime}</span>}
</div> </div>
</div> </div>
@@ -351,6 +393,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
prefix: formData.prefix, prefix: formData.prefix,
apiType: formData.apiType, apiType: formData.apiType,
baseUrl: formData.baseUrl, baseUrl: formData.baseUrl,
type: "openai-compatible",
}), }),
}); });
const data = await res.json(); const data = await res.json();
@@ -378,7 +421,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
const res = await fetch("/api/provider-nodes/validate", { const res = await fetch("/api/provider-nodes/validate", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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(); const data = await res.json();
setValidationResult(data.valid ? "success" : "failed"); setValidationResult(data.valid ? "success" : "failed");
@@ -456,3 +499,137 @@ AddOpenAICompatibleModal.propTypes = {
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
onCreated: 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 (
<Modal isOpen={isOpen} title="Add Anthropic Compatible" onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Anthropic Compatible (Prod)"
hint="Required. A friendly label for this node."
/>
<Input
label="Prefix"
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder="ac-prod"
hint="Required. Used as the provider prefix for model IDs."
/>
<Input
label="Base URL"
value={formData.baseUrl}
onChange={(e) => 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."
/>
<div className="flex gap-2">
<Input
label="API Key (for Check)"
type="password"
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
className="flex-1"
/>
<div className="pt-6">
<Button onClick={handleValidate} disabled={!checkKey || validating || !formData.baseUrl.trim()} variant="secondary">
{validating ? "Checking..." : "Check"}
</Button>
</div>
</div>
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? "Valid" : "Invalid"}
</Badge>
)}
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || submitting}>
{submitting ? "Creating..." : "Create"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
);
}
AddAnthropicCompatibleModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onCreated: PropTypes.func.isRequired,
};

View File

@@ -21,7 +21,8 @@ export async function PUT(request, { params }) {
return NextResponse.json({ error: "Prefix is required" }, { status: 400 }); 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 }); 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 }); 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(), name: name.trim(),
prefix: prefix.trim(), prefix: prefix.trim(),
apiType, baseUrl: sanitizedBaseUrl,
baseUrl: baseUrl.trim(), };
});
if (node.type === "openai-compatible") {
updates.apiType = apiType;
}
const updated = await updateProviderNode(id, updates);
const connections = await getProviderConnections({ provider: id }); const connections = await getProviderConnections({ provider: id });
await Promise.all(connections.map((connection) => ( await Promise.all(connections.map((connection) => (
@@ -42,8 +58,8 @@ export async function PUT(request, { params }) {
providerSpecificData: { providerSpecificData: {
...(connection.providerSpecificData || {}), ...(connection.providerSpecificData || {}),
prefix: prefix.trim(), prefix: prefix.trim(),
apiType, apiType: node.type === "openai-compatible" ? apiType : undefined,
baseUrl: baseUrl.trim(), baseUrl: sanitizedBaseUrl,
nodeName: updated.name, nodeName: updated.name,
} }
}) })

View File

@@ -1,11 +1,15 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createProviderNode, getProviderNodes } from "@/models"; 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 = { const OPENAI_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.openai.com/v1", baseUrl: "https://api.openai.com/v1",
}; };
const ANTHROPIC_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.anthropic.com/v1",
};
// GET /api/provider-nodes - List all provider nodes // GET /api/provider-nodes - List all provider nodes
export async function GET() { export async function GET() {
try { try {
@@ -21,7 +25,7 @@ export async function GET() {
export async function POST(request) { export async function POST(request) {
try { try {
const body = await request.json(); const body = await request.json();
const { name, prefix, apiType, baseUrl } = body; const { name, prefix, apiType, baseUrl, type } = body;
if (!name?.trim()) { if (!name?.trim()) {
return NextResponse.json({ error: "Name is required" }, { status: 400 }); 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 }); return NextResponse.json({ error: "Prefix is required" }, { status: 400 });
} }
if (!apiType || !["chat", "responses"].includes(apiType)) { // Determine type
return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 }); 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({ if (nodeType === "anthropic-compatible") {
id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${crypto.randomUUID()}`, // Sanitize Base URL: remove trailing slash, and remove trailing /messages if user added it
type: "openai-compatible", // This prevents double-appending /messages at runtime
prefix: prefix.trim(), let sanitizedBaseUrl = (baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl).trim().replace(/\/$/, "");
apiType, if (sanitizedBaseUrl.endsWith("/messages")) {
baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(), sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages
name: name.trim(), }
});
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) { } catch (error) {
console.log("Error creating provider node:", error); console.log("Error creating provider node:", error);
return NextResponse.json({ error: "Failed to create provider node" }, { status: 500 }); return NextResponse.json({ error: "Failed to create provider node" }, { status: 500 });

View File

@@ -1,15 +1,39 @@
import { NextResponse } from "next/server"; 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) { export async function POST(request) {
try { try {
const body = await request.json(); const body = await request.json();
const { baseUrl, apiKey } = body; const { baseUrl, apiKey, type } = body;
if (!baseUrl || !apiKey) { if (!baseUrl || !apiKey) {
return NextResponse.json({ error: "Base URL and API key required" }, { status: 400 }); 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 modelsUrl = `${baseUrl.replace(/\/$/, "")}/models`;
const res = await fetch(modelsUrl, { const res = await fetch(modelsUrl, {
headers: { "Authorization": `Bearer ${apiKey}` }, 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" }); return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" });
} catch (error) { } 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 }); return NextResponse.json({ error: "Validation failed" }, { status: 500 });
} }
} }

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/models"; import { getProviderConnectionById } from "@/models";
import { isOpenAICompatibleProvider } from "@/shared/constants/providers"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
// Provider models endpoints configuration // Provider models endpoints configuration
const PROVIDER_MODELS_CONFIG = { 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]; const config = PROVIDER_MODELS_CONFIG[connection.provider];
if (!config) { if (!config) {
return NextResponse.json( return NextResponse.json(

View File

@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb"; import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId"; import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/app/api/sync/cloud/route"; import { syncToCloud } from "@/app/api/sync/cloud/route";
import { isOpenAICompatibleProvider } from "@/shared/constants/providers"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { import {
GEMINI_CONFIG, GEMINI_CONFIG,
ANTIGRAVITY_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 { try {
switch (connection.provider) { switch (connection.provider) {
case "openai": { case "openai": {

View File

@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getProviderConnections, createProviderConnection, getProviderNodeById, isCloudEnabled } from "@/models"; import { getProviderConnections, createProviderConnection, getProviderNodeById, isCloudEnabled } from "@/models";
import { APIKEY_PROVIDERS } from "@/shared/constants/config"; 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 { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/app/api/sync/cloud/route"; 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; const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = body;
// Validation // 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 }); return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
} }
if (!apiKey) { if (!apiKey) {
@@ -62,6 +66,22 @@ export async function POST(request) {
baseUrl: node.baseUrl, baseUrl: node.baseUrl,
nodeName: node.name, 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({ const newConnection = await createProviderConnection({

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getProviderNodeById } from "@/models"; 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 // POST /api/providers/validate - Validate API key with provider
export async function POST(request) { 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) { switch (provider) {
case "openai": case "openai":
const openaiRes = await fetch("https://api.openai.com/v1/models", { const openaiRes = await fetch("https://api.openai.com/v1/models", {

View File

@@ -23,11 +23,16 @@ export const APIKEY_PROVIDERS = {
}; };
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
export const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-";
export function isOpenAICompatibleProvider(providerId) { export function isOpenAICompatibleProvider(providerId) {
return typeof providerId === "string" && providerId.startsWith(OPENAI_COMPATIBLE_PREFIX); 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) // All providers (combined)
export const AI_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS }; export const AI_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };

View File

@@ -20,10 +20,18 @@ export async function getModelInfo(modelStr) {
if (!parsed.isAlias) { if (!parsed.isAlias) {
if (parsed.provider === parsed.providerAlias) { if (parsed.provider === parsed.providerAlias) {
const providerNodes = await getProviderNodes({ type: "openai-compatible" }); // Check OpenAI Compatible nodes
const matchedNode = providerNodes.find((node) => node.prefix === parsed.providerAlias); const openaiNodes = await getProviderNodes({ type: "openai-compatible" });
if (matchedNode) { const matchedOpenAI = openaiNodes.find((node) => node.prefix === parsed.providerAlias);
return { provider: matchedNode.id, model: parsed.model }; 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 { return {