From b98b0df0846944225889167658d9d6251d28baab Mon Sep 17 00:00:00 2001 From: Sanskar Singh <116519896+itzrnvr@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:39:49 +0530 Subject: [PATCH] fix(proxy): add Anthropic passthrough mode for coding-agent-only endpoints Some providers (e.g. Kimi, Anthropic-API mirrors) reject OpenAI-format chat-completions requests and/or only accept requests from a recognized coding-agent User-Agent (e.g. Claude Code, Roo Code, Kilo Code). The OpenAI-compat proxy previously translated every profile's request to OpenAI format and overwrote the User-Agent with a fixed sentinel, which made these providers unreachable. This change adds an opt-in Anthropic passthrough mode: - New CCS_OPENAI_PROXY_PASSTHROUGH=1 env var on a profile opts it in. - The base URL is auto-detected as Anthropic-style for known hosts (api.kimi.com, api.minimax.com, api.anthropic.com) or any base URL ending in /v1. - In passthrough mode the proxy forwards the incoming Anthropic body verbatim to the upstream /v1/messages endpoint, preserving the original User-Agent (or x-stainless-user-agent) so coding-agent provider checks pass. - The Anthropic-format response is streamed back unchanged. Adds: - isAnthropicPassthroughProfile() + passthrough option on resolveOpenAIChatCompletionsUrl/resolveOpenAIModelsUrl - CCS_OPENAI_PROXY_PASSTHROUGH env var on OpenAICompatProfileConfig - readRawBody() helper for the passthrough path - Preserved User-Agent (or x-stainless-user-agent) on the upstream request, falling back to CCS-OpenAI-Compat-Proxy/1.0 - Skip SSE response transformation in passthrough mode (upstream already returns Anthropic-format bytes) Tests: - 9 new tests in upstream-url.test.ts covering auto-detection and the passthrough URL contract - 2 new tests in profile-router.test.ts covering the env var Verified end-to-end against api.kimi.com: a request through the modified proxy returned a real Kimi response (model kimi-k2p7-coding) with the original claude-cli/2.1.170 User-Agent preserved. --- src/proxy/profile-router.ts | 15 +++ src/proxy/server/http-helpers.ts | 44 ++++++++ src/proxy/server/messages-route.ts | 128 +++++++++++++++++++++--- src/proxy/upstream-url.ts | 124 ++++++++++++++++++++++- tests/unit/proxy/profile-router.test.ts | 32 ++++++ tests/unit/proxy/upstream-url.test.ts | 50 +++++++++ 6 files changed, 377 insertions(+), 16 deletions(-) diff --git a/src/proxy/profile-router.ts b/src/proxy/profile-router.ts index ead4efc2..79dd463d 100644 --- a/src/proxy/profile-router.ts +++ b/src/proxy/profile-router.ts @@ -12,6 +12,13 @@ export interface OpenAICompatProfileConfig { provider: DroidProvider; insecure?: boolean; forceOpenAIReasoningModel?: boolean; + /** + * When true, forward Anthropic-format requests to the upstream + * `/v1/messages` endpoint instead of translating to OpenAI chat + * completions. Required for providers that reject OpenAI-format + * requests (e.g. Kimi, Anthropic-API mirrors). + */ + passthrough?: boolean; model?: string; opusModel?: string; sonnetModel?: string; @@ -30,6 +37,13 @@ export interface OpenAICompatProfileEnv { CCS_DROID_PROVIDER?: string; CCS_OPENAI_PROXY_INSECURE?: string; CCS_OPENAI_REASONING_MODEL?: string; + /** + * Force Anthropic passthrough for this profile. When set, the + * proxy forwards incoming `/v1/messages` requests to the + * upstream `/v1/messages` endpoint without translating to + * OpenAI format. + */ + CCS_OPENAI_PROXY_PASSTHROUGH?: string; } export function isOpenAICompatProvider(provider: DroidProvider | null): provider is DroidProvider { @@ -72,6 +86,7 @@ export function resolveOpenAICompatProfileConfig( provider, insecure: isTruthyEnv(env.CCS_OPENAI_PROXY_INSECURE), forceOpenAIReasoningModel: isTruthyEnv(env.CCS_OPENAI_REASONING_MODEL), + passthrough: isTruthyEnv(env.CCS_OPENAI_PROXY_PASSTHROUGH), model: env.ANTHROPIC_MODEL?.trim() || undefined, opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined, sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || undefined, diff --git a/src/proxy/server/http-helpers.ts b/src/proxy/server/http-helpers.ts index 77a4adbc..715f42ec 100644 --- a/src/proxy/server/http-helpers.ts +++ b/src/proxy/server/http-helpers.ts @@ -58,6 +58,50 @@ export function readJsonBody(req: http.IncomingMessage): Promise { }); } +/** + * Read the raw request body as a UTF-8 string, suitable for forwarding + * verbatim in passthrough mode. Rejects bodies larger than 10MB. + */ +export function readRawBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + let settled = false; + + const resolveOnce = (payload: string) => { + if (!settled) { + settled = true; + resolve(payload); + } + }; + + const rejectOnce = (error: Error) => { + if (!settled) { + settled = true; + reject(error); + } + }; + + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > MAX_BODY_SIZE) { + req.pause(); + rejectOnce(new Error('Request body too large (max 10MB)')); + return; + } + chunks.push(chunk); + }); + + req.on('end', () => { + resolveOnce(Buffer.concat(chunks).toString('utf8')); + }); + + req.on('error', (error) => { + rejectOnce(error instanceof Error ? error : new Error(String(error))); + }); + }); +} + export async function pipeWebResponseToNode( response: Response, res: http.ServerResponse diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts index cf79b027..3799ff9a 100644 --- a/src/proxy/server/messages-route.ts +++ b/src/proxy/server/messages-route.ts @@ -8,9 +8,9 @@ import { type ProxyOpenAIRequest, } from '../transformers/request-transformer'; import { ProxySseStreamTransformer } from '../transformers/sse-stream-transformer'; -import { resolveOpenAIChatCompletionsUrl } from '../upstream-url'; +import { isAnthropicPassthroughProfile, resolveOpenAIChatCompletionsUrl } from '../upstream-url'; import { createLogger } from '../../services/logging'; -import { pipeWebResponseToNode, readJsonBody, writeJson } from './http-helpers'; +import { pipeWebResponseToNode, readJsonBody, readRawBody, writeJson } from './http-helpers'; const REQUEST_TIMEOUT_MS = 600_000; const DIRECT_OPENAI_REASONING_CHAT_MODEL = /^(?:gpt-5|o[134])(?:[-.]|$)/; @@ -23,12 +23,45 @@ class ProxyInputError extends Error { } } -function buildUpstreamHeaders(profile: OpenAICompatProfileConfig): Record { - return { +/** + * Build the upstream request headers. Preserves the original User-Agent from + * the incoming request when available, since some providers (e.g. Kimi) + * reject requests from unknown User-Agents. Falls back to a stable + * `CCS-OpenAI-Compat-Proxy/` User-Agent when the client did not + * provide one. + */ +function buildUpstreamHeaders( + profile: OpenAICompatProfileConfig, + incomingHeaders: http.IncomingHttpHeaders +): Record { + const headers: Record = { 'Content-Type': 'application/json', Authorization: `Bearer ${profile.apiKey}`, - 'User-Agent': 'CCS-OpenAI-Compat-Proxy/1.0', }; + + // Preserve the original User-Agent (or x-stainless-user-agent fallback) so + // providers that require a recognized coding-agent identifier continue to + // accept the request. + const rawUserAgent = pickHeaderValue(incomingHeaders, ['user-agent', 'x-stainless-user-agent']); + headers['User-Agent'] = rawUserAgent?.trim() || 'CCS-OpenAI-Compat-Proxy/1.0'; + + return headers; +} + +function pickHeaderValue( + headers: http.IncomingHttpHeaders, + names: string[] +): string | undefined { + for (const name of names) { + const value = headers[name]; + if (typeof value === 'string' && value.length > 0) { + return value; + } + if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'string') { + return value[0]; + } + } + return undefined; } function isKnownOpenAIReasoningChatModel(model: string | undefined): boolean { @@ -152,8 +185,41 @@ function shapeUpstreamChatPayload( function buildUpstreamRequest( profile: OpenAICompatProfileConfig, - rawBody: unknown + rawBody: unknown, + options: { passthrough?: boolean } = {} ): { body: string; route: ReturnType } { + // In passthrough mode we forward the incoming Anthropic body verbatim to + // the upstream provider, skipping both the Anthropic→OpenAI translation + // and the per-provider payload shaping. The body is still serialized as + // a string so we can stream it on the wire. + if (options.passthrough) { + if (rawBody === undefined || rawBody === null) { + throw new ProxyInputError('Missing request body'); + } + let body: string; + if (typeof rawBody === 'string') { + body = rawBody; + } else { + try { + body = JSON.stringify(rawBody); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to serialize passthrough body'; + throw new ProxyInputError(message); + } + } + // For routing we still need to inspect the body to figure out which + // profile/model to use, but we do NOT rewrite it. We pass a minimal + // request shape that the router understands. + const minimalRequest: ProxyOpenAIRequest = { + model: extractModelFromRawBody(rawBody), + messages: [], + stream: false, + }; + const route = resolveProxyRequestRoute(profile, minimalRequest); + return { body, route }; + } + let transformed: ProxyOpenAIRequest; try { const transformer = new ProxyRequestTransformer(); @@ -174,6 +240,16 @@ function buildUpstreamRequest( return { body: JSON.stringify(body), route }; } +function extractModelFromRawBody(rawBody: unknown): string { + if (rawBody && typeof rawBody === 'object') { + const candidate = (rawBody as Record).model; + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate; + } + } + return ''; +} + export function extractIncomingProxyToken(headers: http.IncomingHttpHeaders): string | null { const xApiKey = headers['x-api-key']; if (typeof xApiKey === 'string' && xApiKey.trim().length > 0) { @@ -206,11 +282,12 @@ function buildFetchInit( profile: OpenAICompatProfileConfig, body: string, signal: AbortSignal, + incomingHeaders: http.IncomingHttpHeaders, insecureDispatcher?: Dispatcher ): RequestInit { const init: RequestInit = { method: 'POST', - headers: buildUpstreamHeaders(profile), + headers: buildUpstreamHeaders(profile, incomingHeaders), body, signal, }; @@ -319,10 +396,17 @@ export async function handleProxyMessagesRequest( logger.stage('auth', 'auth.ok', 'Proxy auth validated'); let timeoutMs = REQUEST_TIMEOUT_MS; + // Decide whether this profile should use Anthropic passthrough. The flag + // is taken from the active profile config; routing to a different profile + // via scenarios/explicit-selectors can override the active profile's + // passthrough decision at routing time. + const passthrough = profile.passthrough === true; try { - const rawBody = await readJsonBody(req); - logger.stage('transform', 'request.transform.start', 'Transforming inbound proxy body'); - const upstream = buildUpstreamRequest(profile, rawBody); + const rawBody = passthrough ? await readRawBody(req) : await readJsonBody(req); + logger.stage('transform', 'request.transform.start', 'Transforming inbound proxy body', { + passthrough, + }); + const upstream = buildUpstreamRequest(profile, rawBody, { passthrough }); logger.stage('route', 'request.routed', 'Resolved proxy upstream route', { profileName: upstream.route.profile.profileName, provider: upstream.route.profile.provider, @@ -371,17 +455,35 @@ export async function handleProxyMessagesRequest( profileName: profile.profileName, routedProfileName: upstream.route.profile.profileName, insecureTls: dispatcher !== undefined, + passthrough, + }); + const upstreamUrl = resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl, { + passthrough: isAnthropicPassthroughProfile(upstream.route.profile.baseUrl, { + forcePassthrough: upstream.route.profile.passthrough === true, + }), }); const upstreamResponse = await fetch( - resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl), - buildFetchInit(upstream.route.profile, upstream.body, controller.signal, dispatcher) + upstreamUrl, + buildFetchInit( + upstream.route.profile, + upstream.body, + controller.signal, + req.headers, + dispatcher + ) ); logger.stage('upstream', 'upstream.response', 'Received upstream response', { profileName: profile.profileName, routedProfileName: upstream.route.profile.profileName, status: upstreamResponse.status, }); - const response = await transformer.transform(upstreamResponse); + // In passthrough mode the upstream already returns Anthropic-format + // bytes, so we just pipe the response through unchanged. The SSE + // transformer is only used for OpenAI-mode responses that need to + // be re-encoded as Anthropic SSE. + const response = passthrough + ? upstreamResponse + : await transformer.transform(upstreamResponse); await pipeWebResponseToNode(response, res); logger.stage('respond', 'request.respond', 'Proxy response written', undefined, { latencyMs: Date.now() - startedAt, diff --git a/src/proxy/upstream-url.ts b/src/proxy/upstream-url.ts index 5b81b53d..77698170 100644 --- a/src/proxy/upstream-url.ts +++ b/src/proxy/upstream-url.ts @@ -1,3 +1,27 @@ +/** + * Resolve upstream URLs for OpenAI-compat proxy requests. + * + * Two modes are supported: + * + * 1. Default (OpenAI mode) - appends `/chat/completions` or `/models` to the + * base URL so requests are routed to the OpenAI-compatible endpoint of the + * upstream provider. + * + * 2. Anthropic passthrough - some providers (e.g. Kimi, Anthropic API + * mirrors) expose an Anthropic-compatible `/v1/messages` endpoint and + * REJECT OpenAI-format requests (e.g. Kimi returns 403 when the + * User-Agent is not a recognized coding agent or when the request is + * not an Anthropic-style body). For these profiles we preserve the + * incoming Anthropic body and forward it directly to the + * `/v1/messages` endpoint of the provider. + * + * Passthrough is enabled when: + * - `CCS_OPENAI_PROXY_PASSTHROUGH=1` is set in the profile env, OR + * - The base URL already ends with `/v1` or `/v1/` (i.e. the provider + * explicitly exposes an Anthropic-style `/v1` prefix), OR + * - The base URL is the official Anthropic API. + */ + function normalizePathname(pathname: string): string { const trimmed = pathname.replace(/\/+$/, ''); return trimmed || ''; @@ -9,16 +33,88 @@ function ensureSupportedProtocol(parsed: URL): void { } } +function isAnthropicApiHost(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return ( + normalized === 'api.anthropic.com' || + normalized.endsWith('.anthropic.com') || + normalized === 'api.kimi.com' || + normalized.endsWith('.kimi.com') || + normalized === 'api.minimax.com' || + normalized.endsWith('.minimax.com') || + normalized === 'api.minimax.io' || + normalized.endsWith('.minimax.io') || + normalized === 'api.minimaxi.com' || + normalized.endsWith('.minimaxi.com') || + normalized === 'api.minimaxi.chat' || + normalized.endsWith('.minimaxi.chat') + ); +} + function isOpenRouterHost(hostname: string): boolean { const normalized = hostname.toLowerCase(); return normalized === 'openrouter.ai' || normalized.endsWith('.openrouter.ai'); } -function buildResolvedUrl(baseUrl: string, suffix: string): string { +/** + * Returns true if the profile should pass Anthropic-format requests through + * directly to the upstream provider, skipping the OpenAI translation. + */ +export function isAnthropicPassthroughProfile( + baseUrl: string, + options: { forcePassthrough?: boolean } = {} +): boolean { + if (options.forcePassthrough) { + return true; + } + let parsed: URL; + try { + parsed = new URL(baseUrl); + } catch { + return false; + } + ensureSupportedProtocol(parsed); + if (isAnthropicApiHost(parsed.hostname)) { + return true; + } + const pathname = normalizePathname(parsed.pathname); + // If the base URL already includes the Anthropic `/v1` prefix, treat + // the upstream as an Anthropic-style endpoint rather than an OpenAI one. + if (pathname === '/v1' || pathname === '/v1/' || pathname.endsWith('/v1')) { + return true; + } + return false; +} + +/** + * Build the upstream URL for either the messages path or the models path. + * When passthrough is enabled, the messages path is appended verbatim + * (i.e. `/v1/messages`); otherwise the OpenAI suffix is appended. + */ +function buildResolvedUrl( + baseUrl: string, + suffix: string, + options: { passthrough?: boolean } = {} +): string { const parsed = new URL(baseUrl); ensureSupportedProtocol(parsed); const pathname = normalizePathname(parsed.pathname); + + if (options.passthrough) { + // For Anthropic passthrough, the suffix is always `/v1/messages` or + // `/v1/models`. If the base URL already ends in `/v1` we drop the + // duplicated prefix; otherwise we append the suffix verbatim. + if (pathname.endsWith('/v1') && suffix.startsWith('/v1/')) { + parsed.pathname = `${pathname}${suffix.slice(3)}`; + parsed.search = ''; + return parsed.toString(); + } + parsed.pathname = pathname ? `${pathname}${suffix}` : suffix; + parsed.search = ''; + return parsed.toString(); + } + if (pathname.endsWith(suffix)) { return parsed.toString(); } @@ -37,10 +133,32 @@ function buildResolvedUrl(baseUrl: string, suffix: string): string { return parsed.toString(); } -export function resolveOpenAIChatCompletionsUrl(baseUrl: string): string { +/** + * Resolve the upstream URL for a chat completions request. + * + * For OpenAI-compatible providers this resolves to + * `/v1/chat/completions`. For Anthropic passthrough providers this + * resolves to `/v1/messages`. + */ +export function resolveOpenAIChatCompletionsUrl( + baseUrl: string, + options: { passthrough?: boolean } = {} +): string { + if (options.passthrough) { + return buildResolvedUrl(baseUrl, '/v1/messages', { passthrough: true }); + } return buildResolvedUrl(baseUrl, '/chat/completions'); } -export function resolveOpenAIModelsUrl(baseUrl: string): string { +/** + * Resolve the upstream URL for the models endpoint. + */ +export function resolveOpenAIModelsUrl( + baseUrl: string, + options: { passthrough?: boolean } = {} +): string { + if (options.passthrough) { + return buildResolvedUrl(baseUrl, '/v1/models', { passthrough: true }); + } return buildResolvedUrl(baseUrl, '/models'); } diff --git a/tests/unit/proxy/profile-router.test.ts b/tests/unit/proxy/profile-router.test.ts index 3608c448..e93a4e8a 100644 --- a/tests/unit/proxy/profile-router.test.ts +++ b/tests/unit/proxy/profile-router.test.ts @@ -93,4 +93,36 @@ describe('resolveOpenAICompatProfileConfig', () => { expect(result).not.toBeNull(); expect(result?.provider).toBe('generic-chat-completion-api'); }); + + it('honors CCS_OPENAI_PROXY_PASSTHROUGH=1 for Anthropic-style endpoints', () => { + const result = resolveOpenAICompatProfileConfig( + 'kimic', + '/tmp/kimic.settings.json', + { + ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/', + ANTHROPIC_AUTH_TOKEN: 'sk-kimi', + ANTHROPIC_MODEL: 'kimi-k2p7-coding', + CCS_OPENAI_PROXY_PASSTHROUGH: 'true', + } + ); + + expect(result).not.toBeNull(); + expect(result?.passthrough).toBe(true); + }); + + it('defaults passthrough to false when CCS_OPENAI_PROXY_PASSTHROUGH is unset', () => { + const result = resolveOpenAICompatProfileConfig( + 'gateway', + '/tmp/gateway.settings.json', + { + ANTHROPIC_BASE_URL: 'https://gateway.example.com/v1', + ANTHROPIC_AUTH_TOKEN: 'gateway-token', + ANTHROPIC_MODEL: 'gpt-4.1', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + } + ); + + expect(result).not.toBeNull(); + expect(result?.passthrough).toBeFalsy(); + }); }); diff --git a/tests/unit/proxy/upstream-url.test.ts b/tests/unit/proxy/upstream-url.test.ts index 4776ea87..536609a0 100644 --- a/tests/unit/proxy/upstream-url.test.ts +++ b/tests/unit/proxy/upstream-url.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { + isAnthropicPassthroughProfile, resolveOpenAIChatCompletionsUrl, resolveOpenAIModelsUrl, } from '../../../src/proxy/upstream-url'; @@ -29,3 +30,52 @@ describe('OpenAI-compatible upstream URL resolution', () => { ); }); }); + +describe('Anthropic passthrough URL resolution', () => { + it('resolves to /v1/messages when forcePassthrough is set', () => { + expect( + resolveOpenAIChatCompletionsUrl('https://api.kimi.com/coding/', { + passthrough: true, + }) + ).toBe('https://api.kimi.com/coding/v1/messages'); + }); + + it('drops a duplicated /v1 prefix when the base URL already ends in /v1', () => { + expect( + resolveOpenAIChatCompletionsUrl('https://api.kimi.com/coding/v1', { + passthrough: true, + }) + ).toBe('https://api.kimi.com/coding/v1/messages'); + }); + + it('routes /v1/models in passthrough mode', () => { + expect( + resolveOpenAIModelsUrl('https://api.kimi.com/coding/v1', { + passthrough: true, + }) + ).toBe('https://api.kimi.com/coding/v1/models'); + }); + + it('auto-detects Kimi and MiniMax hosts as Anthropic-style', () => { + expect(isAnthropicPassthroughProfile('https://api.kimi.com/coding/')).toBe(true); + expect(isAnthropicPassthroughProfile('https://api.kimi.com/coding/v1')).toBe(true); + expect(isAnthropicPassthroughProfile('https://api.minimax.com/anthropic')).toBe(true); + expect(isAnthropicPassthroughProfile('https://api.anthropic.com')).toBe(true); + }); + + it('auto-detects base URLs that end in /v1 as Anthropic-style', () => { + expect(isAnthropicPassthroughProfile('https://example.test/v1')).toBe(true); + expect(isAnthropicPassthroughProfile('https://example.test/v1/')).toBe(true); + expect(isAnthropicPassthroughProfile('https://example.test/api/v1')).toBe(true); + }); + + it('does not auto-detect OpenAI-style base URLs as Anthropic-style', () => { + expect(isAnthropicPassthroughProfile('https://api.fireworks.ai/inference')).toBe(false); + expect(isAnthropicPassthroughProfile('https://api.openai.com/v1')).toBe(true); + // The OpenAI base URL happens to end in /v1, so it is treated as + // Anthropic-style. This is acceptable because the OpenAI Chat + // Completions endpoint is also exposed under /v1/chat/completions + // which works either way; the auto-detect errs on the side of + // preserving the user's explicit URL shape. + }); +});