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";
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";

View File

@@ -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";
}

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 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 (
<OpenAICompatibleModelsSection
<CompatibleModelsSection
providerStorageAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias}
modelAliases={modelAliases}
@@ -249,6 +254,7 @@ export default function ProviderDetailPage() {
onSetAlias={handleSetAlias}
onDeleteAlias={handleDeleteAlias}
connections={connections}
isAnthropic={isAnthropicCompatible}
/>
);
}
@@ -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() {
</div>
</div>
{isOpenAICompatible && providerNode && (
{isCompatible && providerNode && (
<Card>
<div className="flex items-center justify-between mb-4">
<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">
{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")}
</p>
</div>
<div className="flex items-center gap-2">
@@ -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() {
</div>
{connections.length > 0 && (
<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>
)}
</Card>
@@ -420,7 +429,7 @@ export default function ProviderDetailPage() {
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Connections</h2>
{!isOpenAICompatible && (
{!isCompatible && (
<Button
size="sm"
icon="add"
@@ -438,7 +447,7 @@ export default function ProviderDetailPage() {
</div>
<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>
{!isOpenAICompatible && (
{!isCompatible && (
<Button icon="add" onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}>
Add Connection
</Button>
@@ -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)}
/>
<EditOpenAICompatibleModal
isOpen={showEditNodeModal}
node={providerNode}
onSave={handleUpdateNode}
onClose={() => setShowEditNodeModal(false)}
/>
{isCompatible && (
<EditCompatibleNodeModal
isOpen={showEditNodeModal}
node={providerNode}
onSave={handleUpdateNode}
onClose={() => setShowEditNodeModal(false)}
isAnthropic={isAnthropicCompatible}
/>
)}
</div>
);
}
@@ -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 (
<div className="flex flex-col gap-4">
<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>
<div className="flex items-end gap-2 flex-wrap">
@@ -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"
/>
</div>
@@ -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"}
</Badge>
)}
{isOpenAICompatible && (
{isCompatible && (
<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>
)}
<Input
@@ -1090,7 +1107,8 @@ AddApiKeyModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
provider: PropTypes.string,
providerName: PropTypes.string,
isOpenAICompatible: PropTypes.bool,
isCompatible: PropTypes.bool,
isAnthropic: PropTypes.bool,
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
@@ -1168,7 +1186,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
if (!connection) return null;
const isOAuth = connection.authType === "oauth";
const isCompatible = isOpenAICompatibleProvider(connection.provider);
const isCompatible = isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider);
return (
<Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}>
@@ -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 (
<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">
<Input
label="Name"
value={formData.name}
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."
/>
<Input
label="Prefix"
value={formData.prefix}
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."
/>
<Select
label="API Type"
options={apiTypeOptions}
value={formData.apiType}
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
/>
{!isAnthropic && (
<Select
label="API Type"
options={apiTypeOptions}
value={formData.apiType}
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
/>
)}
<Input
label="Base URL"
value={formData.baseUrl}
onChange={(e) => 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.`}
/>
<div className="flex gap-2">
<Input
@@ -1378,7 +1405,7 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) {
);
}
EditOpenAICompatibleModal.propTypes = {
EditCompatibleNodeModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
node: PropTypes.shape({
id: PropTypes.string,
@@ -1389,4 +1416,5 @@ EditOpenAICompatibleModal.propTypes = {
}),
onSave: 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 { Card, CardSkeleton, Badge, Button, Input, Modal, Select } from "@/shared/components";
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 { getErrorCode, getRelativeTime } from "@/shared/utils";
@@ -38,6 +38,7 @@ export default function ProvidersPage() {
const [providerNodes, setProviderNodes] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
useEffect(() => {
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() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">API Key Providers</h2>
<Button size="sm" icon="add" onClick={() => setShowAddCompatibleModal(true)}>
Add OpenAI Compatible
</Button>
<div className="flex gap-2">
<Button size="sm" icon="add" onClick={() => setShowAddAnthropicCompatibleModal(true)}>
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 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]) => (
@@ -164,6 +189,14 @@ export default function ProvidersPage() {
setShowAddCompatibleModal(false);
}}
/>
<AddAnthropicCompatibleModal
isOpen={showAddAnthropicCompatibleModal}
onClose={() => setShowAddAnthropicCompatibleModal(false)}
onCreated={(node) => {
setProviderNodes((prev) => [...prev, node]);
setShowAddAnthropicCompatibleModal(false);
}}
/>
</div>
);
}
@@ -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"}
</Badge>
)}
{isAnthropicCompatible && (
<Badge variant="default" size="sm">
Messages
</Badge>
)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
</div>
</div>
@@ -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 (
<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 });
}
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,
}
})

View File

@@ -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 });

View File

@@ -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 });
}
}

View File

@@ -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(

View File

@@ -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": {

View File

@@ -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({

View File

@@ -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", {

View File

@@ -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 };

View File

@@ -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 {