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..ab99b5e0 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, readRawBody, writeJson } from './http-helpers'; const REQUEST_TIMEOUT_MS = 600_000; const DIRECT_OPENAI_REASONING_CHAT_MODEL = /^(?:gpt-5|o[134])(?:[-.]|$)/; @@ -23,12 +23,46 @@ 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, + options: { preserveUserAgent?: boolean } = {} +): Record { + const headers: Record = { 'Content-Type': 'application/json', Authorization: `Bearer ${profile.apiKey}`, 'User-Agent': '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 { + 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 +186,29 @@ function shapeUpstreamChatPayload( function buildUpstreamRequest( profile: OpenAICompatProfileConfig, - rawBody: unknown + rawBody: unknown, + 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 + // 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'); + } + const body = options.rawBodyText ?? JSON.stringify(rawBody); + if (!body.trim()) { + throw new ProxyInputError('Missing request body'); + } + const route = options.route ?? resolveProxyRequestRoute(profile, buildRoutingRequest(rawBody)); + return { body, route }; + } + let transformed: ProxyOpenAIRequest; try { const transformer = new ProxyRequestTransformer(); @@ -174,6 +229,118 @@ function buildUpstreamRequest( return { body: JSON.stringify(body), route }; } +function parseJsonBodyText(rawBodyText: string): unknown { + const raw = rawBodyText.trim(); + if (!raw) { + 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 { const xApiKey = headers['x-api-key']; if (typeof xApiKey === 'string' && xApiKey.trim().length > 0) { @@ -206,11 +373,13 @@ function buildFetchInit( profile: OpenAICompatProfileConfig, body: string, signal: AbortSignal, + incomingHeaders: http.IncomingHttpHeaders, + preserveUserAgent: boolean, insecureDispatcher?: Dispatcher ): RequestInit { const init: RequestInit = { method: 'POST', - headers: buildUpstreamHeaders(profile), + headers: buildUpstreamHeaders(profile, incomingHeaders, { preserveUserAgent }), body, signal, }; @@ -320,9 +489,20 @@ export async function handleProxyMessagesRequest( let timeoutMs = REQUEST_TIMEOUT_MS; try { - const rawBody = await readJsonBody(req); - logger.stage('transform', 'request.transform.start', 'Transforming inbound proxy body'); - const upstream = buildUpstreamRequest(profile, rawBody); + 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, + rawBodyText, + route: passthrough ? initialRoute : undefined, + }); logger.stage('route', 'request.routed', 'Resolved proxy upstream route', { profileName: upstream.route.profile.profileName, provider: upstream.route.profile.provider, @@ -371,17 +551,34 @@ export async function handleProxyMessagesRequest( profileName: profile.profileName, routedProfileName: upstream.route.profile.profileName, insecureTls: dispatcher !== undefined, + passthrough, + }); + const upstreamUrl = resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl, { + passthrough, }); 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, + passthrough, + 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..62af3acb 100644 --- a/src/proxy/upstream-url.ts +++ b/src/proxy/upstream-url.ts @@ -1,3 +1,23 @@ +/** + * 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 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 is a known Anthropic-style provider host. + */ + function normalizePathname(pathname: string): string { const trimmed = pathname.replace(/\/+$/, ''); return trimmed || ''; @@ -9,16 +29,71 @@ function ensureSupportedProtocol(parsed: URL): void { } } +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') + ); +} + 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); + return isAnthropicPassthroughHost(parsed.hostname); +} + +/** + * 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 +112,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/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 3608c448..5b7edba7 100644 --- a/tests/unit/proxy/profile-router.test.ts +++ b/tests/unit/proxy/profile-router.test.ts @@ -93,4 +93,28 @@ 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..aebfd7bb 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,56 @@ 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 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.anthropic.com')).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(false); + }); + + it('honors explicit force passthrough for unknown mirrors', () => { + expect( + isAnthropicPassthroughProfile('https://anthropic-mirror.example.test/v1', { + forcePassthrough: true, + }) + ).toBe(true); + }); +});