Merge Pull Request #891

# Conflicts:
#	open-sse/handlers/chatCore.ts
This commit is contained in:
diegosouzapw
2026-04-01 18:05:44 -03:00
22 changed files with 1594 additions and 119 deletions

View File

@@ -9,6 +9,7 @@
*
* Header order and body field order were captured via mitmproxy traffic analysis.
*/
import { isClaudeCodeCompatible } from "../services/provider.ts";
export interface CliFingerprint {
/** Ordered list of header names (case-sensitive). Unlisted headers are appended. */
@@ -75,6 +76,43 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
],
userAgent: "claude-code",
},
"claude-code-compatible": {
headerOrder: [
"Host",
"Content-Type",
"x-api-key",
"anthropic-version",
"anthropic-beta",
"anthropic-dangerous-direct-browser-access",
"x-app",
"User-Agent",
"X-Claude-Code-Session-Id",
"X-Stainless-Retry-Count",
"X-Stainless-Timeout",
"X-Stainless-Lang",
"X-Stainless-Package-Version",
"X-Stainless-OS",
"X-Stainless-Arch",
"X-Stainless-Runtime",
"X-Stainless-Runtime-Version",
"Accept",
"accept-language",
"sec-fetch-mode",
"accept-encoding",
],
bodyFieldOrder: [
"model",
"messages",
"system",
"tools",
"metadata",
"max_tokens",
"thinking",
"context_management",
"output_config",
"stream",
],
},
github: {
headerOrder: [
"Host",
@@ -243,7 +281,10 @@ export function applyFingerprint(
headers: Record<string, string>,
body: unknown
): { headers: Record<string, string>; bodyString: string } {
const fingerprint = CLI_FINGERPRINTS[provider?.toLowerCase()];
const fingerprintKey = isClaudeCodeCompatible(provider)
? "claude-code-compatible"
: provider?.toLowerCase();
const fingerprint = CLI_FINGERPRINTS[fingerprintKey];
if (!fingerprint) {
return { headers, bodyString: JSON.stringify(body) };
@@ -300,6 +341,8 @@ export function getCliCompatProviders(): string[] {
* Reads from: 1) Runtime cache (Settings UI), 2) Environment variables.
*/
export function isCliCompatEnabled(provider: string): boolean {
if (isClaudeCodeCompatible(provider)) return true;
const key = provider?.toLowerCase().replace(/[^a-z0-9]/g, "_");
// 1. Check runtime cache (set via Settings UI)

View File

@@ -2,6 +2,12 @@ import { BaseExecutor } from "./base.ts";
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
import { getAccessToken } from "../services/tokenRefresh.ts";
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
import {
buildClaudeCodeCompatibleHeaders,
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
joinBaseUrlAndPath,
} from "../services/claudeCodeCompatible.ts";
import { isClaudeCodeCompatible } from "../services/provider.ts";
export class DefaultExecutor extends BaseExecutor {
constructor(provider) {
@@ -9,6 +15,9 @@ export class DefaultExecutor extends BaseExecutor {
}
buildUrl(model, stream, urlIndex = 0, credentials = null) {
void model;
void stream;
void urlIndex;
if (this.provider?.startsWith?.("openai-compatible-")) {
const psd = credentials?.providerSpecificData;
const baseUrl = psd?.baseUrl || "https://api.openai.com/v1";
@@ -21,8 +30,11 @@ export class DefaultExecutor extends BaseExecutor {
if (this.provider?.startsWith?.("anthropic-compatible-")) {
const psd = credentials?.providerSpecificData;
const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1";
const normalized = baseUrl.replace(/\/$/, "");
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);
}
const normalized = baseUrl.replace(/\/$/, "");
return `${normalized}${customPath || "/messages"}`;
}
switch (this.provider) {
@@ -70,6 +82,13 @@ export class DefaultExecutor extends BaseExecutor {
headers["x-api-key"] = effectiveKey || credentials.accessToken;
break;
default:
if (isClaudeCodeCompatible(this.provider)) {
return buildClaudeCodeCompatibleHeaders(
effectiveKey || credentials.accessToken || "",
stream,
credentials?.providerSpecificData?.ccSessionId
);
}
if (this.provider?.startsWith?.("anthropic-compatible-")) {
if (effectiveKey) {
headers["x-api-key"] = effectiveKey;

View File

@@ -92,6 +92,11 @@ import { generateRequestId } from "@/shared/utils/requestId";
import { normalizePayloadForLog } from "@/lib/logPayloads";
import { injectMemory, shouldInjectMemory } from "@/lib/memory/injection";
import { retrieveMemories } from "@/lib/memory/retrieval";
import {
buildClaudeCodeCompatibleRequest,
isClaudeCodeCompatibleProvider,
resolveClaudeCodeCompatibleSessionId,
} from "../services/claudeCodeCompatible.ts";
export function shouldUseNativeCodexPassthrough({
provider,
@@ -705,6 +710,8 @@ export async function handleChatCore({
// Translate request (pass reqLogger for intermediate logging)
let translatedBody = body;
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider);
let ccSessionId: string | null = null;
// Determine if we should preserve client-side cache_control headers
// Fetch settings from DB to get user preference
@@ -729,6 +736,46 @@ export async function handleChatCore({
if (nativeCodexPassthrough) {
translatedBody = { ...body, _nativeCodexPassthrough: true };
log?.debug?.("FORMAT", "native codex passthrough enabled");
} else if (isClaudeCodeCompatible) {
let normalizedForCc = { ...body };
// Claude Code-compatible providers expect Anthropic Messages-shaped payloads,
// but we extract only role/text/max_tokens/effort from an OpenAI-like view first.
if (sourceFormat !== FORMATS.OPENAI) {
const normalizeToolCallId = getModelNormalizeToolCallId(
provider || "",
model || "",
sourceFormat
);
const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole(
provider || "",
model || "",
sourceFormat
);
normalizedForCc = translateRequest(
sourceFormat,
FORMATS.OPENAI,
model,
{ ...body },
stream,
credentials,
provider,
reqLogger,
{ normalizeToolCallId, preserveDeveloperRole, preserveCacheControl }
);
}
ccSessionId = resolveClaudeCodeCompatibleSessionId(clientRawRequest?.headers);
translatedBody = buildClaudeCodeCompatibleRequest({
sourceBody: body,
normalizedBody: normalizedForCc,
model,
stream,
sessionId: ccSessionId,
cwd: process.cwd(),
now: new Date(),
});
log?.debug?.("FORMAT", "claude-code-compatible bridge enabled");
} else if (isClaudePassthrough && preserveCacheControl) {
// Pure passthrough: when preserveCacheControl is true, forward the body
// as-is without prior normalization. The OpenAI round-trip would strip
@@ -965,8 +1012,21 @@ export async function handleChatCore({
// Get executor for this provider
const executor = getExecutor(provider);
const getExecutionCredentials = () =>
nativeCodexPassthrough ? { ...credentials, requestEndpointPath: endpointPath } : credentials;
const getExecutionCredentials = () => {
const nextCredentials = nativeCodexPassthrough
? { ...credentials, requestEndpointPath: endpointPath }
: credentials;
if (!ccSessionId) return nextCredentials;
return {
...nextCredentials,
providerSpecificData: {
...(nextCredentials?.providerSpecificData || {}),
ccSessionId,
},
};
};
// Create stream controller for disconnect detection
const streamController = createStreamController({ onDisconnect, log, provider, model });

View File

@@ -0,0 +1,485 @@
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_MODELS_PATH = "/models";
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS = 8092;
export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION = "2023-06-01";
export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA =
"claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24";
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.89 (external, sdk-cli)";
export const CLAUDE_CODE_COMPATIBLE_BILLING_HEADER =
"x-anthropic-billing-header: cc_version=2.1.89.728; cc_entrypoint=sdk-cli; cch=00000;";
type HeaderLike =
| Headers
| Record<string, string | undefined>
| { get?: (name: string) => string | null }
| null
| undefined;
type MessageLike = {
role?: string;
content?: unknown;
};
type BuildRequestOptions = {
sourceBody?: Record<string, unknown> | null;
normalizedBody?: Record<string, unknown> | null;
model: string;
stream?: boolean;
cwd?: string;
now?: Date;
sessionId?: string | null;
};
export function isClaudeCodeCompatibleProvider(provider: string | null | undefined): boolean {
return typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX);
}
export function stripAnthropicMessagesSuffix(baseUrl: string | null | undefined): string {
const normalized = String(baseUrl || "")
.trim()
.replace(/\/$/, "");
if (!normalized) return "";
return normalized.replace(/\/messages(?:\?[^#]*)?$/i, "");
}
export function joinBaseUrlAndPath(baseUrl: string, path: string): string {
const normalizedBase = stripAnthropicMessagesSuffix(baseUrl).replace(/\/$/, "");
const normalizedPath = String(path || "").startsWith("/")
? String(path)
: `/${String(path || "")}`;
return `${normalizedBase}${normalizedPath}`;
}
export function buildClaudeCodeCompatibleHeaders(
apiKey: string,
stream = false,
sessionId?: string | null
): Record<string, string> {
return {
"Content-Type": "application/json",
Accept: stream ? "text/event-stream" : "application/json",
"x-api-key": apiKey,
"anthropic-version": CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION,
"anthropic-beta": CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA,
"anthropic-dangerous-direct-browser-access": "true",
"x-app": "cli",
"User-Agent": CLAUDE_CODE_COMPATIBLE_USER_AGENT,
"X-Stainless-Retry-Count": "0",
"X-Stainless-Timeout": "300",
"X-Stainless-Lang": "js",
"X-Stainless-Package-Version": "0.74.0",
"X-Stainless-OS": "MacOS",
"X-Stainless-Arch": "arm64",
"X-Stainless-Runtime": "node",
"X-Stainless-Runtime-Version": "v25.8.1",
"accept-language": "*",
"sec-fetch-mode": "cors",
"accept-encoding": "identity",
...(sessionId ? { "X-Claude-Code-Session-Id": sessionId } : {}),
};
}
export function buildClaudeCodeCompatibleValidationPayload(model = "claude-sonnet-4-6") {
const sessionId = randomUUID();
return buildClaudeCodeCompatibleRequest({
sourceBody: { max_tokens: 1 },
normalizedBody: {
messages: [{ role: "user", content: "ok" }],
max_tokens: 1,
},
model,
stream: false,
sessionId,
cwd: process.cwd(),
now: new Date(),
});
}
export function resolveClaudeCodeCompatibleSessionId(headers?: HeaderLike): string {
const raw =
getHeader(headers, "x-claude-code-session-id") ||
getHeader(headers, "x-session-id") ||
getHeader(headers, "x_session_id") ||
getHeader(headers, "x-omniroute-session") ||
null;
return (raw && raw.trim()) || randomUUID();
}
export function buildClaudeCodeCompatibleRequest({
sourceBody,
normalizedBody,
model,
stream = false,
cwd = process.cwd(),
now = new Date(),
sessionId,
}: BuildRequestOptions) {
const normalized = normalizedBody || {};
const messages = Array.isArray(normalized.messages)
? buildClaudeCodeCompatibleMessages(normalized.messages as MessageLike[])
: [];
const system = buildClaudeCodeCompatibleSystemBlocks(
normalized.messages as MessageLike[],
cwd,
now
);
const resolvedSessionId = sessionId || randomUUID();
const effort = resolveClaudeCodeCompatibleEffort(sourceBody, normalizedBody, model);
const maxTokens = resolveClaudeCodeCompatibleMaxTokens(sourceBody, normalizedBody);
const tools = buildClaudeCodeCompatibleTools(normalizedBody, sourceBody);
const toolChoice =
tools.length > 0
? buildClaudeCodeCompatibleToolChoice(
normalizedBody?.["tool_choice"] ?? sourceBody?.["tool_choice"]
)
: undefined;
return {
model,
messages,
system,
tools,
metadata: {
user_id: JSON.stringify({
device_id: createHash("sha256")
.update(String(cwd || ""))
.digest("hex")
.slice(0, 24),
account_uuid: "",
session_id: resolvedSessionId,
}),
},
max_tokens: maxTokens,
thinking: {
type: "adaptive",
},
context_management: {
edits: [
{
type: "clear_thinking_20251015",
keep: "all",
},
],
},
output_config: {
effort,
},
...(toolChoice ? { tool_choice: toolChoice } : {}),
...(stream ? { stream: true } : {}),
};
}
export function resolveClaudeCodeCompatibleEffort(
sourceBody?: Record<string, unknown> | null,
normalizedBody?: Record<string, unknown> | null,
model?: string | null
): "low" | "medium" | "high" {
const raw =
readNestedString(sourceBody, ["output_config", "effort"]) ||
readNestedString(sourceBody, ["reasoning", "effort"]) ||
toNonEmptyString(sourceBody?.["reasoning_effort"]) ||
readNestedString(normalizedBody, ["output_config", "effort"]) ||
readNestedString(normalizedBody, ["reasoning", "effort"]) ||
toNonEmptyString(normalizedBody?.["reasoning_effort"]) ||
"";
const normalizedEffort = raw.toLowerCase();
void model;
if (!normalizedEffort) return "high";
if (normalizedEffort === "low") return "low";
if (normalizedEffort === "medium") return "medium";
if (normalizedEffort === "high") return "high";
if (normalizedEffort === "none" || normalizedEffort === "disabled") return "low";
if (normalizedEffort === "max" || normalizedEffort === "xhigh") {
return "high";
}
return "high";
}
export function resolveClaudeCodeCompatibleMaxTokens(
sourceBody?: Record<string, unknown> | null,
normalizedBody?: Record<string, unknown> | null
): number {
const candidates = [
sourceBody?.["max_tokens"],
sourceBody?.["max_completion_tokens"],
sourceBody?.["max_output_tokens"],
normalizedBody?.["max_tokens"],
normalizedBody?.["max_completion_tokens"],
normalizedBody?.["max_output_tokens"],
];
for (const candidate of candidates) {
const value = Number(candidate);
if (Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
}
return CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS;
}
function buildClaudeCodeCompatibleMessages(messages: MessageLike[]) {
const converted = messages
.map((message) => convertClaudeCodeCompatibleMessage(message))
.filter(
(
message
): message is { role: "user" | "assistant"; content: Array<Record<string, unknown>> } =>
!!message && message.content.length > 0
);
const merged: Array<{ role: "user" | "assistant"; content: Array<Record<string, unknown>> }> = [];
for (const message of converted) {
const last = merged[merged.length - 1];
if (last && last.role === message.role) {
last.content.push(...message.content);
continue;
}
merged.push({ role: message.role, content: [...message.content] });
}
for (let i = merged.length - 1; i >= 0; i--) {
if (merged[i].role !== "user") continue;
const lastBlock = merged[i].content[merged[i].content.length - 1];
if (lastBlock) {
lastBlock.cache_control = { type: "ephemeral" };
}
break;
}
return merged;
}
function buildClaudeCodeCompatibleSystemBlocks(
messages: MessageLike[] | undefined,
cwd: string,
now: Date
) {
const customSystemBlocks = Array.isArray(messages)
? messages
.filter((message) => {
const role = String(message?.role || "").toLowerCase();
return role === "system" || role === "developer";
})
.map((message) => contentToText(message?.content))
.filter(Boolean)
: [];
const dateText = formatDate(now);
const blocks: Array<Record<string, unknown>> = [
{
type: "text",
text: CLAUDE_CODE_COMPATIBLE_BILLING_HEADER,
},
{
type: "text",
text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
cache_control: { type: "ephemeral" },
},
{
type: "text",
text: `You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${cwd}\nDate: ${dateText}`,
cache_control: { type: "ephemeral" },
},
];
for (const systemText of customSystemBlocks) {
blocks.push({
type: "text",
text: systemText,
cache_control: { type: "ephemeral" },
});
}
return blocks;
}
function convertClaudeCodeCompatibleMessage(message: MessageLike | null | undefined) {
const rawRole = String(message?.role || "").toLowerCase();
const role =
rawRole === "user"
? "user"
: rawRole === "assistant" || rawRole === "model"
? "assistant"
: null;
if (!role) return null;
const text = contentToText(message?.content);
if (!text) return null;
return {
role,
content: [{ type: "text", text }],
};
}
function buildClaudeCodeCompatibleTools(
normalizedBody?: Record<string, unknown> | null,
sourceBody?: Record<string, unknown> | null
) {
const rawTools = Array.isArray(normalizedBody?.["tools"])
? normalizedBody?.["tools"]
: Array.isArray(sourceBody?.["tools"])
? sourceBody?.["tools"]
: [];
return rawTools
.map((tool) => convertClaudeCodeCompatibleTool(tool))
.filter((tool): tool is Record<string, unknown> => !!tool);
}
function convertClaudeCodeCompatibleTool(tool: unknown) {
const rawTool = readRecord(tool);
if (!rawTool) return null;
const toolData =
rawTool.type === "function" ? readRecord(rawTool.function) || rawTool : rawTool;
const name = toNonEmptyString(toolData.name);
if (!name) return null;
const rawSchema =
readRecord(toolData.parameters) ||
readRecord(toolData.input_schema) || { type: "object", properties: {}, required: [] };
const inputSchema =
rawSchema.type === "object" && !readRecord(rawSchema.properties)
? { ...rawSchema, properties: {} }
: rawSchema;
const converted: Record<string, unknown> = {
name,
description: toNonEmptyString(toolData.description) || "",
input_schema: inputSchema,
};
if (typeof toolData.defer_loading === "boolean") {
converted.defer_loading = toolData.defer_loading;
}
return converted;
}
function buildClaudeCodeCompatibleToolChoice(choice: unknown) {
if (!choice) return null;
if (typeof choice === "string") {
if (choice === "required") return { type: "any" };
return null;
}
const rawChoice = readRecord(choice);
if (!rawChoice) return null;
if (rawChoice.type === "tool") {
const name = toNonEmptyString(rawChoice.name);
return name ? { type: "tool", name } : null;
}
if (rawChoice.type === "function") {
const functionName =
toNonEmptyString(readRecord(rawChoice.function)?.name) || toNonEmptyString(rawChoice.name);
return functionName ? { type: "tool", name: functionName } : null;
}
if (rawChoice.type === "required" || rawChoice.type === "any") {
return { type: "any" };
}
return null;
}
function contentToText(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (!part || typeof part !== "object") return "";
const record = part as Record<string, unknown>;
if (record.type === "text" && typeof record.text === "string") {
return record.text.trim();
}
if (typeof record.text === "string") {
return record.text.trim();
}
return "";
})
.filter(Boolean)
.join("\n")
.trim();
}
if (content && typeof content === "object") {
const record = content as Record<string, unknown>;
if (typeof record.text === "string") return record.text.trim();
}
return "";
}
function getHeader(headers: HeaderLike, name: string): string | null {
if (!headers) return null;
if (typeof (headers as Headers).get === "function") {
return (headers as Headers).get(name);
}
const record = headers as Record<string, string | undefined>;
const target = name.toLowerCase();
for (const [key, value] of Object.entries(record)) {
if (key.toLowerCase() === target) {
return value ?? null;
}
}
return null;
}
function formatDate(date: Date): string {
const formatter = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const parts = formatter.formatToParts(date);
const year = parts.find((part) => part.type === "year")?.value || "1970";
const month = parts.find((part) => part.type === "month")?.value || "01";
const day = parts.find((part) => part.type === "day")?.value || "01";
return `${year}-${month}-${day}`;
}
function toNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed || null;
}
function readRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readNestedString(
source: Record<string, unknown> | null | undefined,
path: string[]
): string | null {
let current: unknown = source;
for (const key of path) {
if (!current || typeof current !== "object" || Array.isArray(current)) {
return null;
}
current = (current as Record<string, unknown>)[key];
}
return toNonEmptyString(current);
}

View File

@@ -1,5 +1,11 @@
import { PROVIDERS } from "../config/constants.ts";
import { getRegistryEntry } from "../config/providerRegistry.ts";
import {
buildClaudeCodeCompatibleHeaders,
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
joinBaseUrlAndPath,
stripAnthropicMessagesSuffix,
} from "./claudeCodeCompatible.ts";
const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
const OPENAI_COMPATIBLE_DEFAULTS = {
@@ -7,6 +13,7 @@ const OPENAI_COMPATIBLE_DEFAULTS = {
};
const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-";
const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-";
const ANTHROPIC_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.anthropic.com/v1",
};
@@ -19,6 +26,10 @@ function isAnthropicCompatible(provider) {
return typeof provider === "string" && provider.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
}
export function isClaudeCodeCompatible(provider) {
return typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX);
}
function getOpenAICompatibleType(provider) {
if (!isOpenAICompatible(provider)) return "chat";
return provider.includes("responses") ? "responses" : "chat";
@@ -49,7 +60,10 @@ export function detectFormatFromEndpoint(body, endpointPath = "") {
return "claude";
}
if (/\/(?:chat\/completions|completions)(?=\/|$)/i.test(path) || /^(?:chat\/completions|completions)(?=\/|$)/i.test(path)) {
if (
/\/(?:chat\/completions|completions)(?=\/|$)/i.test(path) ||
/^(?:chat\/completions|completions)(?=\/|$)/i.test(path)
) {
return "openai";
}
@@ -190,6 +204,9 @@ 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 buildAnthropicCompatibleUrl(baseUrl);
}
@@ -220,6 +237,7 @@ export function buildProviderUrl(
// Build provider headers
export function buildProviderHeaders(provider, credentials, stream = true, body = null) {
void body;
const config = getProviderConfig(provider);
const entry = getRegistryEntry(provider);
const headers = {
@@ -229,6 +247,14 @@ export function buildProviderHeaders(provider, credentials, stream = true, body
// Add auth header
// Specific override for Anthropic Compatible
if (isClaudeCodeCompatible(provider)) {
const token = credentials.apiKey || credentials.accessToken || "";
return buildClaudeCodeCompatibleHeaders(
token,
stream,
credentials?.providerSpecificData?.ccSessionId
);
}
if (isAnthropicCompatible(provider)) {
if (credentials.apiKey) {
headers["x-api-key"] = credentials.apiKey;

View File

@@ -111,6 +111,21 @@ export function normalizeDeveloperRole(
});
}
export function normalizeModelRole(
messages: NormalizedMessage[] | unknown
): NormalizedMessage[] | unknown {
if (!Array.isArray(messages)) return messages;
return messages.map((msg: NormalizedMessage) => {
if (!msg || typeof msg !== "object") return msg;
const role = typeof msg.role === "string" ? msg.role : "";
if (role.toLowerCase() === "model") {
return { ...msg, role: "assistant" };
}
return msg;
});
}
/**
* Convert `system` messages to user messages for providers that don't support
* the system role. The system content is prepended to the first user message
@@ -196,7 +211,8 @@ export function normalizeRoles(
): NormalizedMessage[] | unknown {
if (!Array.isArray(messages)) return messages;
let result = normalizeDeveloperRole(messages, targetFormat, preserveDeveloperRole);
let result = normalizeModelRole(messages);
result = normalizeDeveloperRole(result, targetFormat, preserveDeveloperRole);
result = normalizeSystemRole(result, provider, model);
return result;

View File

@@ -29,6 +29,7 @@ import {
getProviderAlias,
isOpenAICompatibleProvider,
isAnthropicCompatibleProvider,
isClaudeCodeCompatibleProvider,
} from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
@@ -420,6 +421,7 @@ interface AddApiKeyModalProps {
providerName?: string;
isCompatible?: boolean;
isAnthropic?: boolean;
isCcCompatible?: boolean;
onSave: (data: {
name: string;
apiKey: string;
@@ -463,8 +465,14 @@ interface EditCompatibleNodeModalProps {
onSave: (data: unknown) => Promise<void>;
onClose: () => void;
isAnthropic?: boolean;
isCcCompatible?: boolean;
}
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_MODELS_PATH = "/models";
function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } {
const record =
policy && typeof policy === "object" && !Array.isArray(policy)
@@ -830,17 +838,33 @@ export default function ProviderDetailPage() {
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(null);
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
const isAnthropicCompatible =
isAnthropicCompatibleProvider(providerId) && !isClaudeCodeCompatibleProvider(providerId);
const isCompatible = isOpenAICompatible || isAnthropicCompatible || isCcCompatible;
const isAnthropicProtocolCompatible = isAnthropicCompatible || isCcCompatible;
const providerInfo = providerNode
? {
id: providerNode.id,
name:
providerNode.name ||
(providerNode.type === "anthropic-compatible"
? t("anthropicCompatibleName")
: t("openaiCompatibleName")),
color: providerNode.type === "anthropic-compatible" ? "#D97757" : "#10A37F",
textIcon: providerNode.type === "anthropic-compatible" ? "AC" : "OC",
(isCcCompatible
? CC_COMPATIBLE_LABEL
: providerNode.type === "anthropic-compatible"
? t("anthropicCompatibleName")
: t("openaiCompatibleName")),
color: isCcCompatible
? "#B45309"
: providerNode.type === "anthropic-compatible"
? "#D97757"
: "#10A37F",
textIcon: isCcCompatible
? "CC"
: providerNode.type === "anthropic-compatible"
? "AC"
: "OC",
apiType: providerNode.apiType,
baseUrl: providerNode.baseUrl,
type: providerNode.type,
@@ -851,10 +875,6 @@ export default function ProviderDetailPage() {
const isOAuth = !!(FREE_PROVIDERS as any)[providerId] || !!(OAUTH_PROVIDERS as any)[providerId];
const models = getModelsByProviderId(providerId);
const providerAlias = getProviderAlias(providerId);
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
const isCompatible = isOpenAICompatible || isAnthropicCompatible;
const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter";
const isSearchProvider = providerId.endsWith("-search");
@@ -1836,16 +1856,20 @@ export default function ProviderDetailPage() {
const description =
providerId === "openrouter"
? t("openRouterAnyModelHint")
: t("compatibleModelsDescription", {
type: isAnthropicCompatible ? t("anthropic") : t("openai"),
});
: isCcCompatible
? "CC Compatible provider models are routed through the Claude Code-compatible bridge."
: t("compatibleModelsDescription", {
type: isAnthropicCompatible ? t("anthropic") : t("openai"),
});
const inputLabel = providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId");
const inputPlaceholder =
providerId === "openrouter"
? t("openRouterModelPlaceholder")
: isAnthropicCompatible
? t("anthropicCompatibleModelPlaceholder")
: t("openaiCompatibleModelPlaceholder");
: isCcCompatible
? "claude-sonnet-4-6"
: isAnthropicCompatible
? t("anthropicCompatibleModelPlaceholder")
: t("openaiCompatibleModelPlaceholder");
return (
<div>
@@ -1866,7 +1890,7 @@ export default function ProviderDetailPage() {
onSetAlias={handleSetAlias}
onDeleteAlias={handleDeleteAlias}
connections={connections}
isAnthropic={isAnthropicCompatible}
isAnthropic={isAnthropicProtocolCompatible}
onImportWithProgress={handleCompatibleImportWithProgress}
t={t}
effectiveModelNormalize={effectiveModelNormalize}
@@ -1997,7 +2021,7 @@ export default function ProviderDetailPage() {
? "/providers/oai-r.png"
: "/providers/oai-cc.png";
}
if (isAnthropicCompatible) {
if (isAnthropicProtocolCompatible) {
return "/providers/anthropic-m.png";
}
return `/providers/${providerInfo.id}.png`;
@@ -2062,22 +2086,26 @@ export default function ProviderDetailPage() {
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">
{isAnthropicCompatible
? t("anthropicCompatibleDetails")
: t("openaiCompatibleDetails")}
{isCcCompatible
? CC_COMPATIBLE_DETAILS_TITLE
: isAnthropicCompatible
? t("anthropicCompatibleDetails")
: t("openaiCompatibleDetails")}
</h2>
<p className="text-sm text-text-muted">
{isAnthropicCompatible
{isAnthropicProtocolCompatible
? t("messagesApi")
: providerNode.apiType === "responses"
? t("responsesApi")
: t("chatCompletions")}{" "}
· {(providerNode.baseUrl || "").replace(/\/$/, "")}/
{isAnthropicCompatible
? t("messagesPath")
: providerNode.apiType === "responses"
? t("responsesPath")
: t("chatCompletionsPath")}
{isCcCompatible
? (providerNode.chatPath || CC_COMPATIBLE_DEFAULT_CHAT_PATH).replace(/^\//, "")
: isAnthropicCompatible
? (providerNode.chatPath || "/messages").replace(/^\//, "")
: providerNode.apiType === "responses"
? (providerNode.chatPath || "/responses").replace(/^\//, "")
: (providerNode.chatPath || "/chat/completions").replace(/^\//, "")}
</p>
</div>
<div className="flex items-center gap-2">
@@ -2105,7 +2133,11 @@ export default function ProviderDetailPage() {
if (
!confirm(
t("deleteCompatibleNodeConfirm", {
type: isAnthropicCompatible ? t("anthropic") : t("openai"),
type: isCcCompatible
? CC_COMPATIBLE_LABEL
: isAnthropicCompatible
? t("anthropic")
: t("openai"),
})
)
)
@@ -2466,7 +2498,8 @@ export default function ProviderDetailPage() {
provider={providerId}
providerName={providerInfo.name}
isCompatible={isCompatible}
isAnthropic={isAnthropicCompatible}
isAnthropic={isAnthropicProtocolCompatible}
isCcCompatible={isCcCompatible}
onSave={handleSaveApiKey}
onClose={() => setShowAddApiKeyModal(false)}
/>
@@ -2482,7 +2515,8 @@ export default function ProviderDetailPage() {
node={providerNode}
onSave={handleUpdateNode}
onClose={() => setShowEditNodeModal(false)}
isAnthropic={isAnthropicCompatible}
isAnthropic={isAnthropicProtocolCompatible}
isCcCompatible={isCcCompatible}
/>
)}
{/* Batch Test Results Modal */}
@@ -4332,6 +4366,7 @@ function AddApiKeyModal({
providerName,
isCompatible,
isAnthropic,
isCcCompatible,
onSave,
onClose,
}: AddApiKeyModalProps) {
@@ -4499,13 +4534,15 @@ function AddApiKeyModal({
)}
{isCompatible && (
<p className="text-xs text-text-muted">
{isAnthropic
? t("validationChecksAnthropicCompatible", {
provider: providerName || t("anthropicCompatibleName"),
})
: t("validationChecksOpenAiCompatible", {
provider: providerName || t("openaiCompatibleName"),
})}
{isCcCompatible
? "Validation uses the strict Claude Code-compatible bridge request for this provider."
: isAnthropic
? t("validationChecksAnthropicCompatible", {
provider: providerName || t("anthropicCompatibleName"),
})
: t("validationChecksOpenAiCompatible", {
provider: providerName || t("openaiCompatibleName"),
})}
</p>
)}
<Input
@@ -4580,6 +4617,7 @@ AddApiKeyModal.propTypes = {
providerName: PropTypes.string,
isCompatible: PropTypes.bool,
isAnthropic: PropTypes.bool,
isCcCompatible: PropTypes.bool,
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
@@ -4650,7 +4688,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
setValidationResult(null);
setSaveError(null);
}
}, [connection, isBailian]);
}, [connection, isBailian, isVertex]);
const handleTest = async () => {
if (!connection?.provider) return;
@@ -5041,6 +5079,7 @@ function EditCompatibleNodeModal({
onSave,
onClose,
isAnthropic,
isCcCompatible,
}: EditCompatibleNodeModalProps) {
const t = useTranslations("providers");
const [formData, setFormData] = useState({
@@ -5066,12 +5105,18 @@ function EditCompatibleNodeModal({
baseUrl:
node.baseUrl ||
(isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"),
chatPath: node.chatPath || "",
modelsPath: node.modelsPath || "",
chatPath: node.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""),
modelsPath: node.modelsPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_MODELS_PATH : ""),
});
setShowAdvanced(!!(node.chatPath || node.modelsPath));
setShowAdvanced(
!!(
node.chatPath ||
node.modelsPath ||
(isCcCompatible && !node.chatPath && !node.modelsPath)
)
);
}
}, [node, isAnthropic]);
}, [node, isAnthropic, isCcCompatible]);
const apiTypeOptions = [
{ value: "chat", label: t("chatCompletions") },
@@ -5086,8 +5131,9 @@ function EditCompatibleNodeModal({
name: formData.name,
prefix: formData.prefix,
baseUrl: formData.baseUrl,
chatPath: formData.chatPath || "",
modelsPath: formData.modelsPath || "",
chatPath: formData.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""),
modelsPath:
formData.modelsPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_MODELS_PATH : ""),
};
if (!isAnthropic) {
payload.apiType = formData.apiType;
@@ -5108,7 +5154,10 @@ function EditCompatibleNodeModal({
baseUrl: formData.baseUrl,
apiKey: checkKey,
type: isAnthropic ? "anthropic-compatible" : "openai-compatible",
modelsPath: formData.modelsPath || "",
compatMode: isCcCompatible ? "cc" : undefined,
chatPath: formData.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""),
modelsPath:
formData.modelsPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_MODELS_PATH : ""),
}),
});
const data = await res.json();
@@ -5125,25 +5174,39 @@ function EditCompatibleNodeModal({
return (
<Modal
isOpen={isOpen}
title={t("editCompatibleTitle", { type: isAnthropic ? t("anthropic") : t("openai") })}
title={
isCcCompatible
? CC_COMPATIBLE_DETAILS_TITLE
: t("editCompatibleTitle", { type: isAnthropic ? t("anthropic") : t("openai") })
}
onClose={onClose}
>
<div className="flex flex-col gap-4">
<Input
label={t("nameLabel")}
label={isCcCompatible ? "Name" : t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t("compatibleProdPlaceholder", {
type: isAnthropic ? t("anthropic") : t("openai"),
})}
hint={t("nameHint")}
placeholder={
isCcCompatible
? "CC Compatible Production"
: t("compatibleProdPlaceholder", {
type: isAnthropic ? t("anthropic") : t("openai"),
})
}
hint={isCcCompatible ? "Display name for this provider" : t("nameHint")}
/>
<Input
label={t("prefixLabel")}
label={isCcCompatible ? "Prefix" : t("prefixLabel")}
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder={isAnthropic ? t("anthropicPrefixPlaceholder") : t("openaiPrefixPlaceholder")}
hint={t("prefixHint")}
placeholder={
isCcCompatible
? "cc"
: isAnthropic
? t("anthropicPrefixPlaceholder")
: t("openaiPrefixPlaceholder")
}
hint={isCcCompatible ? "Used for aliases such as prefix/model-id" : t("prefixHint")}
/>
{!isAnthropic && (
<Select
@@ -5154,15 +5217,23 @@ function EditCompatibleNodeModal({
/>
)}
<Input
label={t("baseUrlLabel")}
label={isCcCompatible ? "Base URL" : t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={
isAnthropic ? t("anthropicBaseUrlPlaceholder") : t("openaiBaseUrlPlaceholder")
isCcCompatible
? "https://example.com/v1"
: isAnthropic
? t("anthropicBaseUrlPlaceholder")
: t("openaiBaseUrlPlaceholder")
}
hint={
isCcCompatible
? "Base URL for the CC-compatible site. Do not include /messages."
: t("compatibleBaseUrlHint", {
type: isAnthropic ? t("anthropic") : t("openai"),
})
}
hint={t("compatibleBaseUrlHint", {
type: isAnthropic ? t("anthropic") : t("openai"),
})}
/>
<button
type="button"
@@ -5182,18 +5253,30 @@ function EditCompatibleNodeModal({
{showAdvanced && (
<div id="advanced-settings" className="flex flex-col gap-3 pl-2 border-l-2 border-border">
<Input
label={t("chatPathLabel")}
label={isCcCompatible ? "Chat Path" : t("chatPathLabel")}
value={formData.chatPath}
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
placeholder={isAnthropic ? "/messages" : t("chatPathPlaceholder")}
hint={t("chatPathHint")}
placeholder={
isCcCompatible
? CC_COMPATIBLE_DEFAULT_CHAT_PATH
: isAnthropic
? "/messages"
: t("chatPathPlaceholder")
}
hint={
isCcCompatible
? "Defaults to the strict Claude Code-compatible messages path"
: t("chatPathHint")
}
/>
<Input
label={t("modelsPathLabel")}
label={isCcCompatible ? "Models Path" : t("modelsPathLabel")}
value={formData.modelsPath}
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
placeholder={t("modelsPathPlaceholder")}
hint={t("modelsPathHint")}
placeholder={
isCcCompatible ? CC_COMPATIBLE_DEFAULT_MODELS_PATH : t("modelsPathPlaceholder")
}
hint={isCcCompatible ? "Defaults to /models" : t("modelsPathHint")}
/>
</div>
)}
@@ -5253,4 +5336,5 @@ EditCompatibleNodeModal.propTypes = {
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
isAnthropic: PropTypes.bool,
isCcCompatible: PropTypes.bool,
};

View File

@@ -17,8 +17,9 @@ import {
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import {
FREE_PROVIDERS,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
isAnthropicCompatibleProvider,
isClaudeCodeCompatibleProvider,
isOpenAICompatibleProvider,
} from "@/shared/constants/providers";
import Link from "next/link";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
@@ -26,6 +27,11 @@ import { useNotificationStore } from "@/store/notificationStore";
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
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_MODELS_PATH = "/models";
// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard
function getStatusDisplay(connected, error, errorCode, t) {
const parts = [];
@@ -95,10 +101,12 @@ function getConnectionErrorTag(connection) {
export default function ProvidersPage() {
const [connections, setConnections] = useState<any[]>([]);
const [providerNodes, setProviderNodes] = useState<any[]>([]);
const [ccCompatibleProviderEnabled, setCcCompatibleProviderEnabled] = useState(false);
const [expirations, setExpirations] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
const [showAddCcCompatibleModal, setShowAddCcCompatibleModal] = useState(false);
const [testingMode, setTestingMode] = useState<string | null>(null);
const [testResults, setTestResults] = useState<any>(null);
const [importingZed, setImportingZed] = useState(false);
@@ -118,7 +126,10 @@ export default function ProvidersPage() {
const nodesData = await nodesRes.json();
const expirationsData = await expirationsRes.json();
if (connectionsRes.ok) setConnections(connectionsData.connections || []);
if (nodesRes.ok) setProviderNodes(nodesData.nodes || []);
if (nodesRes.ok) {
setProviderNodes(nodesData.nodes || []);
setCcCompatibleProviderEnabled(nodesData.ccCompatibleProviderEnabled === true);
}
if (expirationsRes.ok && expirationsData) setExpirations(expirationsData);
} catch (error) {
console.log("Error fetching data:", error);
@@ -283,7 +294,9 @@ export default function ProvidersPage() {
}));
const anthropicCompatibleProviders = providerNodes
.filter((node) => node.type === "anthropic-compatible")
.filter(
(node) => node.type === "anthropic-compatible" && !isClaudeCodeCompatibleProvider(node.id)
)
.map((node) => ({
id: node.id,
name: node.name || t("anthropicCompatibleName"),
@@ -291,6 +304,17 @@ export default function ProvidersPage() {
textIcon: "AC",
}));
const ccCompatibleProviders = providerNodes
.filter(
(node) => node.type === "anthropic-compatible" && isClaudeCodeCompatibleProvider(node.id)
)
.map((node) => ({
id: node.id,
name: node.name || CC_COMPATIBLE_LABEL,
color: "#B45309",
textIcon: "CC",
}));
if (loading) {
return (
<div className="flex flex-col gap-8">
@@ -474,7 +498,9 @@ export default function ProvidersPage() {
<span className="size-2.5 rounded-full bg-orange-500" title={t("compatibleLabel")} />
</h2>
<div className="flex flex-wrap gap-2">
{(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
{(compatibleProviders.length > 0 ||
anthropicCompatibleProviders.length > 0 ||
ccCompatibleProviders.length > 0) && (
<button
onClick={() => handleBatchTest("compatible")}
disabled={!!testingMode}
@@ -491,6 +517,16 @@ export default function ProvidersPage() {
{testingMode === "compatible" ? t("testing") : t("testAll")}
</button>
)}
{ccCompatibleProviderEnabled && (
<Button
size="sm"
variant="secondary"
icon="add"
onClick={() => setShowAddCcCompatibleModal(true)}
>
{ADD_CC_COMPATIBLE_LABEL}
</Button>
)}
<Button size="sm" icon="add" onClick={() => setShowAddAnthropicCompatibleModal(true)}>
{t("addAnthropicCompatible")}
</Button>
@@ -505,7 +541,9 @@ export default function ProvidersPage() {
</Button>
</div>
</div>
{compatibleProviders.length === 0 && anthropicCompatibleProviders.length === 0 ? (
{compatibleProviders.length === 0 &&
anthropicCompatibleProviders.length === 0 &&
ccCompatibleProviders.length === 0 ? (
<div className="text-center py-8 border border-dashed border-border rounded-xl">
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
extension
@@ -515,7 +553,11 @@ export default function ProvidersPage() {
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{[...compatibleProviders, ...anthropicCompatibleProviders].map((info) => (
{[
...compatibleProviders,
...anthropicCompatibleProviders,
...ccCompatibleProviders,
].map((info) => (
<ApiKeyProviderCard
key={info.id}
providerId={info.id}
@@ -544,6 +586,16 @@ export default function ProvidersPage() {
setShowAddAnthropicCompatibleModal(false);
}}
/>
{ccCompatibleProviderEnabled && (
<AddCcCompatibleModal
isOpen={showAddCcCompatibleModal}
onClose={() => setShowAddCcCompatibleModal(false)}
onCreated={(node) => {
setProviderNodes((prev) => [...prev, node]);
setShowAddCcCompatibleModal(false);
}}
/>
)}
{/* Test Results Modal */}
{testResults && (
<div
@@ -695,8 +747,10 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
const t = useTranslations("providers");
const tc = useTranslations("common");
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
const isCompatible = isOpenAICompatibleProvider(providerId);
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
const isAnthropicCompatible =
isAnthropicCompatibleProvider(providerId) && !isClaudeCodeCompatibleProvider(providerId);
const dotColors = {
free: "bg-green-500",
@@ -717,7 +771,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
if (isCompatible) {
return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
}
if (isAnthropicCompatible) return "/providers/anthropic-m.png";
if (isAnthropicCompatible || isCcCompatible) return "/providers/anthropic-m.png";
return null; // ProviderIcon will handle it
})();
@@ -781,6 +835,11 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
{provider.apiType === "responses" ? t("responses") : t("chat")}
</Badge>
)}
{isCcCompatible && (
<Badge variant="default" size="sm">
CC
</Badge>
)}
{isAnthropicCompatible && (
<Badge variant="default" size="sm">
{t("messages")}
@@ -1232,6 +1291,200 @@ AddAnthropicCompatibleModal.propTypes = {
onCreated: PropTypes.func.isRequired,
};
function AddCcCompatibleModal({ isOpen, onClose, onCreated }) {
const [formData, setFormData] = useState({
name: "",
prefix: "",
baseUrl: "https://api.anthropic.com/v1",
chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH,
modelsPath: CC_COMPATIBLE_DEFAULT_MODELS_PATH,
});
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
useEffect(() => {
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",
compatMode: "cc",
chatPath: formData.chatPath || CC_COMPATIBLE_DEFAULT_CHAT_PATH,
modelsPath: formData.modelsPath || CC_COMPATIBLE_DEFAULT_MODELS_PATH,
}),
});
const data = await res.json();
if (res.ok) {
onCreated(data.node);
setFormData({
name: "",
prefix: "",
baseUrl: "https://api.anthropic.com/v1",
chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH,
modelsPath: CC_COMPATIBLE_DEFAULT_MODELS_PATH,
});
setCheckKey("");
setValidationResult(null);
setShowAdvanced(false);
}
} catch (error) {
console.log("Error creating CC 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",
compatMode: "cc",
chatPath: formData.chatPath || CC_COMPATIBLE_DEFAULT_CHAT_PATH,
modelsPath: formData.modelsPath || CC_COMPATIBLE_DEFAULT_MODELS_PATH,
}),
});
const data = await res.json();
setValidationResult(data.valid ? "success" : "failed");
} catch {
setValidationResult("failed");
} finally {
setValidating(false);
}
};
return (
<Modal isOpen={isOpen} title={ADD_CC_COMPATIBLE_LABEL} onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="CC Compatible Production"
hint="Display name for this Claude Code-compatible provider"
/>
<Input
label="Prefix"
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder="cc"
hint="Used for model aliases such as prefix/model-id"
/>
<Input
label="Base URL"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://example.com/v1"
hint="Base URL for the CC-compatible site. Do not include /messages."
/>
<button
type="button"
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
onClick={() => setShowAdvanced(!showAdvanced)}
aria-expanded={showAdvanced}
aria-controls="advanced-settings-cc"
>
<span
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
aria-hidden="true"
>
</span>
Advanced settings
</button>
{showAdvanced && (
<div
id="advanced-settings-cc"
className="flex flex-col gap-3 pl-2 border-l-2 border-border"
>
<Input
label="Chat Path"
value={formData.chatPath}
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
placeholder={CC_COMPATIBLE_DEFAULT_CHAT_PATH}
hint="Defaults to the strict Claude Code-compatible messages path"
/>
<Input
label="Models Path"
value={formData.modelsPath}
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
placeholder={CC_COMPATIBLE_DEFAULT_MODELS_PATH}
hint="Defaults to /models"
/>
</div>
)}
<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..." : "Add"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
);
}
AddCcCompatibleModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onCreated: PropTypes.func.isRequired,
};
// ─── Provider Test Results View (mirrors combo TestResultsView) ──────────────
function ProviderTestResultsView({ results }) {

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import {
AI_PROVIDERS,
CLAUDE_CODE_COMPATIBLE_PREFIX,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
@@ -39,6 +40,8 @@ export function useProviderOptions(initialProvider = "openai") {
const info = (AI_PROVIDERS as any)[pid as string];
const node: any = nodeMap.get(pid);
let label = info?.name || node?.name || pid;
if (!info && (pid as string).startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX))
label = node?.name || "CC Compatible";
if (!info && (pid as string).startsWith(OPENAI_COMPATIBLE_PREFIX))
label = node?.name || t("openaiCompatibleLabel");
if (!info && (pid as string).startsWith(ANTHROPIC_COMPATIBLE_PREFIX))

View File

@@ -59,9 +59,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
// 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
}
sanitizedBaseUrl = sanitizedBaseUrl.replace(/\/messages(?:\?[^#]*)?$/i, "");
}
const updates: Record<string, unknown> = {

View File

@@ -3,8 +3,10 @@ import { createProviderNode, getProviderNodes } from "@/models";
import {
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
CLAUDE_CODE_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
import { generateId } from "@/shared/utils";
import { isCcCompatibleProviderEnabled } from "@/shared/utils/featureFlags";
import { createProviderNodeSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -16,11 +18,21 @@ const ANTHROPIC_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.anthropic.com/v1",
};
function sanitizeAnthropicBaseUrl(baseUrl: string) {
return (baseUrl || "")
.trim()
.replace(/\/$/, "")
.replace(/\/messages(?:\?[^#]*)?$/i, "");
}
// GET /api/provider-nodes - List all provider nodes
export async function GET() {
try {
const nodes = await getProviderNodes();
return NextResponse.json({ nodes });
return NextResponse.json({
nodes,
ccCompatibleProviderEnabled: isCcCompatibleProviderEnabled(),
});
} catch (error) {
console.log("Error fetching provider nodes:", error);
return NextResponse.json({ error: "Failed to fetch provider nodes" }, { status: 500 });
@@ -49,7 +61,8 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { name, prefix, apiType, baseUrl, type, chatPath, modelsPath } = validation.data;
const { name, prefix, apiType, baseUrl, type, compatMode, chatPath, modelsPath } =
validation.data;
// Determine type
const nodeType = type || "openai-compatible";
@@ -69,17 +82,19 @@ export async function POST(request) {
}
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
if (compatMode === "cc" && !isCcCompatibleProviderEnabled()) {
return NextResponse.json({ error: "CC Compatible provider is disabled" }, { status: 403 });
}
const sanitizedBaseUrl = sanitizeAnthropicBaseUrl(
baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl
);
const node = await createProviderNode({
id: `${ANTHROPIC_COMPATIBLE_PREFIX}${generateId()}`,
id:
compatMode === "cc"
? `${CLAUDE_CODE_COMPATIBLE_PREFIX}${generateId()}`
: `${ANTHROPIC_COMPATIBLE_PREFIX}${generateId()}`,
type: "anthropic-compatible",
prefix: prefix.trim(),
baseUrl: sanitizedBaseUrl,

View File

@@ -1,7 +1,16 @@
import { NextResponse } from "next/server";
import { validateClaudeCodeCompatibleProvider } from "@/lib/providers/validation";
import { isCcCompatibleProviderEnabled } from "@/shared/utils/featureFlags";
import { providerNodeValidateSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
function sanitizeAnthropicBaseUrl(baseUrl: string) {
return (baseUrl || "")
.trim()
.replace(/\/$/, "")
.replace(/\/messages(?:\?[^#]*)?$/i, "");
}
// POST /api/provider-nodes/validate - Validate API key against base URL
export async function POST(request) {
let rawBody;
@@ -24,16 +33,38 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, type, modelsPath } = validation.data;
const { baseUrl, apiKey, type, compatMode, chatPath, modelsPath } = validation.data;
// 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
if (compatMode === "cc") {
if (!isCcCompatibleProviderEnabled()) {
return NextResponse.json(
{ valid: false, error: "CC Compatible provider is disabled" },
{ status: 403 }
);
}
const result = await validateClaudeCodeCompatibleProvider({
apiKey,
providerSpecificData: {
baseUrl: sanitizeAnthropicBaseUrl(baseUrl),
chatPath: chatPath || undefined,
modelsPath: modelsPath || undefined,
},
});
return NextResponse.json({
valid: !!result.valid,
error: result.valid ? null : result.error || "Invalid API key",
warning: result.warning || null,
method: result.method || null,
});
}
// Robustly construct URL: remove trailing slash, and remove trailing /messages if user added it
const normalizedBase = sanitizeAnthropicBaseUrl(baseUrl);
// Use /models endpoint for validation as many compatible providers support it (like OpenAI)
const modelsUrl = `${normalizedBase}${modelsPath || "/models"}`;

View File

@@ -7,6 +7,7 @@ import {
} from "@/models";
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
import {
isClaudeCodeCompatibleProvider,
isOpenAICompatibleProvider,
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
@@ -97,7 +98,14 @@ export async function POST(request: Request) {
} else if (isAnthropicCompatibleProvider(provider)) {
const node: any = await getProviderNodeById(provider);
if (!node) {
return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 });
return NextResponse.json(
{
error: isClaudeCodeCompatibleProvider(provider)
? "CC Compatible node not found"
: "Anthropic Compatible node not found",
},
{ status: 404 }
);
}
const existingConnections = await getProviderConnections({ provider });

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { getProviderNodeById } from "@/models";
import {
isClaudeCodeCompatibleProvider,
isOpenAICompatibleProvider,
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
@@ -37,7 +38,11 @@ export async function POST(request) {
if (isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider)) {
const node: any = await getProviderNodeById(provider);
if (!node) {
const typeName = isOpenAICompatibleProvider(provider) ? "OpenAI" : "Anthropic";
const typeName = isOpenAICompatibleProvider(provider)
? "OpenAI"
: isClaudeCodeCompatibleProvider(provider)
? "CC"
: "Anthropic";
return NextResponse.json(
{ error: `${typeName} Compatible node not found` },
{ status: 404 }
@@ -47,6 +52,8 @@ export async function POST(request) {
...providerSpecificData,
baseUrl: node.baseUrl,
apiType: node.apiType,
chatPath: node.chatPath,
modelsPath: node.modelsPath,
};
}

View File

@@ -65,5 +65,9 @@ export function getProviderDisplayName(
);
if (match) return `Compatible (${match[1]})`;
if (/^anthropic-compatible-cc-[0-9a-f-]{10,}$/i.test(providerId)) {
return "CC Compatible";
}
return providerId;
}

View File

@@ -1,5 +1,14 @@
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
import {
buildClaudeCodeCompatibleHeaders,
buildClaudeCodeCompatibleValidationPayload,
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH,
joinBaseUrlAndPath,
stripAnthropicMessagesSuffix,
} from "@omniroute/open-sse/services/claudeCodeCompatible.ts";
import {
isClaudeCodeCompatibleProvider,
isAnthropicCompatibleProvider,
isOpenAICompatibleProvider,
} from "@/shared/constants/providers";
@@ -11,6 +20,10 @@ function normalizeBaseUrl(baseUrl: string) {
return (baseUrl || "").trim().replace(/\/$/, "");
}
function normalizeAnthropicBaseUrl(baseUrl: string) {
return stripAnthropicMessagesSuffix(baseUrl || "");
}
function addModelsSuffix(baseUrl: string) {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) return "";
@@ -36,6 +49,9 @@ function resolveChatUrl(provider: string, baseUrl: string, providerSpecificData:
if (!normalized) return "";
if (isOpenAICompatibleProvider(provider)) {
if (providerSpecificData?.chatPath) {
return `${normalized}${providerSpecificData.chatPath}`;
}
if (providerSpecificData?.apiType === "responses") {
return `${normalized}/responses`;
}
@@ -496,15 +512,11 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData =
}
async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificData = {} }: any) {
let baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
let baseUrl = normalizeAnthropicBaseUrl(providerSpecificData.baseUrl);
if (!baseUrl) {
return { valid: false, error: "No base URL configured for Anthropic compatible provider" };
}
if (baseUrl.endsWith("/messages")) {
baseUrl = baseUrl.slice(0, -9);
}
const headers = {
"Content-Type": "application/json",
"x-api-key": apiKey,
@@ -514,10 +526,13 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat
// Step 1: Try GET /models
try {
const modelsRes = await fetch(`${baseUrl}/models`, {
method: "GET",
headers,
});
const modelsRes = await fetch(
joinBaseUrlAndPath(baseUrl, providerSpecificData?.modelsPath || "/models"),
{
method: "GET",
headers,
}
);
if (modelsRes.ok) {
return { valid: true, error: null };
@@ -533,15 +548,18 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat
// Step 2: Fallback — try a minimal messages request
const testModelId = providerSpecificData?.validationModelId || "claude-3-5-sonnet-20241022";
try {
const messagesRes = await fetch(`${baseUrl}/messages`, {
method: "POST",
headers,
body: JSON.stringify({
model: testModelId,
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
});
const messagesRes = await fetch(
joinBaseUrlAndPath(baseUrl, providerSpecificData?.chatPath || "/messages"),
{
method: "POST",
headers,
body: JSON.stringify({
model: testModelId,
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
}
);
if (messagesRes.status === 401 || messagesRes.status === 403) {
return { valid: false, error: "Invalid API key" };
@@ -554,6 +572,80 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat
}
}
export async function validateClaudeCodeCompatibleProvider({
apiKey,
providerSpecificData = {},
}: any) {
const baseUrl = normalizeAnthropicBaseUrl(providerSpecificData.baseUrl);
if (!baseUrl) {
return { valid: false, error: "No base URL configured for CC Compatible provider" };
}
const modelsPath = providerSpecificData?.modelsPath || CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH;
const chatPath = providerSpecificData?.chatPath || CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH;
const defaultHeaders = buildClaudeCodeCompatibleHeaders(apiKey, false);
try {
const modelsRes = await fetch(joinBaseUrlAndPath(baseUrl, modelsPath), {
method: "GET",
headers: defaultHeaders,
});
if (modelsRes.ok) {
return { valid: true, error: null, method: "models_endpoint" };
}
if (modelsRes.status === 401 || modelsRes.status === 403) {
return { valid: false, error: "Invalid API key" };
}
} catch {
// Fall through to bridge request validation.
}
const payload = buildClaudeCodeCompatibleValidationPayload(
providerSpecificData?.validationModelId || "claude-sonnet-4-6"
);
const sessionId = JSON.parse(payload.metadata.user_id).session_id;
try {
const messagesRes = await fetch(joinBaseUrlAndPath(baseUrl, chatPath), {
method: "POST",
headers: buildClaudeCodeCompatibleHeaders(apiKey, false, sessionId),
body: JSON.stringify(payload),
});
if (messagesRes.status === 401 || messagesRes.status === 403) {
return { valid: false, error: "Invalid API key" };
}
if (messagesRes.status === 429) {
return {
valid: true,
error: null,
method: "cc_bridge_request",
warning: "Rate limited, but credentials are valid",
};
}
if (messagesRes.status >= 400 && messagesRes.status < 500) {
return {
valid: true,
error: null,
method: "cc_bridge_request",
warning: "Bridge request reached upstream, but the model or payload was rejected",
};
}
return {
valid: messagesRes.ok,
error: messagesRes.ok ? null : `Validation failed: ${messagesRes.status}`,
method: "cc_bridge_request",
};
} catch (error: any) {
return { valid: false, error: error.message || "Connection failed" };
}
}
// ── Search provider validators (factored) ──
async function validateSearchProvider(
@@ -638,6 +730,9 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
if (isAnthropicCompatibleProvider(provider)) {
try {
if (isClaudeCodeCompatibleProvider(provider)) {
return await validateClaudeCodeCompatibleProvider({ apiKey, providerSpecificData });
}
return await validateAnthropicCompatibleProvider({ apiKey, providerSpecificData });
} catch (error: any) {
return { valid: false, error: error.message || "Validation failed", unsupported: false };

View File

@@ -14,6 +14,7 @@ import {
OAUTH_PROVIDERS,
APIKEY_PROVIDERS,
FREE_PROVIDERS,
CLAUDE_CODE_COMPATIBLE_PREFIX,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
@@ -45,6 +46,17 @@ function usePageInfo(pathname: string | null): {
};
}
if (providerId.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX)) {
return {
title: "CC Compatible",
description: "",
breadcrumbs: [
{ label: t("providers"), href: "/dashboard/providers" },
{ label: "CC Compatible", providerId: "claude" },
],
};
}
if (providerId.startsWith(OPENAI_COMPATIBLE_PREFIX)) {
return {
title: t("openaiCompatible"),

View File

@@ -10,7 +10,11 @@ export {
getModelsByProviderId,
} from "@omniroute/open-sse/config/providerModels.ts";
import { AI_PROVIDERS, isOpenAICompatibleProvider } from "./providers";
import {
AI_PROVIDERS,
isAnthropicCompatibleProvider,
isOpenAICompatibleProvider,
} from "./providers";
import { PROVIDER_MODELS as MODELS } from "@omniroute/open-sse/config/providerModels.ts";
// Providers that accept any model (passthrough)
@@ -23,6 +27,7 @@ const PASSTHROUGH_PROVIDERS = new Set(
// Wrap isValidModel with passthrough providers
export function isValidModel(aliasOrId, modelId) {
if (isOpenAICompatibleProvider(aliasOrId)) return true;
if (isAnthropicCompatibleProvider(aliasOrId)) return true;
if (PASSTHROUGH_PROVIDERS.has(aliasOrId)) return true;
const models = MODELS[aliasOrId];
if (!models) return false;

View File

@@ -601,6 +601,7 @@ export const APIKEY_PROVIDERS = {
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
export const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-";
export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-";
export function isOpenAICompatibleProvider(providerId) {
return typeof providerId === "string" && providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
@@ -610,6 +611,10 @@ export function isAnthropicCompatibleProvider(providerId) {
return typeof providerId === "string" && providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
}
export function isClaudeCodeCompatibleProvider(providerId) {
return typeof providerId === "string" && providerId.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX);
}
// All providers (combined)
export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };

View File

@@ -0,0 +1,3 @@
export function isCcCompatibleProviderEnabled() {
return process.env.ENABLE_CC_COMPATIBLE_PROVIDER === "true";
}

View File

@@ -966,6 +966,7 @@ export const createProviderNodeSchema = z
apiType: z.enum(["chat", "responses"]).optional(),
baseUrl: z.string().trim().min(1).optional(),
type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
compatMode: z.enum(["cc"]).optional(),
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
})
@@ -993,6 +994,8 @@ export const providerNodeValidateSchema = z.object({
baseUrl: z.string().trim().min(1, "Base URL and API key required"),
apiKey: z.string().trim().min(1, "Base URL and API key required"),
type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
compatMode: z.enum(["cc"]).optional(),
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
});

View File

@@ -0,0 +1,300 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cc-compatible-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { DefaultExecutor } = await import("../../open-sse/executors/default.ts");
const {
buildClaudeCodeCompatibleRequest,
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS,
CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH,
} = 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");
const providerNodesValidateRoute =
await import("../../src/app/api/provider-nodes/validate/route.ts");
const originalFetch = globalThis.fetch;
const originalFlag = process.env.ENABLE_CC_COMPATIBLE_PROVIDER;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.afterEach(async () => {
globalThis.fetch = originalFetch;
if (originalFlag === undefined) {
delete process.env.ENABLE_CC_COMPATIBLE_PROVIDER;
} else {
process.env.ENABLE_CC_COMPATIBLE_PROVIDER = originalFlag;
}
await resetStorage();
});
test.after(() => {
globalThis.fetch = originalFetch;
if (originalFlag === undefined) {
delete process.env.ENABLE_CC_COMPATIBLE_PROVIDER;
} else {
process.env.ENABLE_CC_COMPATIBLE_PROVIDER = originalFlag;
}
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("buildClaudeCodeCompatibleRequest keeps order/text while mapping unsupported roles", () => {
const payload = buildClaudeCodeCompatibleRequest({
sourceBody: {
reasoning_effort: "xhigh",
tool_choice: "required",
},
normalizedBody: {
tools: [
{
type: "function",
function: {
name: "lookup_weather",
description: "Fetch weather",
parameters: {
type: "object",
properties: {
city: { type: "string" },
},
required: ["city"],
},
},
},
],
messages: [
{ role: "system", content: "sys" },
{ role: "user", content: [{ type: "text", text: "u1" }, { type: "image_url" }] },
{ role: "model", content: "a1" },
{ role: "user", content: [{ type: "text", text: "u2" }, { type: "tool_result" }] },
],
},
model: "claude-sonnet-4-6",
cwd: "/tmp/work",
now: new Date("2026-04-01T12:00:00.000Z"),
sessionId: "session-1",
});
assert.equal(payload.max_tokens, CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS);
assert.equal(payload.output_config.effort, "high");
assert.deepEqual(
payload.messages.map((message) => ({
role: message.role,
text: message.content.map((block) => block.text).join("\n"),
})),
[
{ role: "user", text: "u1" },
{ role: "assistant", text: "a1" },
{ role: "user", text: "u2" },
]
);
assert.deepEqual(payload.messages.at(-1).content.at(-1).cache_control, { type: "ephemeral" });
assert.equal(payload.system.length, 4);
assert.equal(payload.system.at(-1).text, "sys");
assert.equal(payload.tools.length, 1);
assert.deepEqual(payload.tools[0], {
name: "lookup_weather",
description: "Fetch weather",
input_schema: {
type: "object",
properties: {
city: { type: "string" },
},
required: ["city"],
},
});
assert.deepEqual(payload.tool_choice, { type: "any" });
assert.equal(payload.context_management.edits[0].type, "clear_thinking_20251015");
assert.equal(JSON.parse(payload.metadata.user_id).session_id, "session-1");
});
test("buildClaudeCodeCompatibleRequest honors token priority fields", () => {
const payload = buildClaudeCodeCompatibleRequest({
sourceBody: { max_completion_tokens: 321 },
normalizedBody: {
max_tokens: 123,
max_output_tokens: 456,
messages: [{ role: "user", content: "hi" }],
},
model: "claude-sonnet-4-6",
sessionId: "session-2",
});
assert.equal(payload.max_tokens, 321);
assert.deepEqual(payload.tools, []);
assert.equal(payload.tool_choice, undefined);
});
test("buildClaudeCodeCompatibleRequest omits auto tool_choice while preserving tools", () => {
const payload = buildClaudeCodeCompatibleRequest({
sourceBody: { tool_choice: "auto" },
normalizedBody: {
messages: [{ role: "user", content: "hi" }],
tools: [
{
type: "function",
function: {
name: "ping",
parameters: { type: "object" },
},
},
],
},
model: "claude-sonnet-4-6",
sessionId: "session-4",
});
assert.equal(payload.tools.length, 1);
assert.equal(payload.tool_choice, undefined);
});
test("DefaultExecutor uses CC-compatible path and headers", () => {
const executor = new DefaultExecutor("anthropic-compatible-cc-test");
const credentials = {
apiKey: "sk-test",
providerSpecificData: {
baseUrl: "https://proxy.example.com/v1/",
chatPath: "",
ccSessionId: "session-3",
},
};
assert.equal(
executor.buildUrl("claude-sonnet-4-6", true, 0, credentials),
`https://proxy.example.com/v1${CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH}`
);
const headers = executor.buildHeaders(credentials, true);
assert.equal(headers["x-api-key"], "sk-test");
assert.equal(headers["X-Claude-Code-Session-Id"], "session-3");
assert.equal(headers.Accept, "text/event-stream");
assert.equal(headers.Authorization, undefined);
});
test("validateProviderApiKey uses CC skeleton request after /models fallback", async () => {
const calls = [];
globalThis.fetch = async (url, init = {}) => {
calls.push({
url: String(url),
method: init.method || "GET",
headers: init.headers,
body: init.body ? JSON.parse(String(init.body)) : null,
});
if (String(url).endsWith(CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH)) {
return new Response(JSON.stringify({ error: "missing models" }), { status: 500 });
}
return new Response(JSON.stringify({ error: "bad model" }), { status: 400 });
};
const result = await validateProviderApiKey({
provider: "anthropic-compatible-cc-test",
apiKey: "sk-test",
providerSpecificData: {
baseUrl: "https://proxy.example.com/v1/messages?beta=true",
validationModelId: "claude-sonnet-4-6",
},
});
assert.equal(result.valid, true);
assert.equal(result.method, "cc_bridge_request");
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",
]
);
assert.equal(calls[1].body.model, "claude-sonnet-4-6");
assert.equal(calls[1].body.messages[0].role, "user");
assert.equal(calls[1].headers["x-api-key"], "sk-test");
});
test("provider-nodes create route rejects CC mode when feature flag is disabled", async () => {
delete process.env.ENABLE_CC_COMPATIBLE_PROVIDER;
const response = await providerNodesRoute.POST(
new Request("http://localhost/api/provider-nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Hidden CC",
prefix: "cc",
baseUrl: "https://proxy.example.com/v1",
type: "anthropic-compatible",
compatMode: "cc",
}),
})
);
assert.equal(response.status, 403);
});
test("provider-nodes create route creates CC node with dedicated prefix when enabled", async () => {
process.env.ENABLE_CC_COMPATIBLE_PROVIDER = "true";
const response = await providerNodesRoute.POST(
new Request("http://localhost/api/provider-nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Hidden CC",
prefix: "cc",
baseUrl: "https://proxy.example.com/v1/messages?beta=true",
type: "anthropic-compatible",
compatMode: "cc",
chatPath: CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
modelsPath: CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH,
}),
})
);
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.chatPath, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH);
assert.equal(data.node.modelsPath, CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH);
});
test("provider-nodes validate route rejects CC mode when feature flag is disabled", async () => {
delete process.env.ENABLE_CC_COMPATIBLE_PROVIDER;
const response = await providerNodesValidateRoute.POST(
new Request("http://localhost/api/provider-nodes/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: "https://proxy.example.com/v1",
apiKey: "sk-test",
type: "anthropic-compatible",
compatMode: "cc",
}),
})
);
assert.equal(response.status, 403);
});
test("provider-nodes list route exposes CC flag state from server env", async () => {
process.env.ENABLE_CC_COMPATIBLE_PROVIDER = "true";
const response = await providerNodesRoute.GET();
assert.equal(response.status, 200);
const data = await response.json();
assert.equal(data.ccCompatibleProviderEnabled, true);
});