diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts index 3799ff9a..ab99b5e0 100644 --- a/src/proxy/server/messages-route.ts +++ b/src/proxy/server/messages-route.ts @@ -10,7 +10,7 @@ import { import { ProxySseStreamTransformer } from '../transformers/sse-stream-transformer'; import { isAnthropicPassthroughProfile, resolveOpenAIChatCompletionsUrl } from '../upstream-url'; import { createLogger } from '../../services/logging'; -import { pipeWebResponseToNode, readJsonBody, readRawBody, writeJson } from './http-helpers'; +import { pipeWebResponseToNode, readRawBody, writeJson } from './http-helpers'; const REQUEST_TIMEOUT_MS = 600_000; const DIRECT_OPENAI_REASONING_CHAT_MODEL = /^(?:gpt-5|o[134])(?:[-.]|$)/; @@ -32,26 +32,27 @@ class ProxyInputError extends Error { */ function buildUpstreamHeaders( profile: OpenAICompatProfileConfig, - incomingHeaders: http.IncomingHttpHeaders + incomingHeaders: http.IncomingHttpHeaders, + options: { preserveUserAgent?: boolean } = {} ): 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'; + if (options.preserveUserAgent) { + // Preserve the original User-Agent (or x-stainless-user-agent fallback) so + // providers that require a recognized coding-agent identifier accept the + // passthrough request. + const rawUserAgent = pickHeaderValue(incomingHeaders, ['user-agent', 'x-stainless-user-agent']); + headers['User-Agent'] = rawUserAgent?.trim() || headers['User-Agent']; + } return headers; } -function pickHeaderValue( - headers: http.IncomingHttpHeaders, - names: string[] -): string | undefined { +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) { @@ -186,7 +187,11 @@ function shapeUpstreamChatPayload( function buildUpstreamRequest( profile: OpenAICompatProfileConfig, rawBody: unknown, - options: { passthrough?: boolean } = {} + options: { + passthrough?: boolean; + rawBodyText?: string; + route?: ReturnType; + } = {} ): { body: string; route: ReturnType } { // In passthrough mode we forward the incoming Anthropic body verbatim to // the upstream provider, skipping both the Anthropic→OpenAI translation @@ -196,27 +201,11 @@ function buildUpstreamRequest( 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); - } + const body = options.rawBodyText ?? JSON.stringify(rawBody); + if (!body.trim()) { + throw new ProxyInputError('Missing request body'); } - // 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); + const route = options.route ?? resolveProxyRequestRoute(profile, buildRoutingRequest(rawBody)); return { body, route }; } @@ -240,14 +229,116 @@ 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; - } +function parseJsonBodyText(rawBodyText: string): unknown { + const raw = rawBodyText.trim(); + if (!raw) { + return {}; } - return ''; + + try { + return JSON.parse(raw); + } catch { + throw new ProxyInputError('Invalid JSON in request body'); + } +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function routingTextContent(content: unknown): string { + if (typeof content === 'string') { + return content; + } + if (!Array.isArray(content)) { + return ''; + } + + return content + .map((part) => { + const parsed = asRecord(part); + if (!parsed) { + return ''; + } + if (parsed.type === 'text' && typeof parsed.text === 'string') { + return parsed.text; + } + if (parsed.type === 'image') { + return '[image]'; + } + return ''; + }) + .filter(Boolean) + .join('\n'); +} + +function routingMessages(messages: unknown): ProxyOpenAIRequest['messages'] { + if (!Array.isArray(messages)) { + return []; + } + + return messages.flatMap((message): ProxyOpenAIRequest['messages'] => { + const parsed = asRecord(message); + const role = parsed?.role; + if (role !== 'system' && role !== 'user' && role !== 'assistant' && role !== 'tool') { + return []; + } + + return [ + { + role, + content: routingTextContent(parsed?.content), + }, + ]; + }); +} + +function routingTools(tools: unknown): ProxyOpenAIRequest['tools'] | undefined { + if (!Array.isArray(tools)) { + return undefined; + } + + const normalized = tools.flatMap((tool): NonNullable => { + const parsed = asRecord(tool); + const fn = asRecord(parsed?.function); + if (parsed?.type !== 'function' || typeof fn?.name !== 'string') { + return []; + } + return [ + { + type: 'function', + function: { + name: fn.name, + description: typeof fn.description === 'string' ? fn.description : undefined, + parameters: asRecord(fn.parameters) ?? {}, + }, + }, + ]; + }); + + return normalized.length > 0 ? normalized : undefined; +} + +function buildRoutingRequest(rawBody: unknown): ProxyOpenAIRequest { + const parsed = asRecord(rawBody); + const model = typeof parsed?.model === 'string' ? parsed.model : undefined; + const thinking = asRecord(parsed?.thinking); + const thinkingEnabled = thinking?.type === 'enabled' || thinking?.type === 'adaptive'; + + return { + model, + stream: parsed?.stream === true, + messages: routingMessages(parsed?.messages), + tools: routingTools(parsed?.tools), + reasoning: thinkingEnabled + ? { + enabled: true, + effort: 'high', + } + : undefined, + }; } export function extractIncomingProxyToken(headers: http.IncomingHttpHeaders): string | null { @@ -283,11 +374,12 @@ function buildFetchInit( body: string, signal: AbortSignal, incomingHeaders: http.IncomingHttpHeaders, + preserveUserAgent: boolean, insecureDispatcher?: Dispatcher ): RequestInit { const init: RequestInit = { method: 'POST', - headers: buildUpstreamHeaders(profile, incomingHeaders), + headers: buildUpstreamHeaders(profile, incomingHeaders, { preserveUserAgent }), body, signal, }; @@ -396,17 +488,21 @@ 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 = passthrough ? await readRawBody(req) : await readJsonBody(req); + const rawBodyText = await readRawBody(req); + const rawBody = parseJsonBodyText(rawBodyText); + const initialRoute = resolveProxyRequestRoute(profile, buildRoutingRequest(rawBody)); + const passthrough = isAnthropicPassthroughProfile(initialRoute.profile.baseUrl, { + forcePassthrough: initialRoute.profile.passthrough === true, + }); logger.stage('transform', 'request.transform.start', 'Transforming inbound proxy body', { passthrough, }); - const upstream = buildUpstreamRequest(profile, rawBody, { passthrough }); + const upstream = buildUpstreamRequest(profile, rawBody, { + passthrough, + rawBodyText, + route: passthrough ? initialRoute : undefined, + }); logger.stage('route', 'request.routed', 'Resolved proxy upstream route', { profileName: upstream.route.profile.profileName, provider: upstream.route.profile.provider, @@ -458,9 +554,7 @@ export async function handleProxyMessagesRequest( passthrough, }); const upstreamUrl = resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl, { - passthrough: isAnthropicPassthroughProfile(upstream.route.profile.baseUrl, { - forcePassthrough: upstream.route.profile.passthrough === true, - }), + passthrough, }); const upstreamResponse = await fetch( upstreamUrl, @@ -469,6 +563,7 @@ export async function handleProxyMessagesRequest( upstream.body, controller.signal, req.headers, + passthrough, dispatcher ) ); diff --git a/src/proxy/upstream-url.ts b/src/proxy/upstream-url.ts index 77698170..62af3acb 100644 --- a/src/proxy/upstream-url.ts +++ b/src/proxy/upstream-url.ts @@ -7,19 +7,15 @@ * 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. + * 2. Anthropic passthrough - some providers (e.g. Kimi coding endpoints) + * expose an Anthropic-compatible `/v1/messages` endpoint and reject + * OpenAI-format requests. For these profiles we preserve the incoming + * Anthropic body and forward it directly to the provider's + * `/v1/messages` endpoint. * * 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. + * - The base URL is a known Anthropic-style provider host. */ function normalizePathname(pathname: string): string { @@ -33,21 +29,13 @@ function ensureSupportedProtocol(parsed: URL): void { } } -function isAnthropicApiHost(hostname: string): boolean { +function isAnthropicPassthroughHost(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') + normalized.endsWith('.kimi.com') ); } @@ -74,16 +62,7 @@ export function isAnthropicPassthroughProfile( 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; + return isAnthropicPassthroughHost(parsed.hostname); } /** diff --git a/tests/unit/proxy/messages-route.test.ts b/tests/unit/proxy/messages-route.test.ts index bcf4a031..e398ea33 100644 --- a/tests/unit/proxy/messages-route.test.ts +++ b/tests/unit/proxy/messages-route.test.ts @@ -103,6 +103,12 @@ beforeEach(() => { ANTHROPIC_MODEL: 'MiniMax-M2.7', CCS_DROID_PROVIDER: 'openai', }), + kimic: writeSettings('kimic', { + ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/', + ANTHROPIC_AUTH_TOKEN: 'kimi_token', + ANTHROPIC_MODEL: 'kimi-k2p7-coding', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }), }; fs.writeFileSync( @@ -166,6 +172,152 @@ describe('attachDisconnectAbortHandlers', () => { }); describe('handleProxyMessagesRequest', () => { + it('auto-passes through Kimi requests and preserves the coding-agent User-Agent', async () => { + const activeProfile = buildProfile('kimic'); + let capturedInput: RequestInfo | URL | undefined; + let capturedInit: RequestInit | undefined; + const rawBody = JSON.stringify({ + model: 'kimi-k2p7-coding', + max_tokens: 50, + messages: [{ role: 'user', content: 'Say hi in 2 words' }], + }); + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + capturedInput = input; + capturedInit = init; + return new Response( + JSON.stringify({ + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Hello there!' }], + model: 'kimi-k2p7-coding', + stop_reason: 'end_turn', + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }) as typeof globalThis.fetch; + + const req = new FakeRequest({ + 'x-api-key': 'local-token', + 'user-agent': 'claude-cli/2.1.170', + }); + const res = new FakeResponse(); + const pending = handleProxyMessagesRequest( + req as never, + res as never, + activeProfile, + 'local-token' + ); + req.end(rawBody); + await pending; + + expect(String(capturedInput)).toBe('https://api.kimi.com/coding/v1/messages'); + expect(capturedInit?.body).toBe(rawBody); + expect((capturedInit?.headers as Record)['User-Agent']).toBe( + 'claude-cli/2.1.170' + ); + expect(res.statusCode).toBe(200); + }); + + it('uses routed-profile passthrough for explicit profile:model selectors', async () => { + const activeProfile = buildProfile('hf'); + let capturedInput: RequestInfo | URL | undefined; + let capturedInit: RequestInit | undefined; + const rawBody = JSON.stringify({ + model: 'kimic:kimi-k2p7-coding', + max_tokens: 50, + messages: [{ role: 'user', content: 'Say hi' }], + }); + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + capturedInput = input; + capturedInit = init; + return new Response( + JSON.stringify({ + id: 'msg_2', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Hi' }], + model: 'kimi-k2p7-coding', + stop_reason: 'end_turn', + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }) as typeof globalThis.fetch; + + const req = new FakeRequest({ + 'x-api-key': 'local-token', + 'x-stainless-user-agent': 'claude-cli/2.1.170', + }); + const res = new FakeResponse(); + const pending = handleProxyMessagesRequest( + req as never, + res as never, + activeProfile, + 'local-token' + ); + req.end(rawBody); + await pending; + + expect(String(capturedInput)).toBe('https://api.kimi.com/coding/v1/messages'); + expect(capturedInit?.body).toBe(rawBody); + expect((capturedInit?.headers as Record)['User-Agent']).toBe( + 'claude-cli/2.1.170' + ); + expect(res.statusCode).toBe(200); + }); + + it('keeps default OpenAI-compatible requests on chat completions with CCS User-Agent', async () => { + const activeProfile = buildProfile('hf'); + let capturedInput: RequestInfo | URL | undefined; + let capturedInit: RequestInit | undefined; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + capturedInput = input; + capturedInit = init; + return new Response( + JSON.stringify({ + id: 'chatcmpl_1', + object: 'chat.completion', + created: 1, + model: 'hf-default', + choices: [{ index: 0, message: { role: 'assistant', content: 'ok' } }], + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }) as typeof globalThis.fetch; + + const req = new FakeRequest({ + 'x-api-key': 'local-token', + 'user-agent': 'claude-cli/2.1.170', + }); + const res = new FakeResponse(); + const pending = handleProxyMessagesRequest( + req as never, + res as never, + activeProfile, + 'local-token' + ); + req.end( + JSON.stringify({ + model: 'hf-default', + messages: [{ role: 'user', content: 'stay translated' }], + }) + ); + await pending; + + expect(String(capturedInput)).toBe('https://router.huggingface.co/v1/chat/completions'); + expect(JSON.parse(String(capturedInit?.body))).toMatchObject({ + model: 'hf-default', + messages: [{ role: 'user', content: 'stay translated' }], + }); + expect((capturedInit?.headers as Record)['User-Agent']).toBe( + 'CCS-OpenAI-Compat-Proxy/1.0' + ); + expect(res.statusCode).toBe(200); + }); + it('uses a per-request insecure dispatcher for routed profiles and closes it on failure', async () => { const activeProfile = buildProfile('hf'); const sharedDispatcher = { name: 'shared-insecure-dispatcher' } as never; diff --git a/tests/unit/proxy/profile-router.test.ts b/tests/unit/proxy/profile-router.test.ts index e93a4e8a..5b7edba7 100644 --- a/tests/unit/proxy/profile-router.test.ts +++ b/tests/unit/proxy/profile-router.test.ts @@ -95,32 +95,24 @@ describe('resolveOpenAICompatProfileConfig', () => { }); 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', - } - ); + 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', - } - ); + 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 536609a0..aebfd7bb 100644 --- a/tests/unit/proxy/upstream-url.test.ts +++ b/tests/unit/proxy/upstream-url.test.ts @@ -56,26 +56,30 @@ describe('Anthropic passthrough URL resolution', () => { ).toBe('https://api.kimi.com/coding/v1/models'); }); - it('auto-detects Kimi and MiniMax hosts as Anthropic-style', () => { + it('auto-detects known Anthropic-style hosts', () => { 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 treat generic /v1 roots as Anthropic-style passthrough', () => { + expect(isAnthropicPassthroughProfile('https://example.test/v1')).toBe(false); + expect(isAnthropicPassthroughProfile('https://example.test/v1/')).toBe(false); + expect(isAnthropicPassthroughProfile('https://example.test/api/v1')).toBe(false); + expect(isAnthropicPassthroughProfile('https://api.openai.com/v1')).toBe(false); + expect(isAnthropicPassthroughProfile('https://api.minimax.io/v1')).toBe(false); }); 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. + expect(isAnthropicPassthroughProfile('https://api.openai.com/v1')).toBe(false); + }); + + it('honors explicit force passthrough for unknown mirrors', () => { + expect( + isAnthropicPassthroughProfile('https://anthropic-mirror.example.test/v1', { + forcePassthrough: true, + }) + ).toBe(true); }); });