Merge pull request #1519 from itzrnvr/fix/openai-compat-preserve-user-agent

fix(proxy): add Anthropic passthrough mode for coding-agent-only endpoints
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-14 11:22:39 -04:00
commit 65c2964050
7 files changed
+598 -15

No files matched your search

+15
View File
@@ -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,
+44
View File
@@ -58,6 +58,50 @@ export function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
});
}
/**
* 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<string> {
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
+209 -12
View File
@@ -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<string, string> {
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/<version>` User-Agent when the client did not
* provide one.
*/
function buildUpstreamHeaders(
profile: OpenAICompatProfileConfig,
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',
};
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<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
// 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<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 {
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,
+100 -3
View File
@@ -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
* `<base>/v1/chat/completions`. For Anthropic passthrough providers this
* resolves to `<base>/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');
}