mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
feat: Add Anthropic Compatible provider support
- Added support for 'anthropic-compatible' provider nodes in backend. - Implemented isAnthropicCompatible logic in open-sse for /messages URL construction and headers. - Added UI for creating and managing Anthropic Compatible providers in the dashboard. - Updated validation logic for Anthropic-compatible endpoints. - Sanitize base URL input (strip trailing /messages) to prevent 404s and improve UX. - Improve validation: use GET /models (2xx success), and support x-api-key / Authorization Bearer hybrid proxies. - Enable model import via /models for Anthropic Compatible providers. - Ensure Authorization is omitted when x-api-key is present to avoid strict proxy conflicts. - Resolve Anthropic-compatible credentials by prefix during model resolution (e.g., acx/model). - Update default executor to match provider header/url behavior for Anthropic-compatible providers.
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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.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,
|
||||
};
|
||||
|
||||
@@ -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.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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user