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:
ramhaidar
2026-02-03 15:11:41 +07:00
parent 7881db81ec
commit da5bdef4cb
13 changed files with 614 additions and 154 deletions
+23 -7
View File
@@ -21,7 +21,8 @@ export async function PUT(request, { params }) {
return NextResponse.json({ error: "Prefix is required" }, { status: 400 });
}
if (!apiType || !["chat", "responses"].includes(apiType)) {
// Only validate apiType for OpenAI Compatible nodes
if (node.type === "openai-compatible" && (!apiType || !["chat", "responses"].includes(apiType))) {
return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 });
}
@@ -29,12 +30,27 @@ export async function PUT(request, { params }) {
return NextResponse.json({ error: "Base URL is required" }, { status: 400 });
}
const updated = await updateProviderNode(id, {
let sanitizedBaseUrl = baseUrl.trim();
// Sanitize Base URL for Anthropic Compatible
if (node.type === "anthropic-compatible") {
sanitizedBaseUrl = sanitizedBaseUrl.replace(/\/$/, "");
if (sanitizedBaseUrl.endsWith("/messages")) {
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages
}
}
const updates = {
name: name.trim(),
prefix: prefix.trim(),
apiType,
baseUrl: baseUrl.trim(),
});
baseUrl: sanitizedBaseUrl,
};
if (node.type === "openai-compatible") {
updates.apiType = apiType;
}
const updated = await updateProviderNode(id, updates);
const connections = await getProviderConnections({ provider: id });
await Promise.all(connections.map((connection) => (
@@ -42,8 +58,8 @@ export async function PUT(request, { params }) {
providerSpecificData: {
...(connection.providerSpecificData || {}),
prefix: prefix.trim(),
apiType,
baseUrl: baseUrl.trim(),
apiType: node.type === "openai-compatible" ? apiType : undefined,
baseUrl: sanitizedBaseUrl,
nodeName: updated.name,
}
})
+41 -13
View File
@@ -1,11 +1,15 @@
import { NextResponse } from "next/server";
import { createProviderNode, getProviderNodes } from "@/models";
import { OPENAI_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
const OPENAI_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.openai.com/v1",
};
const ANTHROPIC_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.anthropic.com/v1",
};
// GET /api/provider-nodes - List all provider nodes
export async function GET() {
try {
@@ -21,7 +25,7 @@ export async function GET() {
export async function POST(request) {
try {
const body = await request.json();
const { name, prefix, apiType, baseUrl } = body;
const { name, prefix, apiType, baseUrl, type } = body;
if (!name?.trim()) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
@@ -31,20 +35,44 @@ export async function POST(request) {
return NextResponse.json({ error: "Prefix is required" }, { status: 400 });
}
if (!apiType || !["chat", "responses"].includes(apiType)) {
return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 });
// Determine type
const nodeType = type || "openai-compatible";
if (nodeType === "openai-compatible") {
if (!apiType || !["chat", "responses"].includes(apiType)) {
return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 });
}
const node = await createProviderNode({
id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${crypto.randomUUID()}`,
type: "openai-compatible",
prefix: prefix.trim(),
apiType,
baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(),
name: name.trim(),
});
return NextResponse.json({ node }, { status: 201 });
}
const node = await createProviderNode({
id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${crypto.randomUUID()}`,
type: "openai-compatible",
prefix: prefix.trim(),
apiType,
baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(),
name: name.trim(),
});
if (nodeType === "anthropic-compatible") {
// Sanitize Base URL: remove trailing slash, and remove trailing /messages if user added it
// This prevents double-appending /messages at runtime
let sanitizedBaseUrl = (baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl).trim().replace(/\/$/, "");
if (sanitizedBaseUrl.endsWith("/messages")) {
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages
}
return NextResponse.json({ node }, { status: 201 });
const node = await createProviderNode({
id: `${ANTHROPIC_COMPATIBLE_PREFIX}${crypto.randomUUID()}`,
type: "anthropic-compatible",
prefix: prefix.trim(),
baseUrl: sanitizedBaseUrl,
name: name.trim(),
});
return NextResponse.json({ node }, { status: 201 });
}
return NextResponse.json({ error: "Invalid provider node type" }, { status: 400 });
} catch (error) {
console.log("Error creating provider node:", error);
return NextResponse.json({ error: "Failed to create provider node" }, { status: 500 });
+27 -3
View File
@@ -1,15 +1,39 @@
import { NextResponse } from "next/server";
// POST /api/provider-nodes/validate - Validate API key against base URL /models
// POST /api/provider-nodes/validate - Validate API key against base URL
export async function POST(request) {
try {
const body = await request.json();
const { baseUrl, apiKey } = body;
const { baseUrl, apiKey, type } = body;
if (!baseUrl || !apiKey) {
return NextResponse.json({ error: "Base URL and API key required" }, { status: 400 });
}
// Anthropic Compatible Validation
if (type === "anthropic-compatible") {
// Robustly construct URL: remove trailing slash, and remove trailing /messages if user added it
let normalizedBase = baseUrl.trim().replace(/\/$/, "");
if (normalizedBase.endsWith("/messages")) {
normalizedBase = normalizedBase.slice(0, -9); // remove /messages
}
// Use /models endpoint for validation as many compatible providers support it (like OpenAI)
const modelsUrl = `${normalizedBase}/models`;
const res = await fetch(modelsUrl, {
method: "GET",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"Authorization": `Bearer ${apiKey}` // Add Bearer token for hybrid proxies
}
});
return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" });
}
// OpenAI Compatible Validation (Default)
const modelsUrl = `${baseUrl.replace(/\/$/, "")}/models`;
const res = await fetch(modelsUrl, {
headers: { "Authorization": `Bearer ${apiKey}` },
@@ -17,7 +41,7 @@ export async function POST(request) {
return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" });
} catch (error) {
console.log("Error validating OpenAI compatible base URL:", error);
console.log("Error validating provider node:", error);
return NextResponse.json({ error: "Validation failed" }, { status: 500 });
}
}