mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 20:15:40 +00:00
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:
7 files changed
+598
-15
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user