From 190f02a93910efda09833cc1d15d20cce1ba503e Mon Sep 17 00:00:00 2001 From: "R.D." Date: Wed, 1 Apr 2026 04:35:16 -0400 Subject: [PATCH 1/3] feat: add hidden Claude Code compatible provider --- open-sse/config/cliFingerprints.ts | 45 +- open-sse/executors/default.ts | 21 +- open-sse/handlers/chatCore.ts | 64 ++- open-sse/services/claudeCodeCompatible.ts | 396 ++++++++++++++++++ open-sse/services/provider.ts | 28 +- open-sse/services/roleNormalizer.ts | 18 +- .../dashboard/providers/[id]/page.tsx | 212 +++++++--- .../(dashboard)/dashboard/providers/page.tsx | 268 +++++++++++- .../translator/hooks/useProviderOptions.tsx | 3 + src/app/api/provider-nodes/[id]/route.ts | 4 +- src/app/api/provider-nodes/route.ts | 30 +- src/app/api/provider-nodes/validate/route.ts | 41 +- src/app/api/providers/route.ts | 10 +- src/app/api/providers/validate/route.ts | 9 +- src/lib/display/names.ts | 4 + src/lib/providers/validation.ts | 131 +++++- src/shared/components/Header.tsx | 12 + src/shared/constants/models.ts | 7 +- src/shared/constants/providers.ts | 5 + src/shared/utils/featureFlags.ts | 5 + src/shared/validation/schemas.ts | 3 + tests/unit/cc-compatible-provider.test.mjs | 236 +++++++++++ 22 files changed, 1435 insertions(+), 117 deletions(-) create mode 100644 open-sse/services/claudeCodeCompatible.ts create mode 100644 src/shared/utils/featureFlags.ts create mode 100644 tests/unit/cc-compatible-provider.test.mjs diff --git a/open-sse/config/cliFingerprints.ts b/open-sse/config/cliFingerprints.ts index 87e76c173b..3a18d2af3d 100644 --- a/open-sse/config/cliFingerprints.ts +++ b/open-sse/config/cliFingerprints.ts @@ -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 = { ], 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, body: unknown ): { headers: Record; 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) diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index d3727083f8..b9b52e64e3 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -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; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d8a46133d3..108b0a65a5 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -90,6 +90,11 @@ import { import { resolveStreamFlag, stripMarkdownCodeFence } from "../utils/aiSdkCompat.ts"; import { generateRequestId } from "@/shared/utils/requestId"; import { normalizePayloadForLog } from "@/lib/logPayloads"; +import { + buildClaudeCodeCompatibleRequest, + isClaudeCodeCompatibleProvider, + resolveClaudeCodeCompatibleSessionId, +} from "../services/claudeCodeCompatible.ts"; export function shouldUseNativeCodexPassthrough({ provider, @@ -686,6 +691,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 @@ -710,6 +717,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 any normalization. The OpenAI round-trip would strip @@ -946,8 +993,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 }); diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts new file mode 100644 index 0000000000..d0d2cbde01 --- /dev/null +++ b/open-sse/services/claudeCodeCompatible.ts @@ -0,0 +1,396 @@ +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 + | { get?: (name: string) => string | null } + | null + | undefined; + +type MessageLike = { + role?: string; + content?: unknown; +}; + +type BuildRequestOptions = { + sourceBody?: Record | null; + normalizedBody?: Record | 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 { + 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); + + 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, + }, + ...(stream ? { stream: true } : {}), + }; +} + +export function resolveClaudeCodeCompatibleEffort( + sourceBody?: Record | null, + normalizedBody?: Record | 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 | null, + normalizedBody?: Record | 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> } => + !!message && message.content.length > 0 + ); + + const merged: Array<{ role: "user" | "assistant"; content: Array> }> = []; + + 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> = [ + { + 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 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; + 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; + 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; + 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 readNestedString( + source: Record | 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)[key]; + } + return toNonEmptyString(current); +} diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index 9e9742d3a8..217ac38d3e 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -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; diff --git a/open-sse/services/roleNormalizer.ts b/open-sse/services/roleNormalizer.ts index b3d7ebdc3d..8a946496ce 100644 --- a/open-sse/services/roleNormalizer.ts +++ b/open-sse/services/roleNormalizer.ts @@ -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; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 2b8e405427..ec58d4c4f4 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -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; 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(null); const [applyingCodexAuthId, setApplyingCodexAuthId] = useState(null); const [exportingCodexAuthId, setExportingCodexAuthId] = useState(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 (
@@ -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() {

- {isAnthropicCompatible - ? t("anthropicCompatibleDetails") - : t("openaiCompatibleDetails")} + {isCcCompatible + ? CC_COMPATIBLE_DETAILS_TITLE + : isAnthropicCompatible + ? t("anthropicCompatibleDetails") + : t("openaiCompatibleDetails")}

- {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(/^\//, "")}

@@ -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 && (

- {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"), + })}

)} { 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 (
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")} /> 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 && ( 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"), - })} />
)} @@ -5253,4 +5336,5 @@ EditCompatibleNodeModal.propTypes = { onSave: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, isAnthropic: PropTypes.bool, + isCcCompatible: PropTypes.bool, }; diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 7ea88f92ef..bd0bc0d86d 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -17,15 +17,22 @@ 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 { CC_COMPATIBLE_PROVIDER_ENABLED } from "@/shared/utils/featureFlags"; import Link from "next/link"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; 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 = []; @@ -99,6 +106,7 @@ export default function ProvidersPage() { const [loading, setLoading] = useState(true); const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false); const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false); + const [showAddCcCompatibleModal, setShowAddCcCompatibleModal] = useState(false); const [testingMode, setTestingMode] = useState(null); const [testResults, setTestResults] = useState(null); const [importingZed, setImportingZed] = useState(false); @@ -283,7 +291,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 +301,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 (
@@ -474,7 +495,9 @@ export default function ProvidersPage() {
- {(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && ( + {(compatibleProviders.length > 0 || + anthropicCompatibleProviders.length > 0 || + ccCompatibleProviders.length > 0) && ( )} + {CC_COMPATIBLE_PROVIDER_ENABLED && ( + + )} @@ -505,7 +538,9 @@ export default function ProvidersPage() {
- {compatibleProviders.length === 0 && anthropicCompatibleProviders.length === 0 ? ( + {compatibleProviders.length === 0 && + anthropicCompatibleProviders.length === 0 && + ccCompatibleProviders.length === 0 ? (
extension @@ -515,7 +550,11 @@ export default function ProvidersPage() {
) : (
- {[...compatibleProviders, ...anthropicCompatibleProviders].map((info) => ( + {[ + ...compatibleProviders, + ...anthropicCompatibleProviders, + ...ccCompatibleProviders, + ].map((info) => ( + {CC_COMPATIBLE_PROVIDER_ENABLED && ( + setShowAddCcCompatibleModal(false)} + onCreated={(node) => { + setProviderNodes((prev) => [...prev, node]); + setShowAddCcCompatibleModal(false); + }} + /> + )} {/* Test Results Modal */} {testResults && (
)} + {isCcCompatible && ( + + CC + + )} {isAnthropicCompatible && ( {t("messages")} @@ -1232,6 +1288,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 ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="CC Compatible Production" + hint="Display name for this Claude Code-compatible provider" + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder="cc" + hint="Used for model aliases such as prefix/model-id" + /> + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://example.com/v1" + hint="Base URL for the CC-compatible site. Do not include /messages." + /> + + {showAdvanced && ( +
+ setFormData({ ...formData, chatPath: e.target.value })} + placeholder={CC_COMPATIBLE_DEFAULT_CHAT_PATH} + hint="Defaults to the strict Claude Code-compatible messages path" + /> + setFormData({ ...formData, modelsPath: e.target.value })} + placeholder={CC_COMPATIBLE_DEFAULT_MODELS_PATH} + hint="Defaults to /models" + /> +
+ )} +
+ setCheckKey(e.target.value)} + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} +
+ + +
+
+
+ ); +} + +AddCcCompatibleModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onCreated: PropTypes.func.isRequired, +}; + // ─── Provider Test Results View (mirrors combo TestResultsView) ────────────── function ProviderTestResultsView({ results }) { diff --git a/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx b/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx index 9b4453bf48..a7d2cb7b50 100644 --- a/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx +++ b/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx @@ -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)) diff --git a/src/app/api/provider-nodes/[id]/route.ts b/src/app/api/provider-nodes/[id]/route.ts index 7af0697c8c..5c44c42c2e 100644 --- a/src/app/api/provider-nodes/[id]/route.ts +++ b/src/app/api/provider-nodes/[id]/route.ts @@ -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 = { diff --git a/src/app/api/provider-nodes/route.ts b/src/app/api/provider-nodes/route.ts index c0c59916cc..bc712284dd 100644 --- a/src/app/api/provider-nodes/route.ts +++ b/src/app/api/provider-nodes/route.ts @@ -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,6 +18,13 @@ 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 { @@ -49,7 +58,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 +79,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, diff --git a/src/app/api/provider-nodes/validate/route.ts b/src/app/api/provider-nodes/validate/route.ts index 089ba419f4..dd41b1624a 100644 --- a/src/app/api/provider-nodes/validate/route.ts +++ b/src/app/api/provider-nodes/validate/route.ts @@ -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"}`; diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 9db482958d..0c5e06a156 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -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 }); diff --git a/src/app/api/providers/validate/route.ts b/src/app/api/providers/validate/route.ts index 60917878ab..b3f0772730 100644 --- a/src/app/api/providers/validate/route.ts +++ b/src/app/api/providers/validate/route.ts @@ -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, }; } diff --git a/src/lib/display/names.ts b/src/lib/display/names.ts index f729c845b6..b06a650bba 100644 --- a/src/lib/display/names.ts +++ b/src/lib/display/names.ts @@ -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; } diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 1d209dcdf6..51fbe5f33a 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -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 }; diff --git a/src/shared/components/Header.tsx b/src/shared/components/Header.tsx index 9ffa39bbd6..4ea0334abd 100644 --- a/src/shared/components/Header.tsx +++ b/src/shared/components/Header.tsx @@ -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"), diff --git a/src/shared/constants/models.ts b/src/shared/constants/models.ts index ac120ccaba..7595280708 100644 --- a/src/shared/constants/models.ts +++ b/src/shared/constants/models.ts @@ -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; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 6f81b77246..b4aba4c670 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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 }; diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts new file mode 100644 index 0000000000..449da7e88f --- /dev/null +++ b/src/shared/utils/featureFlags.ts @@ -0,0 +1,5 @@ +export function isCcCompatibleProviderEnabled() { + return process.env.NEXT_PUBLIC_ENABLE_CC_COMPATIBLE_PROVIDER === "true"; +} + +export const CC_COMPATIBLE_PROVIDER_ENABLED = isCcCompatibleProviderEnabled(); diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index fbab5451aa..298a0b4295 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -967,6 +967,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("")), }) @@ -994,6 +995,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("")), }); diff --git a/tests/unit/cc-compatible-provider.test.mjs b/tests/unit/cc-compatible-provider.test.mjs new file mode 100644 index 0000000000..797928f155 --- /dev/null +++ b/tests/unit/cc-compatible-provider.test.mjs @@ -0,0 +1,236 @@ +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.NEXT_PUBLIC_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.NEXT_PUBLIC_ENABLE_CC_COMPATIBLE_PROVIDER; + } else { + process.env.NEXT_PUBLIC_ENABLE_CC_COMPATIBLE_PROVIDER = originalFlag; + } + await resetStorage(); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + if (originalFlag === undefined) { + delete process.env.NEXT_PUBLIC_ENABLE_CC_COMPATIBLE_PROVIDER; + } else { + process.env.NEXT_PUBLIC_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", + }, + normalizedBody: { + 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, 0); + 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); +}); + +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.NEXT_PUBLIC_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.NEXT_PUBLIC_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.NEXT_PUBLIC_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); +}); From 32dc3b36abb6c55cdea15059bb9c8ea2df48c870 Mon Sep 17 00:00:00 2001 From: "R.D." Date: Wed, 1 Apr 2026 04:39:29 -0400 Subject: [PATCH 2/3] chore: rename CC compatible feature flag --- .../(dashboard)/dashboard/providers/page.tsx | 11 +++++--- src/app/api/provider-nodes/route.ts | 5 +++- src/shared/utils/featureFlags.ts | 4 +-- tests/unit/cc-compatible-provider.test.mjs | 26 +++++++++++++------ 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index bd0bc0d86d..2aaf2fd2ed 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -21,7 +21,6 @@ import { isClaudeCodeCompatibleProvider, isOpenAICompatibleProvider, } from "@/shared/constants/providers"; -import { CC_COMPATIBLE_PROVIDER_ENABLED } from "@/shared/utils/featureFlags"; import Link from "next/link"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; import { useNotificationStore } from "@/store/notificationStore"; @@ -102,6 +101,7 @@ function getConnectionErrorTag(connection) { export default function ProvidersPage() { const [connections, setConnections] = useState([]); const [providerNodes, setProviderNodes] = useState([]); + const [ccCompatibleProviderEnabled, setCcCompatibleProviderEnabled] = useState(false); const [expirations, setExpirations] = useState(null); const [loading, setLoading] = useState(true); const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false); @@ -126,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); @@ -514,7 +517,7 @@ export default function ProvidersPage() { {testingMode === "compatible" ? t("testing") : t("testAll")} )} - {CC_COMPATIBLE_PROVIDER_ENABLED && ( + {ccCompatibleProviderEnabled && (