Merge branch 'pr-44'
This commit is contained in:
@@ -13,6 +13,11 @@ export class DefaultExecutor extends BaseExecutor {
|
|||||||
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
|
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
|
||||||
return `${normalized}${path}`;
|
return `${normalized}${path}`;
|
||||||
}
|
}
|
||||||
|
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||||
|
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.anthropic.com/v1";
|
||||||
|
const normalized = baseUrl.replace(/\/$/, "");
|
||||||
|
return `${normalized}/messages`;
|
||||||
|
}
|
||||||
switch (this.provider) {
|
switch (this.provider) {
|
||||||
case "claude":
|
case "claude":
|
||||||
case "glm":
|
case "glm":
|
||||||
@@ -42,7 +47,18 @@ export class DefaultExecutor extends BaseExecutor {
|
|||||||
headers["x-api-key"] = credentials.apiKey;
|
headers["x-api-key"] = credentials.apiKey;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||||
|
if (credentials.apiKey) {
|
||||||
|
headers["x-api-key"] = credentials.apiKey;
|
||||||
|
} else if (credentials.accessToken) {
|
||||||
|
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||||
|
}
|
||||||
|
if (!headers["anthropic-version"]) {
|
||||||
|
headers["anthropic-version"] = "2023-06-01";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stream) headers["Accept"] = "text/event-stream";
|
if (stream) headers["Accept"] = "text/event-stream";
|
||||||
|
|||||||
@@ -5,10 +5,19 @@ const OPENAI_COMPATIBLE_DEFAULTS = {
|
|||||||
baseUrl: "https://api.openai.com/v1",
|
baseUrl: "https://api.openai.com/v1",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-";
|
||||||
|
const ANTHROPIC_COMPATIBLE_DEFAULTS = {
|
||||||
|
baseUrl: "https://api.anthropic.com/v1",
|
||||||
|
};
|
||||||
|
|
||||||
function isOpenAICompatible(provider) {
|
function isOpenAICompatible(provider) {
|
||||||
return typeof provider === "string" && provider.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
return typeof provider === "string" && provider.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAnthropicCompatible(provider) {
|
||||||
|
return typeof provider === "string" && provider.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
function getOpenAICompatibleType(provider) {
|
function getOpenAICompatibleType(provider) {
|
||||||
if (!isOpenAICompatible(provider)) return "chat";
|
if (!isOpenAICompatible(provider)) return "chat";
|
||||||
return provider.includes("responses") ? "responses" : "chat";
|
return provider.includes("responses") ? "responses" : "chat";
|
||||||
@@ -20,6 +29,11 @@ function buildOpenAICompatibleUrl(baseUrl, apiType) {
|
|||||||
return `${normalized}${path}`;
|
return `${normalized}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAnthropicCompatibleUrl(baseUrl) {
|
||||||
|
const normalized = baseUrl.replace(/\/$/, "");
|
||||||
|
return `${normalized}/messages`;
|
||||||
|
}
|
||||||
|
|
||||||
// Detect request format from body structure
|
// Detect request format from body structure
|
||||||
export function detectFormat(body) {
|
export function detectFormat(body) {
|
||||||
// OpenAI Responses API: has input[] array instead of messages[]
|
// OpenAI Responses API: has input[] array instead of messages[]
|
||||||
@@ -104,6 +118,13 @@ export function getProviderConfig(provider) {
|
|||||||
baseUrl: OPENAI_COMPATIBLE_DEFAULTS.baseUrl,
|
baseUrl: OPENAI_COMPATIBLE_DEFAULTS.baseUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (isAnthropicCompatible(provider)) {
|
||||||
|
return {
|
||||||
|
...PROVIDERS.anthropic, // Use Anthropic defaults (header: x-api-key)
|
||||||
|
format: "claude",
|
||||||
|
baseUrl: ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
return PROVIDERS[provider] || PROVIDERS.openai;
|
return PROVIDERS[provider] || PROVIDERS.openai;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +141,10 @@ export function buildProviderUrl(provider, model, stream = true, options = {}) {
|
|||||||
const baseUrl = options?.baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl;
|
const baseUrl = options?.baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl;
|
||||||
return buildOpenAICompatibleUrl(baseUrl, apiType);
|
return buildOpenAICompatibleUrl(baseUrl, apiType);
|
||||||
}
|
}
|
||||||
|
if (isAnthropicCompatible(provider)) {
|
||||||
|
const baseUrl = options?.baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl;
|
||||||
|
return buildAnthropicCompatibleUrl(baseUrl);
|
||||||
|
}
|
||||||
const config = getProviderConfig(provider);
|
const config = getProviderConfig(provider);
|
||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
@@ -170,72 +195,87 @@ export function buildProviderHeaders(provider, credentials, stream = true, body
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Add auth header
|
// Add auth header
|
||||||
switch (provider) {
|
// Specific override for Anthropic Compatible
|
||||||
case "gemini":
|
if (isAnthropicCompatible(provider)) {
|
||||||
if (credentials.apiKey) {
|
if (credentials.apiKey) {
|
||||||
headers["x-goog-api-key"] = credentials.apiKey;
|
|
||||||
} else if (credentials.accessToken) {
|
|
||||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "antigravity":
|
|
||||||
case "gemini-cli":
|
|
||||||
// Antigravity and Gemini CLI use OAuth access token
|
|
||||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "claude":
|
|
||||||
// Claude uses x-api-key header for API key, or Authorization for OAuth
|
|
||||||
if (credentials.apiKey) {
|
|
||||||
headers["x-api-key"] = credentials.apiKey;
|
|
||||||
} else if (credentials.accessToken) {
|
|
||||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "github":
|
|
||||||
// GitHub Copilot requires special headers to mimic VSCode
|
|
||||||
// Prioritize copilotToken from providerSpecificData, fallback to accessToken
|
|
||||||
const githubToken = credentials.copilotToken || credentials.accessToken;
|
|
||||||
// Add headers in exact same order as test endpoint
|
|
||||||
headers["Authorization"] = `Bearer ${githubToken}`;
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
headers["copilot-integration-id"] = "vscode-chat";
|
|
||||||
headers["editor-version"] = "vscode/1.107.1";
|
|
||||||
headers["editor-plugin-version"] = "copilot-chat/0.26.7";
|
|
||||||
headers["user-agent"] = "GitHubCopilotChat/0.26.7";
|
|
||||||
headers["openai-intent"] = "conversation-panel";
|
|
||||||
headers["x-github-api-version"] = "2025-04-01";
|
|
||||||
// Generate a UUID for x-request-id (Cloudflare Workers compatible)
|
|
||||||
headers["x-request-id"] = crypto.randomUUID ? crypto.randomUUID() :
|
|
||||||
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
||||||
const r = Math.random() * 16 | 0;
|
|
||||||
const v = c == 'x' ? r : (r & 0x3 | 0x8);
|
|
||||||
return v.toString(16);
|
|
||||||
});
|
|
||||||
headers["x-vscode-user-agent-library-version"] = "electron-fetch";
|
|
||||||
headers["X-Initiator"] = "user";
|
|
||||||
headers["Accept"] = "application/json";
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "codex":
|
|
||||||
case "qwen":
|
|
||||||
case "openai":
|
|
||||||
case "openrouter":
|
|
||||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "glm":
|
|
||||||
case "kimi":
|
|
||||||
case "minimax":
|
|
||||||
// Claude-compatible API providers use x-api-key
|
|
||||||
headers["x-api-key"] = credentials.apiKey;
|
headers["x-api-key"] = credentials.apiKey;
|
||||||
break;
|
// Do NOT send Authorization header when apiKey is present for Anthropic Compatible
|
||||||
|
// as it causes issues with some providers (e.g. opencode.ai)
|
||||||
|
} 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;
|
||||||
|
|
||||||
default:
|
case "antigravity":
|
||||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
case "gemini-cli":
|
||||||
break;
|
// Antigravity and Gemini CLI use OAuth access token
|
||||||
|
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "claude":
|
||||||
|
// Claude uses x-api-key header for API key, or Authorization for OAuth
|
||||||
|
if (credentials.apiKey) {
|
||||||
|
headers["x-api-key"] = credentials.apiKey;
|
||||||
|
} else if (credentials.accessToken) {
|
||||||
|
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "github":
|
||||||
|
// GitHub Copilot requires special headers to mimic VSCode
|
||||||
|
// Prioritize copilotToken from providerSpecificData, fallback to accessToken
|
||||||
|
const githubToken = credentials.copilotToken || credentials.accessToken;
|
||||||
|
// Add headers in exact same order as test endpoint
|
||||||
|
headers["Authorization"] = `Bearer ${githubToken}`;
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
headers["copilot-integration-id"] = "vscode-chat";
|
||||||
|
headers["editor-version"] = "vscode/1.107.1";
|
||||||
|
headers["editor-plugin-version"] = "copilot-chat/0.26.7";
|
||||||
|
headers["user-agent"] = "GitHubCopilotChat/0.26.7";
|
||||||
|
headers["openai-intent"] = "conversation-panel";
|
||||||
|
headers["x-github-api-version"] = "2025-04-01";
|
||||||
|
// Generate a UUID for x-request-id (Cloudflare Workers compatible)
|
||||||
|
headers["x-request-id"] = crypto.randomUUID ? crypto.randomUUID() :
|
||||||
|
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
headers["x-vscode-user-agent-library-version"] = "electron-fetch";
|
||||||
|
headers["X-Initiator"] = "user";
|
||||||
|
headers["Accept"] = "application/json";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "codex":
|
||||||
|
case "qwen":
|
||||||
|
case "openai":
|
||||||
|
case "openrouter":
|
||||||
|
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "glm":
|
||||||
|
case "kimi":
|
||||||
|
case "minimax":
|
||||||
|
// Claude-compatible API providers use x-api-key
|
||||||
|
headers["x-api-key"] = credentials.apiKey;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream accept header
|
// Stream accept header
|
||||||
@@ -251,6 +291,9 @@ export function getTargetFormat(provider) {
|
|||||||
if (isOpenAICompatible(provider)) {
|
if (isOpenAICompatible(provider)) {
|
||||||
return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai";
|
return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai";
|
||||||
}
|
}
|
||||||
|
if (isAnthropicCompatible(provider)) {
|
||||||
|
return "claude";
|
||||||
|
}
|
||||||
const config = getProviderConfig(provider);
|
const config = getProviderConfig(provider);
|
||||||
return config.format || "openai";
|
return config.format || "openai";
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/providers/anthropic-m.png
Normal file
BIN
public/providers/anthropic-m.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -6,7 +6,7 @@ import { useParams, useRouter } from "next/navigation";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, Toggle, Select } from "@/shared/components";
|
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, Toggle, Select } from "@/shared/components";
|
||||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider } from "@/shared/constants/providers";
|
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||||
|
|
||||||
@@ -29,19 +29,24 @@ export default function ProviderDetailPage() {
|
|||||||
const providerInfo = providerNode
|
const providerInfo = providerNode
|
||||||
? {
|
? {
|
||||||
id: providerNode.id,
|
id: providerNode.id,
|
||||||
name: providerNode.name || "OpenAI Compatible",
|
name: providerNode.name || (providerNode.type === "anthropic-compatible" ? "Anthropic Compatible" : "OpenAI Compatible"),
|
||||||
color: "#10A37F",
|
color: providerNode.type === "anthropic-compatible" ? "#D97757" : "#10A37F",
|
||||||
textIcon: "OC",
|
textIcon: providerNode.type === "anthropic-compatible" ? "AC" : "OC",
|
||||||
apiType: providerNode.apiType,
|
apiType: providerNode.apiType,
|
||||||
baseUrl: providerNode.baseUrl,
|
baseUrl: providerNode.baseUrl,
|
||||||
|
type: providerNode.type,
|
||||||
}
|
}
|
||||||
: (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId]);
|
: (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId]);
|
||||||
const isOAuth = !!OAUTH_PROVIDERS[providerId];
|
const isOAuth = !!OAUTH_PROVIDERS[providerId];
|
||||||
const models = getModelsByProviderId(providerId);
|
const models = getModelsByProviderId(providerId);
|
||||||
const providerAlias = getProviderAlias(providerId);
|
const providerAlias = getProviderAlias(providerId);
|
||||||
|
|
||||||
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
||||||
const providerStorageAlias = isOpenAICompatible ? providerId : providerAlias;
|
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
|
||||||
const providerDisplayAlias = isOpenAICompatible
|
const isCompatible = isOpenAICompatible || isAnthropicCompatible;
|
||||||
|
|
||||||
|
const providerStorageAlias = isCompatible ? providerId : providerAlias;
|
||||||
|
const providerDisplayAlias = isCompatible
|
||||||
? (providerNode?.prefix || providerId)
|
? (providerNode?.prefix || providerId)
|
||||||
: providerAlias;
|
: providerAlias;
|
||||||
|
|
||||||
@@ -238,9 +243,9 @@ export default function ProviderDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderModelsSection = () => {
|
const renderModelsSection = () => {
|
||||||
if (isOpenAICompatible) {
|
if (isCompatible) {
|
||||||
return (
|
return (
|
||||||
<OpenAICompatibleModelsSection
|
<CompatibleModelsSection
|
||||||
providerStorageAlias={providerStorageAlias}
|
providerStorageAlias={providerStorageAlias}
|
||||||
providerDisplayAlias={providerDisplayAlias}
|
providerDisplayAlias={providerDisplayAlias}
|
||||||
modelAliases={modelAliases}
|
modelAliases={modelAliases}
|
||||||
@@ -249,6 +254,7 @@ export default function ProviderDetailPage() {
|
|||||||
onSetAlias={handleSetAlias}
|
onSetAlias={handleSetAlias}
|
||||||
onDeleteAlias={handleDeleteAlias}
|
onDeleteAlias={handleDeleteAlias}
|
||||||
connections={connections}
|
connections={connections}
|
||||||
|
isAnthropic={isAnthropicCompatible}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -317,6 +323,9 @@ export default function ProviderDetailPage() {
|
|||||||
if (isOpenAICompatible && providerInfo.apiType) {
|
if (isOpenAICompatible && providerInfo.apiType) {
|
||||||
return providerInfo.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
|
return providerInfo.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
|
||||||
}
|
}
|
||||||
|
if (isAnthropicCompatible) {
|
||||||
|
return "/providers/anthropic-m.png";
|
||||||
|
}
|
||||||
return `/providers/${providerInfo.id}.png`;
|
return `/providers/${providerInfo.id}.png`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -361,14 +370,14 @@ export default function ProviderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOpenAICompatible && providerNode && (
|
{isCompatible && providerNode && (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">OpenAI Compatible Details</h2>
|
<h2 className="text-lg font-semibold">{isAnthropicCompatible ? "Anthropic Compatible Details" : "OpenAI Compatible Details"}</h2>
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
{providerNode.apiType === "responses" ? "Responses API" : "Chat Completions"} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/
|
{isAnthropicCompatible ? "Messages API" : (providerNode.apiType === "responses" ? "Responses API" : "Chat Completions")} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/
|
||||||
{providerNode.apiType === "responses" ? "responses" : "chat/completions"}
|
{isAnthropicCompatible ? "messages" : (providerNode.apiType === "responses" ? "responses" : "chat/completions")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -393,7 +402,7 @@ export default function ProviderDetailPage() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="delete"
|
icon="delete"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!confirm("Delete this OpenAI Compatible node?")) return;
|
if (!confirm(`Delete this ${isAnthropicCompatible ? "Anthropic" : "OpenAI"} Compatible node?`)) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/provider-nodes/${providerId}`, { method: "DELETE" });
|
const res = await fetch(`/api/provider-nodes/${providerId}`, { method: "DELETE" });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -410,7 +419,7 @@ export default function ProviderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{connections.length > 0 && (
|
{connections.length > 0 && (
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
Only one connection is allowed per OpenAI Compatible node. Add another node if you need more connections.
|
Only one connection is allowed per compatible node. Add another node if you need more connections.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -420,7 +429,7 @@ export default function ProviderDetailPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold">Connections</h2>
|
<h2 className="text-lg font-semibold">Connections</h2>
|
||||||
{!isOpenAICompatible && (
|
{!isCompatible && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="add"
|
icon="add"
|
||||||
@@ -438,7 +447,7 @@ export default function ProviderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-text-main font-medium mb-1">No connections yet</p>
|
<p className="text-text-main font-medium mb-1">No connections yet</p>
|
||||||
<p className="text-sm text-text-muted mb-4">Add your first connection to get started</p>
|
<p className="text-sm text-text-muted mb-4">Add your first connection to get started</p>
|
||||||
{!isOpenAICompatible && (
|
{!isCompatible && (
|
||||||
<Button icon="add" onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}>
|
<Button icon="add" onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}>
|
||||||
Add Connection
|
Add Connection
|
||||||
</Button>
|
</Button>
|
||||||
@@ -499,7 +508,8 @@ export default function ProviderDetailPage() {
|
|||||||
isOpen={showAddApiKeyModal}
|
isOpen={showAddApiKeyModal}
|
||||||
provider={providerId}
|
provider={providerId}
|
||||||
providerName={providerInfo.name}
|
providerName={providerInfo.name}
|
||||||
isOpenAICompatible={isOpenAICompatible}
|
isCompatible={isCompatible}
|
||||||
|
isAnthropic={isAnthropicCompatible}
|
||||||
onSave={handleSaveApiKey}
|
onSave={handleSaveApiKey}
|
||||||
onClose={() => setShowAddApiKeyModal(false)}
|
onClose={() => setShowAddApiKeyModal(false)}
|
||||||
/>
|
/>
|
||||||
@@ -509,12 +519,15 @@ export default function ProviderDetailPage() {
|
|||||||
onSave={handleUpdateConnection}
|
onSave={handleUpdateConnection}
|
||||||
onClose={() => setShowEditModal(false)}
|
onClose={() => setShowEditModal(false)}
|
||||||
/>
|
/>
|
||||||
<EditOpenAICompatibleModal
|
{isCompatible && (
|
||||||
isOpen={showEditNodeModal}
|
<EditCompatibleNodeModal
|
||||||
node={providerNode}
|
isOpen={showEditNodeModal}
|
||||||
onSave={handleUpdateNode}
|
node={providerNode}
|
||||||
onClose={() => setShowEditNodeModal(false)}
|
onSave={handleUpdateNode}
|
||||||
/>
|
onClose={() => setShowEditNodeModal(false)}
|
||||||
|
isAnthropic={isAnthropicCompatible}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -685,7 +698,7 @@ PassthroughModelRow.propTypes = {
|
|||||||
onDeleteAlias: PropTypes.func.isRequired,
|
onDeleteAlias: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
function OpenAICompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias, connections }) {
|
function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias, connections, isAnthropic }) {
|
||||||
const [newModel, setNewModel] = useState("");
|
const [newModel, setNewModel] = useState("");
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
@@ -775,7 +788,7 @@ function OpenAICompatibleModelsSection({ providerStorageAlias, providerDisplayAl
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
Add OpenAI-compatible models manually or import them from the /models endpoint.
|
Add {isAnthropic ? "Anthropic" : "OpenAI"}-compatible models manually or import them from the /models endpoint.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-end gap-2 flex-wrap">
|
<div className="flex items-end gap-2 flex-wrap">
|
||||||
@@ -787,7 +800,7 @@ function OpenAICompatibleModelsSection({ providerStorageAlias, providerDisplayAl
|
|||||||
value={newModel}
|
value={newModel}
|
||||||
onChange={(e) => setNewModel(e.target.value)}
|
onChange={(e) => setNewModel(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||||
placeholder="gpt-4o"
|
placeholder={isAnthropic ? "claude-3-opus-20240229" : "gpt-4o"}
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -823,7 +836,7 @@ function OpenAICompatibleModelsSection({ providerStorageAlias, providerDisplayAl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
OpenAICompatibleModelsSection.propTypes = {
|
CompatibleModelsSection.propTypes = {
|
||||||
providerStorageAlias: PropTypes.string.isRequired,
|
providerStorageAlias: PropTypes.string.isRequired,
|
||||||
providerDisplayAlias: PropTypes.string.isRequired,
|
providerDisplayAlias: PropTypes.string.isRequired,
|
||||||
modelAliases: PropTypes.object.isRequired,
|
modelAliases: PropTypes.object.isRequired,
|
||||||
@@ -835,6 +848,7 @@ OpenAICompatibleModelsSection.propTypes = {
|
|||||||
id: PropTypes.string,
|
id: PropTypes.string,
|
||||||
isActive: PropTypes.bool,
|
isActive: PropTypes.bool,
|
||||||
})).isRequired,
|
})).isRequired,
|
||||||
|
isAnthropic: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
function CooldownTimer({ until }) {
|
function CooldownTimer({ until }) {
|
||||||
@@ -997,7 +1011,7 @@ ConnectionRow.propTypes = {
|
|||||||
onDelete: PropTypes.func.isRequired,
|
onDelete: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
function AddApiKeyModal({ isOpen, provider, providerName, isOpenAICompatible, onSave, onClose }) {
|
function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, onSave, onClose }) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
@@ -1062,9 +1076,12 @@ function AddApiKeyModal({ isOpen, provider, providerName, isOpenAICompatible, on
|
|||||||
{validationResult === "success" ? "Valid" : "Invalid"}
|
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{isOpenAICompatible && (
|
{isCompatible && (
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
Validation checks {providerName || "OpenAI Compatible"} via /models on your base URL.
|
{isAnthropic
|
||||||
|
? `Validation checks ${providerName || "Anthropic Compatible"} by verifying the API key.`
|
||||||
|
: `Validation checks ${providerName || "OpenAI Compatible"} via /models on your base URL.`
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
@@ -1090,7 +1107,8 @@ AddApiKeyModal.propTypes = {
|
|||||||
isOpen: PropTypes.bool.isRequired,
|
isOpen: PropTypes.bool.isRequired,
|
||||||
provider: PropTypes.string,
|
provider: PropTypes.string,
|
||||||
providerName: PropTypes.string,
|
providerName: PropTypes.string,
|
||||||
isOpenAICompatible: PropTypes.bool,
|
isCompatible: PropTypes.bool,
|
||||||
|
isAnthropic: PropTypes.bool,
|
||||||
onSave: PropTypes.func.isRequired,
|
onSave: PropTypes.func.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
@@ -1168,7 +1186,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
|||||||
if (!connection) return null;
|
if (!connection) return null;
|
||||||
|
|
||||||
const isOAuth = connection.authType === "oauth";
|
const isOAuth = connection.authType === "oauth";
|
||||||
const isCompatible = isOpenAICompatibleProvider(connection.provider);
|
const isCompatible = isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}>
|
<Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}>
|
||||||
@@ -1254,7 +1272,7 @@ EditConnectionModal.propTypes = {
|
|||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) {
|
function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
@@ -1272,10 +1290,10 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) {
|
|||||||
name: node.name || "",
|
name: node.name || "",
|
||||||
prefix: node.prefix || "",
|
prefix: node.prefix || "",
|
||||||
apiType: node.apiType || "chat",
|
apiType: node.apiType || "chat",
|
||||||
baseUrl: node.baseUrl || "https://api.openai.com/v1",
|
baseUrl: node.baseUrl || (isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [node]);
|
}, [node, isAnthropic]);
|
||||||
|
|
||||||
const apiTypeOptions = [
|
const apiTypeOptions = [
|
||||||
{ value: "chat", label: "Chat Completions" },
|
{ value: "chat", label: "Chat Completions" },
|
||||||
@@ -1286,12 +1304,15 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) {
|
|||||||
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
|
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await onSave({
|
const payload = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
prefix: formData.prefix,
|
prefix: formData.prefix,
|
||||||
apiType: formData.apiType,
|
|
||||||
baseUrl: formData.baseUrl,
|
baseUrl: formData.baseUrl,
|
||||||
});
|
};
|
||||||
|
if (!isAnthropic) {
|
||||||
|
payload.apiType = formData.apiType;
|
||||||
|
}
|
||||||
|
await onSave(payload);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -1303,7 +1324,11 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) {
|
|||||||
const res = await fetch("/api/provider-nodes/validate", {
|
const res = await fetch("/api/provider-nodes/validate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey }),
|
body: JSON.stringify({
|
||||||
|
baseUrl: formData.baseUrl,
|
||||||
|
apiKey: checkKey,
|
||||||
|
type: isAnthropic ? "anthropic-compatible" : "openai-compatible"
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setValidationResult(data.valid ? "success" : "failed");
|
setValidationResult(data.valid ? "success" : "failed");
|
||||||
@@ -1317,34 +1342,36 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) {
|
|||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} title="Edit OpenAI Compatible" onClose={onClose}>
|
<Modal isOpen={isOpen} title={`Edit ${isAnthropic ? "Anthropic" : "OpenAI"} Compatible`} onClose={onClose}>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Input
|
<Input
|
||||||
label="Name"
|
label="Name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
placeholder="OpenAI Compatible (Prod)"
|
placeholder={`${isAnthropic ? "Anthropic" : "OpenAI"} Compatible (Prod)`}
|
||||||
hint="Required. A friendly label for this node."
|
hint="Required. A friendly label for this node."
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Prefix"
|
label="Prefix"
|
||||||
value={formData.prefix}
|
value={formData.prefix}
|
||||||
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
||||||
placeholder="oc-prod"
|
placeholder={isAnthropic ? "ac-prod" : "oc-prod"}
|
||||||
hint="Required. Used as the provider prefix for model IDs."
|
hint="Required. Used as the provider prefix for model IDs."
|
||||||
/>
|
/>
|
||||||
<Select
|
{!isAnthropic && (
|
||||||
label="API Type"
|
<Select
|
||||||
options={apiTypeOptions}
|
label="API Type"
|
||||||
value={formData.apiType}
|
options={apiTypeOptions}
|
||||||
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
|
value={formData.apiType}
|
||||||
/>
|
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
label="Base URL"
|
label="Base URL"
|
||||||
value={formData.baseUrl}
|
value={formData.baseUrl}
|
||||||
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||||
placeholder="https://api.openai.com/v1"
|
placeholder={isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"}
|
||||||
hint="Use the base URL (ending in /v1) for your OpenAI-compatible API."
|
hint={`Use the base URL (ending in /v1) for your ${isAnthropic ? "Anthropic" : "OpenAI"}-compatible API.`}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -1378,7 +1405,7 @@ function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
EditOpenAICompatibleModal.propTypes = {
|
EditCompatibleNodeModal.propTypes = {
|
||||||
isOpen: PropTypes.bool.isRequired,
|
isOpen: PropTypes.bool.isRequired,
|
||||||
node: PropTypes.shape({
|
node: PropTypes.shape({
|
||||||
id: PropTypes.string,
|
id: PropTypes.string,
|
||||||
@@ -1389,4 +1416,5 @@ EditOpenAICompatibleModal.propTypes = {
|
|||||||
}),
|
}),
|
||||||
onSave: PropTypes.func.isRequired,
|
onSave: PropTypes.func.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
|
isAnthropic: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Image from "next/image";
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Card, CardSkeleton, Badge, Button, Input, Modal, Select } from "@/shared/components";
|
import { Card, CardSkeleton, Badge, Button, Input, Modal, Select } from "@/shared/components";
|
||||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||||
import { OPENAI_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
|
import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ export default function ProvidersPage() {
|
|||||||
const [providerNodes, setProviderNodes] = useState([]);
|
const [providerNodes, setProviderNodes] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
|
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
|
||||||
|
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -103,12 +104,25 @@ export default function ProvidersPage() {
|
|||||||
apiType: node.apiType,
|
apiType: node.apiType,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const anthropicCompatibleProviders = providerNodes
|
||||||
|
.filter((node) => node.type === "anthropic-compatible")
|
||||||
|
.map((node) => ({
|
||||||
|
id: node.id,
|
||||||
|
name: node.name || "Anthropic Compatible",
|
||||||
|
color: "#D97757",
|
||||||
|
textIcon: "AC",
|
||||||
|
}));
|
||||||
|
|
||||||
const apiKeyProviders = {
|
const apiKeyProviders = {
|
||||||
...APIKEY_PROVIDERS,
|
...APIKEY_PROVIDERS,
|
||||||
...compatibleProviders.reduce((acc, provider) => {
|
...compatibleProviders.reduce((acc, provider) => {
|
||||||
acc[provider.id] = provider;
|
acc[provider.id] = provider;
|
||||||
return acc;
|
return acc;
|
||||||
}, {}),
|
}, {}),
|
||||||
|
...anthropicCompatibleProviders.reduce((acc, provider) => {
|
||||||
|
acc[provider.id] = provider;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -141,9 +155,20 @@ export default function ProvidersPage() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold">API Key Providers</h2>
|
<h2 className="text-xl font-semibold">API Key Providers</h2>
|
||||||
<Button size="sm" icon="add" onClick={() => setShowAddCompatibleModal(true)}>
|
<div className="flex gap-2">
|
||||||
Add OpenAI Compatible
|
<Button size="sm" icon="add" onClick={() => setShowAddAnthropicCompatibleModal(true)}>
|
||||||
</Button>
|
Add Anthropic Compatible
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="add"
|
||||||
|
onClick={() => setShowAddCompatibleModal(true)}
|
||||||
|
className="!bg-white !text-black hover:!bg-gray-100"
|
||||||
|
>
|
||||||
|
Add OpenAI Compatible
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{Object.entries(apiKeyProviders).map(([key, info]) => (
|
{Object.entries(apiKeyProviders).map(([key, info]) => (
|
||||||
@@ -164,6 +189,14 @@ export default function ProvidersPage() {
|
|||||||
setShowAddCompatibleModal(false);
|
setShowAddCompatibleModal(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<AddAnthropicCompatibleModal
|
||||||
|
isOpen={showAddAnthropicCompatibleModal}
|
||||||
|
onClose={() => setShowAddAnthropicCompatibleModal(false)}
|
||||||
|
onCreated={(node) => {
|
||||||
|
setProviderNodes((prev) => [...prev, node]);
|
||||||
|
setShowAddAnthropicCompatibleModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -237,6 +270,7 @@ ProviderCard.propTypes = {
|
|||||||
function ApiKeyProviderCard({ providerId, provider, stats }) {
|
function ApiKeyProviderCard({ providerId, provider, stats }) {
|
||||||
const { connected, error, errorCode, errorTime } = stats;
|
const { connected, error, errorCode, errorTime } = stats;
|
||||||
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
||||||
|
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
|
|
||||||
// Determine icon path: OpenAI Compatible providers use specialized icons
|
// Determine icon path: OpenAI Compatible providers use specialized icons
|
||||||
@@ -244,6 +278,9 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
|
|||||||
if (isCompatible) {
|
if (isCompatible) {
|
||||||
return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
|
return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
|
||||||
}
|
}
|
||||||
|
if (isAnthropicCompatible) {
|
||||||
|
return "/providers/anthropic-m.png"; // Use Anthropic icon as base
|
||||||
|
}
|
||||||
return `/providers/${provider.id}.png`;
|
return `/providers/${provider.id}.png`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -284,6 +321,11 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
|
|||||||
{provider.apiType === "responses" ? "Responses" : "Chat"}
|
{provider.apiType === "responses" ? "Responses" : "Chat"}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{isAnthropicCompatible && (
|
||||||
|
<Badge variant="default" size="sm">
|
||||||
|
Messages
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,6 +393,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
|||||||
prefix: formData.prefix,
|
prefix: formData.prefix,
|
||||||
apiType: formData.apiType,
|
apiType: formData.apiType,
|
||||||
baseUrl: formData.baseUrl,
|
baseUrl: formData.baseUrl,
|
||||||
|
type: "openai-compatible",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -378,7 +421,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
|||||||
const res = await fetch("/api/provider-nodes/validate", {
|
const res = await fetch("/api/provider-nodes/validate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey }),
|
body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey, type: "openai-compatible" }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setValidationResult(data.valid ? "success" : "failed");
|
setValidationResult(data.valid ? "success" : "failed");
|
||||||
@@ -456,3 +499,137 @@ AddOpenAICompatibleModal.propTypes = {
|
|||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
onCreated: PropTypes.func.isRequired,
|
onCreated: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
prefix: "",
|
||||||
|
baseUrl: "https://api.anthropic.com/v1",
|
||||||
|
});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [checkKey, setCheckKey] = useState("");
|
||||||
|
const [validating, setValidating] = useState(false);
|
||||||
|
const [validationResult, setValidationResult] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset validation when modal opens
|
||||||
|
if (isOpen) {
|
||||||
|
setValidationResult(null);
|
||||||
|
setCheckKey("");
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/provider-nodes", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: formData.name,
|
||||||
|
prefix: formData.prefix,
|
||||||
|
baseUrl: formData.baseUrl,
|
||||||
|
type: "anthropic-compatible",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
onCreated(data.node);
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
prefix: "",
|
||||||
|
baseUrl: "https://api.anthropic.com/v1",
|
||||||
|
});
|
||||||
|
setCheckKey("");
|
||||||
|
setValidationResult(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error creating Anthropic Compatible node:", error);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValidate = async () => {
|
||||||
|
setValidating(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/provider-nodes/validate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
baseUrl: formData.baseUrl,
|
||||||
|
apiKey: checkKey,
|
||||||
|
type: "anthropic-compatible"
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setValidationResult(data.valid ? "success" : "failed");
|
||||||
|
} catch {
|
||||||
|
setValidationResult("failed");
|
||||||
|
} finally {
|
||||||
|
setValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} title="Add Anthropic Compatible" onClose={onClose}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="Anthropic Compatible (Prod)"
|
||||||
|
hint="Required. A friendly label for this node."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Prefix"
|
||||||
|
value={formData.prefix}
|
||||||
|
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
||||||
|
placeholder="ac-prod"
|
||||||
|
hint="Required. Used as the provider prefix for model IDs."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Base URL"
|
||||||
|
value={formData.baseUrl}
|
||||||
|
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||||
|
placeholder="https://api.anthropic.com/v1"
|
||||||
|
hint="Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages."
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
label="API Key (for Check)"
|
||||||
|
type="password"
|
||||||
|
value={checkKey}
|
||||||
|
onChange={(e) => setCheckKey(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<div className="pt-6">
|
||||||
|
<Button onClick={handleValidate} disabled={!checkKey || validating || !formData.baseUrl.trim()} variant="secondary">
|
||||||
|
{validating ? "Checking..." : "Check"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{validationResult && (
|
||||||
|
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||||
|
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleSubmit} fullWidth disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || submitting}>
|
||||||
|
{submitting ? "Creating..." : "Create"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddAnthropicCompatibleModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
onCreated: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export async function PUT(request, { params }) {
|
|||||||
return NextResponse.json({ error: "Prefix is required" }, { status: 400 });
|
return NextResponse.json({ error: "Prefix is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiType || !["chat", "responses"].includes(apiType)) {
|
// Only validate apiType for OpenAI Compatible nodes
|
||||||
|
if (node.type === "openai-compatible" && (!apiType || !["chat", "responses"].includes(apiType))) {
|
||||||
return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,12 +30,27 @@ export async function PUT(request, { params }) {
|
|||||||
return NextResponse.json({ error: "Base URL is required" }, { status: 400 });
|
return NextResponse.json({ error: "Base URL is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await updateProviderNode(id, {
|
let sanitizedBaseUrl = baseUrl.trim();
|
||||||
|
|
||||||
|
// Sanitize Base URL for Anthropic Compatible
|
||||||
|
if (node.type === "anthropic-compatible") {
|
||||||
|
sanitizedBaseUrl = sanitizedBaseUrl.replace(/\/$/, "");
|
||||||
|
if (sanitizedBaseUrl.endsWith("/messages")) {
|
||||||
|
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
prefix: prefix.trim(),
|
prefix: prefix.trim(),
|
||||||
apiType,
|
baseUrl: sanitizedBaseUrl,
|
||||||
baseUrl: baseUrl.trim(),
|
};
|
||||||
});
|
|
||||||
|
if (node.type === "openai-compatible") {
|
||||||
|
updates.apiType = apiType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateProviderNode(id, updates);
|
||||||
|
|
||||||
const connections = await getProviderConnections({ provider: id });
|
const connections = await getProviderConnections({ provider: id });
|
||||||
await Promise.all(connections.map((connection) => (
|
await Promise.all(connections.map((connection) => (
|
||||||
@@ -42,8 +58,8 @@ export async function PUT(request, { params }) {
|
|||||||
providerSpecificData: {
|
providerSpecificData: {
|
||||||
...(connection.providerSpecificData || {}),
|
...(connection.providerSpecificData || {}),
|
||||||
prefix: prefix.trim(),
|
prefix: prefix.trim(),
|
||||||
apiType,
|
apiType: node.type === "openai-compatible" ? apiType : undefined,
|
||||||
baseUrl: baseUrl.trim(),
|
baseUrl: sanitizedBaseUrl,
|
||||||
nodeName: updated.name,
|
nodeName: updated.name,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createProviderNode, getProviderNodes } from "@/models";
|
import { createProviderNode, getProviderNodes } from "@/models";
|
||||||
import { OPENAI_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
|
import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
|
||||||
|
|
||||||
const OPENAI_COMPATIBLE_DEFAULTS = {
|
const OPENAI_COMPATIBLE_DEFAULTS = {
|
||||||
baseUrl: "https://api.openai.com/v1",
|
baseUrl: "https://api.openai.com/v1",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ANTHROPIC_COMPATIBLE_DEFAULTS = {
|
||||||
|
baseUrl: "https://api.anthropic.com/v1",
|
||||||
|
};
|
||||||
|
|
||||||
// GET /api/provider-nodes - List all provider nodes
|
// GET /api/provider-nodes - List all provider nodes
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -21,7 +25,7 @@ export async function GET() {
|
|||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, prefix, apiType, baseUrl } = body;
|
const { name, prefix, apiType, baseUrl, type } = body;
|
||||||
|
|
||||||
if (!name?.trim()) {
|
if (!name?.trim()) {
|
||||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||||
@@ -31,20 +35,44 @@ export async function POST(request) {
|
|||||||
return NextResponse.json({ error: "Prefix is required" }, { status: 400 });
|
return NextResponse.json({ error: "Prefix is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiType || !["chat", "responses"].includes(apiType)) {
|
// Determine type
|
||||||
return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 });
|
const nodeType = type || "openai-compatible";
|
||||||
|
|
||||||
|
if (nodeType === "openai-compatible") {
|
||||||
|
if (!apiType || !["chat", "responses"].includes(apiType)) {
|
||||||
|
return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = await createProviderNode({
|
||||||
|
id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${crypto.randomUUID()}`,
|
||||||
|
type: "openai-compatible",
|
||||||
|
prefix: prefix.trim(),
|
||||||
|
apiType,
|
||||||
|
baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(),
|
||||||
|
name: name.trim(),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ node }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = await createProviderNode({
|
if (nodeType === "anthropic-compatible") {
|
||||||
id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${crypto.randomUUID()}`,
|
// Sanitize Base URL: remove trailing slash, and remove trailing /messages if user added it
|
||||||
type: "openai-compatible",
|
// This prevents double-appending /messages at runtime
|
||||||
prefix: prefix.trim(),
|
let sanitizedBaseUrl = (baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl).trim().replace(/\/$/, "");
|
||||||
apiType,
|
if (sanitizedBaseUrl.endsWith("/messages")) {
|
||||||
baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(),
|
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages
|
||||||
name: name.trim(),
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ node }, { status: 201 });
|
const node = await createProviderNode({
|
||||||
|
id: `${ANTHROPIC_COMPATIBLE_PREFIX}${crypto.randomUUID()}`,
|
||||||
|
type: "anthropic-compatible",
|
||||||
|
prefix: prefix.trim(),
|
||||||
|
baseUrl: sanitizedBaseUrl,
|
||||||
|
name: name.trim(),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ node }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "Invalid provider node type" }, { status: 400 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error creating provider node:", error);
|
console.log("Error creating provider node:", error);
|
||||||
return NextResponse.json({ error: "Failed to create provider node" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to create provider node" }, { status: 500 });
|
||||||
|
|||||||
@@ -1,15 +1,39 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
// POST /api/provider-nodes/validate - Validate API key against base URL /models
|
// POST /api/provider-nodes/validate - Validate API key against base URL
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { baseUrl, apiKey } = body;
|
const { baseUrl, apiKey, type } = body;
|
||||||
|
|
||||||
if (!baseUrl || !apiKey) {
|
if (!baseUrl || !apiKey) {
|
||||||
return NextResponse.json({ error: "Base URL and API key required" }, { status: 400 });
|
return NextResponse.json({ error: "Base URL and API key required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anthropic Compatible Validation
|
||||||
|
if (type === "anthropic-compatible") {
|
||||||
|
// Robustly construct URL: remove trailing slash, and remove trailing /messages if user added it
|
||||||
|
let normalizedBase = baseUrl.trim().replace(/\/$/, "");
|
||||||
|
if (normalizedBase.endsWith("/messages")) {
|
||||||
|
normalizedBase = normalizedBase.slice(0, -9); // remove /messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use /models endpoint for validation as many compatible providers support it (like OpenAI)
|
||||||
|
const modelsUrl = `${normalizedBase}/models`;
|
||||||
|
|
||||||
|
const res = await fetch(modelsUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"x-api-key": apiKey,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"Authorization": `Bearer ${apiKey}` // Add Bearer token for hybrid proxies
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI Compatible Validation (Default)
|
||||||
const modelsUrl = `${baseUrl.replace(/\/$/, "")}/models`;
|
const modelsUrl = `${baseUrl.replace(/\/$/, "")}/models`;
|
||||||
const res = await fetch(modelsUrl, {
|
const res = await fetch(modelsUrl, {
|
||||||
headers: { "Authorization": `Bearer ${apiKey}` },
|
headers: { "Authorization": `Bearer ${apiKey}` },
|
||||||
@@ -17,7 +41,7 @@ export async function POST(request) {
|
|||||||
|
|
||||||
return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" });
|
return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error validating OpenAI compatible base URL:", error);
|
console.log("Error validating provider node:", error);
|
||||||
return NextResponse.json({ error: "Validation failed" }, { status: 500 });
|
return NextResponse.json({ error: "Validation failed" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getProviderConnectionById } from "@/models";
|
import { getProviderConnectionById } from "@/models";
|
||||||
import { isOpenAICompatibleProvider } from "@/shared/constants/providers";
|
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||||
|
|
||||||
// Provider models endpoints configuration
|
// Provider models endpoints configuration
|
||||||
const PROVIDER_MODELS_CONFIG = {
|
const PROVIDER_MODELS_CONFIG = {
|
||||||
@@ -119,6 +119,47 @@ export async function GET(request, { params }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAnthropicCompatibleProvider(connection.provider)) {
|
||||||
|
let baseUrl = connection.providerSpecificData?.baseUrl;
|
||||||
|
if (!baseUrl) {
|
||||||
|
return NextResponse.json({ error: "No base URL configured for Anthropic compatible provider" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUrl = baseUrl.replace(/\/$/, "");
|
||||||
|
if (baseUrl.endsWith("/messages")) {
|
||||||
|
baseUrl = baseUrl.slice(0, -9);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/models`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": connection.apiKey,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"Authorization": `Bearer ${connection.apiKey}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.log(`Error fetching models from ${connection.provider}:`, errorText);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Failed to fetch models: ${response.status}` },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const models = data.data || data.models || [];
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
provider: connection.provider,
|
||||||
|
connectionId: connection.id,
|
||||||
|
models
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const config = PROVIDER_MODELS_CONFIG[connection.provider];
|
const config = PROVIDER_MODELS_CONFIG[connection.provider];
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb";
|
import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb";
|
||||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||||
import { isOpenAICompatibleProvider } from "@/shared/constants/providers";
|
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||||
import {
|
import {
|
||||||
GEMINI_CONFIG,
|
GEMINI_CONFIG,
|
||||||
ANTIGRAVITY_CONFIG,
|
ANTIGRAVITY_CONFIG,
|
||||||
@@ -322,6 +322,32 @@ async function testApiKeyConnection(connection) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anthropic Compatible providers - test via /models endpoint
|
||||||
|
if (isAnthropicCompatibleProvider(connection.provider)) {
|
||||||
|
let modelsBase = connection.providerSpecificData?.baseUrl;
|
||||||
|
if (!modelsBase) {
|
||||||
|
return { valid: false, error: "Missing base URL" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
modelsBase = modelsBase.replace(/\/$/, "");
|
||||||
|
if (modelsBase.endsWith("/messages")) {
|
||||||
|
modelsBase = modelsBase.slice(0, -9);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelsUrl = `${modelsBase}/models`;
|
||||||
|
const res = await fetch(modelsUrl, {
|
||||||
|
headers: {
|
||||||
|
"x-api-key": connection.apiKey,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"Authorization": `Bearer ${connection.apiKey}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
|
||||||
|
} catch (err) {
|
||||||
|
return { valid: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (connection.provider) {
|
switch (connection.provider) {
|
||||||
case "openai": {
|
case "openai": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getProviderConnections, createProviderConnection, getProviderNodeById, isCloudEnabled } from "@/models";
|
import { getProviderConnections, createProviderConnection, getProviderNodeById, isCloudEnabled } from "@/models";
|
||||||
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
|
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||||
import { isOpenAICompatibleProvider } from "@/shared/constants/providers";
|
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||||
|
|
||||||
@@ -33,7 +33,11 @@ export async function POST(request) {
|
|||||||
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = body;
|
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = body;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!provider || (!APIKEY_PROVIDERS[provider] && !isOpenAICompatibleProvider(provider))) {
|
const isValidProvider = APIKEY_PROVIDERS[provider] ||
|
||||||
|
isOpenAICompatibleProvider(provider) ||
|
||||||
|
isAnthropicCompatibleProvider(provider);
|
||||||
|
|
||||||
|
if (!provider || !isValidProvider) {
|
||||||
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -62,6 +66,22 @@ export async function POST(request) {
|
|||||||
baseUrl: node.baseUrl,
|
baseUrl: node.baseUrl,
|
||||||
nodeName: node.name,
|
nodeName: node.name,
|
||||||
};
|
};
|
||||||
|
} else if (isAnthropicCompatibleProvider(provider)) {
|
||||||
|
const node = await getProviderNodeById(provider);
|
||||||
|
if (!node) {
|
||||||
|
return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingConnections = await getProviderConnections({ provider });
|
||||||
|
if (existingConnections.length > 0) {
|
||||||
|
return NextResponse.json({ error: "Only one connection is allowed for this Anthropic Compatible node" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
providerSpecificData = {
|
||||||
|
prefix: node.prefix,
|
||||||
|
baseUrl: node.baseUrl,
|
||||||
|
nodeName: node.name,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const newConnection = await createProviderConnection({
|
const newConnection = await createProviderConnection({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getProviderNodeById } from "@/models";
|
import { getProviderNodeById } from "@/models";
|
||||||
import { isOpenAICompatibleProvider } from "@/shared/constants/providers";
|
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||||
|
|
||||||
// POST /api/providers/validate - Validate API key with provider
|
// POST /api/providers/validate - Validate API key with provider
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
@@ -33,6 +33,34 @@ export async function POST(request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAnthropicCompatibleProvider(provider)) {
|
||||||
|
const node = await getProviderNodeById(provider);
|
||||||
|
if (!node) {
|
||||||
|
return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedBase = node.baseUrl?.trim().replace(/\/$/, "") || "";
|
||||||
|
if (normalizedBase.endsWith("/messages")) {
|
||||||
|
normalizedBase = normalizedBase.slice(0, -9); // remove /messages
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelsUrl = `${normalizedBase}/models`;
|
||||||
|
|
||||||
|
const res = await fetch(modelsUrl, {
|
||||||
|
headers: {
|
||||||
|
"x-api-key": apiKey,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"Authorization": `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
isValid = res.ok;
|
||||||
|
return NextResponse.json({
|
||||||
|
valid: isValid,
|
||||||
|
error: isValid ? null : "Invalid API key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "openai":
|
case "openai":
|
||||||
const openaiRes = await fetch("https://api.openai.com/v1/models", {
|
const openaiRes = await fetch("https://api.openai.com/v1/models", {
|
||||||
|
|||||||
@@ -23,11 +23,16 @@ export const APIKEY_PROVIDERS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||||
|
export const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-";
|
||||||
|
|
||||||
export function isOpenAICompatibleProvider(providerId) {
|
export function isOpenAICompatibleProvider(providerId) {
|
||||||
return typeof providerId === "string" && providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
return typeof providerId === "string" && providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAnthropicCompatibleProvider(providerId) {
|
||||||
|
return typeof providerId === "string" && providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
// All providers (combined)
|
// All providers (combined)
|
||||||
export const AI_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
|
export const AI_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,18 @@ export async function getModelInfo(modelStr) {
|
|||||||
|
|
||||||
if (!parsed.isAlias) {
|
if (!parsed.isAlias) {
|
||||||
if (parsed.provider === parsed.providerAlias) {
|
if (parsed.provider === parsed.providerAlias) {
|
||||||
const providerNodes = await getProviderNodes({ type: "openai-compatible" });
|
// Check OpenAI Compatible nodes
|
||||||
const matchedNode = providerNodes.find((node) => node.prefix === parsed.providerAlias);
|
const openaiNodes = await getProviderNodes({ type: "openai-compatible" });
|
||||||
if (matchedNode) {
|
const matchedOpenAI = openaiNodes.find((node) => node.prefix === parsed.providerAlias);
|
||||||
return { provider: matchedNode.id, model: parsed.model };
|
if (matchedOpenAI) {
|
||||||
|
return { provider: matchedOpenAI.id, model: parsed.model };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Anthropic Compatible nodes
|
||||||
|
const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" });
|
||||||
|
const matchedAnthropic = anthropicNodes.find((node) => node.prefix === parsed.providerAlias);
|
||||||
|
if (matchedAnthropic) {
|
||||||
|
return { provider: matchedAnthropic.id, model: parsed.model };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user