fix: repair cc compatible v1 route handling

This commit is contained in:
R.D.
2026-04-01 19:47:34 -04:00
committed by diegosouzapw
parent a07e643020
commit 33297e0226
10 changed files with 121 additions and 32 deletions

View File

@@ -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"}`;

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -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 : ""),
});

View File

@@ -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,
});

View File

@@ -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<string, unknown> = {

View File

@@ -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:

View File

@@ -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,
},

View File

@@ -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),

View File

@@ -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);
});