diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 249f3253e0..25b08115fd 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -5,7 +5,7 @@ import { getRotatingApiKey } from "../services/apiKeyRotator.ts"; import { buildClaudeCodeCompatibleHeaders, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH, - joinBaseUrlAndPath, + joinClaudeCodeCompatibleUrl, } from "../services/claudeCodeCompatible.ts"; import { isClaudeCodeCompatible } from "../services/provider.ts"; @@ -32,7 +32,10 @@ export class DefaultExecutor extends BaseExecutor { const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1"; const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null; if (isClaudeCodeCompatible(this.provider)) { - return joinBaseUrlAndPath(baseUrl, customPath || CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH); + return joinClaudeCodeCompatibleUrl( + baseUrl, + customPath || CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH + ); } const normalized = baseUrl.replace(/\/$/, ""); return `${normalized}${customPath || "/messages"}`; diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 5ccf4828aa..5ebf6e909e 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-"; -export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/messages?beta=true"; +export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH = "/models"; export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS = 8092; export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION = "2023-06-01"; @@ -45,14 +45,39 @@ export function stripAnthropicMessagesSuffix(baseUrl: string | null | undefined) return normalized.replace(/\/messages(?:\?[^#]*)?$/i, ""); } -export function joinBaseUrlAndPath(baseUrl: string, path: string): string { - const normalizedBase = stripAnthropicMessagesSuffix(baseUrl).replace(/\/$/, ""); +export function stripClaudeCodeCompatibleEndpointSuffix( + baseUrl: string | null | undefined +): string { + const normalized = String(baseUrl || "") + .trim() + .replace(/\/$/, ""); + if (!normalized) return ""; + return normalized.replace(/\/(?:v\d+\/)?messages(?:\?[^#]*)?$/i, ""); +} + +function joinNormalizedBaseUrlAndPath(baseUrl: string, path: string): string { + const normalizedBase = String(baseUrl || "").replace(/\/$/, ""); const normalizedPath = String(path || "").startsWith("/") ? String(path) : `/${String(path || "")}`; + const versionMatch = normalizedBase.match(/(\/v\d+)$/i); + if ( + versionMatch && + normalizedPath.toLowerCase().startsWith(`${versionMatch[1].toLowerCase()}/`) + ) { + return `${normalizedBase}${normalizedPath.slice(versionMatch[1].length)}`; + } return `${normalizedBase}${normalizedPath}`; } +export function joinBaseUrlAndPath(baseUrl: string, path: string): string { + return joinNormalizedBaseUrlAndPath(stripAnthropicMessagesSuffix(baseUrl), path); +} + +export function joinClaudeCodeCompatibleUrl(baseUrl: string, path: string): string { + return joinNormalizedBaseUrlAndPath(stripClaudeCodeCompatibleEndpointSuffix(baseUrl), path); +} + export function buildClaudeCodeCompatibleHeaders( apiKey: string, stream = false, @@ -340,14 +365,12 @@ function convertClaudeCodeCompatibleTool(tool: unknown) { const rawTool = readRecord(tool); if (!rawTool) return null; - const toolData = - rawTool.type === "function" ? readRecord(rawTool.function) || rawTool : rawTool; + const toolData = rawTool.type === "function" ? readRecord(rawTool.function) || rawTool : rawTool; const name = toNonEmptyString(toolData.name); if (!name) return null; - const rawSchema = - readRecord(toolData.parameters) || + const rawSchema = readRecord(toolData.parameters) || readRecord(toolData.input_schema) || { type: "object", properties: {}, required: [] }; const inputSchema = rawSchema.type === "object" && !readRecord(rawSchema.properties) diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index 217ac38d3e..584ea713f3 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -3,8 +3,7 @@ import { getRegistryEntry } from "../config/providerRegistry.ts"; import { buildClaudeCodeCompatibleHeaders, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH, - joinBaseUrlAndPath, - stripAnthropicMessagesSuffix, + joinClaudeCodeCompatibleUrl, } from "./claudeCodeCompatible.ts"; const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; @@ -205,7 +204,7 @@ export function buildProviderUrl( if (isAnthropicCompatible(provider)) { const baseUrl = options?.baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl; if (isClaudeCodeCompatible(provider)) { - return joinBaseUrlAndPath(baseUrl, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH); + return joinClaudeCodeCompatibleUrl(baseUrl, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH); } return buildAnthropicCompatibleUrl(baseUrl); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index ec58d4c4f4..e941a11b93 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -470,7 +470,7 @@ interface EditCompatibleNodeModalProps { const CC_COMPATIBLE_LABEL = "CC Compatible"; const CC_COMPATIBLE_DETAILS_TITLE = "CC Compatible Details"; -const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/messages?beta=true"; +const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; const CC_COMPATIBLE_DEFAULT_MODELS_PATH = "/models"; function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } { @@ -5104,7 +5104,11 @@ function EditCompatibleNodeModal({ apiType: node.apiType || "chat", baseUrl: node.baseUrl || - (isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"), + (isCcCompatible + ? "https://api.anthropic.com" + : isAnthropic + ? "https://api.anthropic.com/v1" + : "https://api.openai.com/v1"), chatPath: node.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""), modelsPath: node.modelsPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_MODELS_PATH : ""), }); diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 2aaf2fd2ed..48a9381d9f 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -29,7 +29,7 @@ import { useTranslations } from "next-intl"; const CC_COMPATIBLE_LABEL = "CC Compatible"; const ADD_CC_COMPATIBLE_LABEL = "Add CC Compatible"; -const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/messages?beta=true"; +const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; const CC_COMPATIBLE_DEFAULT_MODELS_PATH = "/models"; // Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard @@ -1295,7 +1295,7 @@ function AddCcCompatibleModal({ isOpen, onClose, onCreated }) { const [formData, setFormData] = useState({ name: "", prefix: "", - baseUrl: "https://api.anthropic.com/v1", + baseUrl: "https://api.anthropic.com", chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH, modelsPath: CC_COMPATIBLE_DEFAULT_MODELS_PATH, }); @@ -1335,7 +1335,7 @@ function AddCcCompatibleModal({ isOpen, onClose, onCreated }) { setFormData({ name: "", prefix: "", - baseUrl: "https://api.anthropic.com/v1", + baseUrl: "https://api.anthropic.com", chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH, modelsPath: CC_COMPATIBLE_DEFAULT_MODELS_PATH, }); diff --git a/src/app/api/provider-nodes/[id]/route.ts b/src/app/api/provider-nodes/[id]/route.ts index 5c44c42c2e..c930d3b246 100644 --- a/src/app/api/provider-nodes/[id]/route.ts +++ b/src/app/api/provider-nodes/[id]/route.ts @@ -7,6 +7,7 @@ import { updateProviderConnection, updateProviderNode, } from "@/models"; +import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers"; import { updateProviderNodeSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; @@ -16,6 +17,20 @@ function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } +function sanitizeAnthropicBaseUrl(baseUrl: string) { + return (baseUrl || "") + .trim() + .replace(/\/$/, "") + .replace(/\/messages(?:\?[^#]*)?$/i, ""); +} + +function sanitizeClaudeCodeCompatibleBaseUrl(baseUrl: string) { + return (baseUrl || "") + .trim() + .replace(/\/$/, "") + .replace(/\/(?:v\d+\/)?messages(?:\?[^#]*)?$/i, ""); +} + // PUT /api/provider-nodes/[id] - Update provider node export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { let rawBody; @@ -58,8 +73,9 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: // Sanitize Base URL for Anthropic Compatible if (node.type === "anthropic-compatible") { - sanitizedBaseUrl = sanitizedBaseUrl.replace(/\/$/, ""); - sanitizedBaseUrl = sanitizedBaseUrl.replace(/\/messages(?:\?[^#]*)?$/i, ""); + sanitizedBaseUrl = isClaudeCodeCompatibleProvider(id) + ? sanitizeClaudeCodeCompatibleBaseUrl(sanitizedBaseUrl) + : sanitizeAnthropicBaseUrl(sanitizedBaseUrl); } const updates: Record = { diff --git a/src/app/api/provider-nodes/route.ts b/src/app/api/provider-nodes/route.ts index f8978e7ad2..e436335645 100644 --- a/src/app/api/provider-nodes/route.ts +++ b/src/app/api/provider-nodes/route.ts @@ -25,6 +25,13 @@ function sanitizeAnthropicBaseUrl(baseUrl: string) { .replace(/\/messages(?:\?[^#]*)?$/i, ""); } +function sanitizeClaudeCodeCompatibleBaseUrl(baseUrl: string) { + return (baseUrl || "") + .trim() + .replace(/\/$/, "") + .replace(/\/(?:v\d+\/)?messages(?:\?[^#]*)?$/i, ""); +} + // GET /api/provider-nodes - List all provider nodes export async function GET() { try { @@ -86,9 +93,11 @@ export async function POST(request) { return NextResponse.json({ error: "CC Compatible provider is disabled" }, { status: 403 }); } - const sanitizedBaseUrl = sanitizeAnthropicBaseUrl( - baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl - ); + const rawBaseUrl = baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl; + const sanitizedBaseUrl = + compatMode === "cc" + ? sanitizeClaudeCodeCompatibleBaseUrl(rawBaseUrl) + : sanitizeAnthropicBaseUrl(rawBaseUrl); const node = await createProviderNode({ id: diff --git a/src/app/api/provider-nodes/validate/route.ts b/src/app/api/provider-nodes/validate/route.ts index dd41b1624a..5b96a4c8a2 100644 --- a/src/app/api/provider-nodes/validate/route.ts +++ b/src/app/api/provider-nodes/validate/route.ts @@ -11,6 +11,13 @@ function sanitizeAnthropicBaseUrl(baseUrl: string) { .replace(/\/messages(?:\?[^#]*)?$/i, ""); } +function sanitizeClaudeCodeCompatibleBaseUrl(baseUrl: string) { + return (baseUrl || "") + .trim() + .replace(/\/$/, "") + .replace(/\/(?:v\d+\/)?messages(?:\?[^#]*)?$/i, ""); +} + // POST /api/provider-nodes/validate - Validate API key against base URL export async function POST(request) { let rawBody; @@ -48,7 +55,7 @@ export async function POST(request) { const result = await validateClaudeCodeCompatibleProvider({ apiKey, providerSpecificData: { - baseUrl: sanitizeAnthropicBaseUrl(baseUrl), + baseUrl: sanitizeClaudeCodeCompatibleBaseUrl(baseUrl), chatPath: chatPath || undefined, modelsPath: modelsPath || undefined, }, diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 51fbe5f33a..4f273e8204 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -4,7 +4,9 @@ import { buildClaudeCodeCompatibleValidationPayload, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH, CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH, + joinClaudeCodeCompatibleUrl, joinBaseUrlAndPath, + stripClaudeCodeCompatibleEndpointSuffix, stripAnthropicMessagesSuffix, } from "@omniroute/open-sse/services/claudeCodeCompatible.ts"; import { @@ -24,6 +26,10 @@ function normalizeAnthropicBaseUrl(baseUrl: string) { return stripAnthropicMessagesSuffix(baseUrl || ""); } +function normalizeClaudeCodeCompatibleBaseUrl(baseUrl: string) { + return stripClaudeCodeCompatibleEndpointSuffix(baseUrl || ""); +} + function addModelsSuffix(baseUrl: string) { const normalized = normalizeBaseUrl(baseUrl); if (!normalized) return ""; @@ -576,7 +582,7 @@ export async function validateClaudeCodeCompatibleProvider({ apiKey, providerSpecificData = {}, }: any) { - const baseUrl = normalizeAnthropicBaseUrl(providerSpecificData.baseUrl); + const baseUrl = normalizeClaudeCodeCompatibleBaseUrl(providerSpecificData.baseUrl); if (!baseUrl) { return { valid: false, error: "No base URL configured for CC Compatible provider" }; } @@ -586,7 +592,7 @@ export async function validateClaudeCodeCompatibleProvider({ const defaultHeaders = buildClaudeCodeCompatibleHeaders(apiKey, false); try { - const modelsRes = await fetch(joinBaseUrlAndPath(baseUrl, modelsPath), { + const modelsRes = await fetch(joinClaudeCodeCompatibleUrl(baseUrl, modelsPath), { method: "GET", headers: defaultHeaders, }); @@ -608,7 +614,7 @@ export async function validateClaudeCodeCompatibleProvider({ const sessionId = JSON.parse(payload.metadata.user_id).session_id; try { - const messagesRes = await fetch(joinBaseUrlAndPath(baseUrl, chatPath), { + const messagesRes = await fetch(joinClaudeCodeCompatibleUrl(baseUrl, chatPath), { method: "POST", headers: buildClaudeCodeCompatibleHeaders(apiKey, false, sessionId), body: JSON.stringify(payload), diff --git a/tests/unit/cc-compatible-provider.test.mjs b/tests/unit/cc-compatible-provider.test.mjs index ea915d4279..e4c487abfb 100644 --- a/tests/unit/cc-compatible-provider.test.mjs +++ b/tests/unit/cc-compatible-provider.test.mjs @@ -14,6 +14,7 @@ const { CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH, CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS, CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH, + joinClaudeCodeCompatibleUrl, } = await import("../../open-sse/services/claudeCodeCompatible.ts"); const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts"); @@ -159,6 +160,30 @@ test("buildClaudeCodeCompatibleRequest omits auto tool_choice while preserving t assert.equal(payload.tool_choice, undefined); }); +test("joinClaudeCodeCompatibleUrl preserves a single /v1 segment for CC paths", () => { + assert.equal( + joinClaudeCodeCompatibleUrl( + "https://proxy.example.com", + CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH + ), + "https://proxy.example.com/v1/messages?beta=true" + ); + assert.equal( + joinClaudeCodeCompatibleUrl( + "https://proxy.example.com/v1", + CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH + ), + "https://proxy.example.com/v1/messages?beta=true" + ); + assert.equal( + joinClaudeCodeCompatibleUrl( + "https://proxy.example.com/v1/messages?beta=true", + CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH + ), + "https://proxy.example.com/v1/messages?beta=true" + ); +}); + test("DefaultExecutor uses CC-compatible path and headers", () => { const executor = new DefaultExecutor("anthropic-compatible-cc-test"); const credentials = { @@ -172,7 +197,7 @@ test("DefaultExecutor uses CC-compatible path and headers", () => { assert.equal( executor.buildUrl("claude-sonnet-4-6", true, 0, credentials), - `https://proxy.example.com/v1${CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH}` + "https://proxy.example.com/v1/messages?beta=true" ); const headers = executor.buildHeaders(credentials, true); @@ -213,10 +238,7 @@ test("validateProviderApiKey uses CC skeleton request after /models fallback", a assert.match(result.warning, /reached upstream/i); assert.deepEqual( calls.map((call) => `${call.method} ${call.url}`), - [ - "GET https://proxy.example.com/v1/models", - "POST https://proxy.example.com/v1/messages?beta=true", - ] + ["GET https://proxy.example.com/models", "POST https://proxy.example.com/v1/messages?beta=true"] ); assert.equal(calls[1].body.model, "claude-sonnet-4-6"); assert.equal(calls[1].body.messages[0].role, "user"); @@ -265,7 +287,7 @@ test("provider-nodes create route creates CC node with dedicated prefix when ena assert.equal(response.status, 201); const data = await response.json(); assert.match(data.node.id, /^anthropic-compatible-cc-/); - assert.equal(data.node.baseUrl, "https://proxy.example.com/v1"); + assert.equal(data.node.baseUrl, "https://proxy.example.com"); assert.equal(data.node.chatPath, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH); assert.equal(data.node.modelsPath, CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH); });