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:
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user