fix(proxy): route Anthropic passthrough by upstream profile

This commit is contained in:
Tam Nhu Tran
2026-06-14 11:19:58 -04:00
parent b98b0df084
commit bb9d23ad6a
5 changed files with 334 additions and 112 deletions
+145 -50
View File
@@ -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<string, string> {
const headers: Record<string, string> = {
'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<typeof resolveProxyRequestRoute>;
} = {}
): { body: string; route: ReturnType<typeof resolveProxyRequestRoute> } {
// 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<string, unknown>).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<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<ProxyOpenAIRequest['tools']> => {
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
)
);
+9 -30
View File
@@ -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);
}
/**
+152
View File
@@ -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<string, string>)['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<string, string>)['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<string, string>)['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;
+12 -20
View File
@@ -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();
+16 -12
View File
@@ -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);
});
});