mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 22:17:14 +00:00
fix(proxy): add Anthropic passthrough mode for coding-agent-only endpoints
Some providers (e.g. Kimi, Anthropic-API mirrors) reject OpenAI-format chat-completions requests and/or only accept requests from a recognized coding-agent User-Agent (e.g. Claude Code, Roo Code, Kilo Code). The OpenAI-compat proxy previously translated every profile's request to OpenAI format and overwrote the User-Agent with a fixed sentinel, which made these providers unreachable. This change adds an opt-in Anthropic passthrough mode: - New CCS_OPENAI_PROXY_PASSTHROUGH=1 env var on a profile opts it in. - The base URL is auto-detected as Anthropic-style for known hosts (api.kimi.com, api.minimax.com, api.anthropic.com) or any base URL ending in /v1. - In passthrough mode the proxy forwards the incoming Anthropic body verbatim to the upstream /v1/messages endpoint, preserving the original User-Agent (or x-stainless-user-agent) so coding-agent provider checks pass. - The Anthropic-format response is streamed back unchanged. Adds: - isAnthropicPassthroughProfile() + passthrough option on resolveOpenAIChatCompletionsUrl/resolveOpenAIModelsUrl - CCS_OPENAI_PROXY_PASSTHROUGH env var on OpenAICompatProfileConfig - readRawBody() helper for the passthrough path - Preserved User-Agent (or x-stainless-user-agent) on the upstream request, falling back to CCS-OpenAI-Compat-Proxy/1.0 - Skip SSE response transformation in passthrough mode (upstream already returns Anthropic-format bytes) Tests: - 9 new tests in upstream-url.test.ts covering auto-detection and the passthrough URL contract - 2 new tests in profile-router.test.ts covering the env var Verified end-to-end against api.kimi.com: a request through the modified proxy returned a real Kimi response (model kimi-k2p7-coding) with the original claude-cli/2.1.170 User-Agent preserved.
This commit is contained in:
@@ -12,6 +12,13 @@ export interface OpenAICompatProfileConfig {
|
|||||||
provider: DroidProvider;
|
provider: DroidProvider;
|
||||||
insecure?: boolean;
|
insecure?: boolean;
|
||||||
forceOpenAIReasoningModel?: 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;
|
model?: string;
|
||||||
opusModel?: string;
|
opusModel?: string;
|
||||||
sonnetModel?: string;
|
sonnetModel?: string;
|
||||||
@@ -30,6 +37,13 @@ export interface OpenAICompatProfileEnv {
|
|||||||
CCS_DROID_PROVIDER?: string;
|
CCS_DROID_PROVIDER?: string;
|
||||||
CCS_OPENAI_PROXY_INSECURE?: string;
|
CCS_OPENAI_PROXY_INSECURE?: string;
|
||||||
CCS_OPENAI_REASONING_MODEL?: 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 {
|
export function isOpenAICompatProvider(provider: DroidProvider | null): provider is DroidProvider {
|
||||||
@@ -72,6 +86,7 @@ export function resolveOpenAICompatProfileConfig(
|
|||||||
provider,
|
provider,
|
||||||
insecure: isTruthyEnv(env.CCS_OPENAI_PROXY_INSECURE),
|
insecure: isTruthyEnv(env.CCS_OPENAI_PROXY_INSECURE),
|
||||||
forceOpenAIReasoningModel: isTruthyEnv(env.CCS_OPENAI_REASONING_MODEL),
|
forceOpenAIReasoningModel: isTruthyEnv(env.CCS_OPENAI_REASONING_MODEL),
|
||||||
|
passthrough: isTruthyEnv(env.CCS_OPENAI_PROXY_PASSTHROUGH),
|
||||||
model: env.ANTHROPIC_MODEL?.trim() || undefined,
|
model: env.ANTHROPIC_MODEL?.trim() || undefined,
|
||||||
opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined,
|
opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined,
|
||||||
sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_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(
|
export async function pipeWebResponseToNode(
|
||||||
response: Response,
|
response: Response,
|
||||||
res: http.ServerResponse
|
res: http.ServerResponse
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import {
|
|||||||
type ProxyOpenAIRequest,
|
type ProxyOpenAIRequest,
|
||||||
} from '../transformers/request-transformer';
|
} from '../transformers/request-transformer';
|
||||||
import { ProxySseStreamTransformer } from '../transformers/sse-stream-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 { createLogger } from '../../services/logging';
|
||||||
import { pipeWebResponseToNode, readJsonBody, writeJson } from './http-helpers';
|
import { pipeWebResponseToNode, readJsonBody, readRawBody, writeJson } from './http-helpers';
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 600_000;
|
const REQUEST_TIMEOUT_MS = 600_000;
|
||||||
const DIRECT_OPENAI_REASONING_CHAT_MODEL = /^(?:gpt-5|o[134])(?:[-.]|$)/;
|
const DIRECT_OPENAI_REASONING_CHAT_MODEL = /^(?:gpt-5|o[134])(?:[-.]|$)/;
|
||||||
@@ -23,12 +23,45 @@ 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
|
||||||
|
): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${profile.apiKey}`,
|
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';
|
||||||
|
|
||||||
|
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 {
|
function isKnownOpenAIReasoningChatModel(model: string | undefined): boolean {
|
||||||
@@ -152,8 +185,41 @@ function shapeUpstreamChatPayload(
|
|||||||
|
|
||||||
function buildUpstreamRequest(
|
function buildUpstreamRequest(
|
||||||
profile: OpenAICompatProfileConfig,
|
profile: OpenAICompatProfileConfig,
|
||||||
rawBody: unknown
|
rawBody: unknown,
|
||||||
|
options: { passthrough?: boolean } = {}
|
||||||
): { body: 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');
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
return { body, route };
|
||||||
|
}
|
||||||
|
|
||||||
let transformed: ProxyOpenAIRequest;
|
let transformed: ProxyOpenAIRequest;
|
||||||
try {
|
try {
|
||||||
const transformer = new ProxyRequestTransformer();
|
const transformer = new ProxyRequestTransformer();
|
||||||
@@ -174,6 +240,16 @@ function buildUpstreamRequest(
|
|||||||
return { body: JSON.stringify(body), route };
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
export function extractIncomingProxyToken(headers: http.IncomingHttpHeaders): string | null {
|
export function extractIncomingProxyToken(headers: http.IncomingHttpHeaders): string | null {
|
||||||
const xApiKey = headers['x-api-key'];
|
const xApiKey = headers['x-api-key'];
|
||||||
if (typeof xApiKey === 'string' && xApiKey.trim().length > 0) {
|
if (typeof xApiKey === 'string' && xApiKey.trim().length > 0) {
|
||||||
@@ -206,11 +282,12 @@ function buildFetchInit(
|
|||||||
profile: OpenAICompatProfileConfig,
|
profile: OpenAICompatProfileConfig,
|
||||||
body: string,
|
body: string,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
|
incomingHeaders: http.IncomingHttpHeaders,
|
||||||
insecureDispatcher?: Dispatcher
|
insecureDispatcher?: Dispatcher
|
||||||
): RequestInit {
|
): RequestInit {
|
||||||
const init: RequestInit = {
|
const init: RequestInit = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: buildUpstreamHeaders(profile),
|
headers: buildUpstreamHeaders(profile, incomingHeaders),
|
||||||
body,
|
body,
|
||||||
signal,
|
signal,
|
||||||
};
|
};
|
||||||
@@ -319,10 +396,17 @@ export async function handleProxyMessagesRequest(
|
|||||||
logger.stage('auth', 'auth.ok', 'Proxy auth validated');
|
logger.stage('auth', 'auth.ok', 'Proxy auth validated');
|
||||||
|
|
||||||
let timeoutMs = REQUEST_TIMEOUT_MS;
|
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 {
|
try {
|
||||||
const rawBody = await readJsonBody(req);
|
const rawBody = passthrough ? await readRawBody(req) : await readJsonBody(req);
|
||||||
logger.stage('transform', 'request.transform.start', 'Transforming inbound proxy body');
|
logger.stage('transform', 'request.transform.start', 'Transforming inbound proxy body', {
|
||||||
const upstream = buildUpstreamRequest(profile, rawBody);
|
passthrough,
|
||||||
|
});
|
||||||
|
const upstream = buildUpstreamRequest(profile, rawBody, { passthrough });
|
||||||
logger.stage('route', 'request.routed', 'Resolved proxy upstream route', {
|
logger.stage('route', 'request.routed', 'Resolved proxy upstream route', {
|
||||||
profileName: upstream.route.profile.profileName,
|
profileName: upstream.route.profile.profileName,
|
||||||
provider: upstream.route.profile.provider,
|
provider: upstream.route.profile.provider,
|
||||||
@@ -371,17 +455,35 @@ export async function handleProxyMessagesRequest(
|
|||||||
profileName: profile.profileName,
|
profileName: profile.profileName,
|
||||||
routedProfileName: upstream.route.profile.profileName,
|
routedProfileName: upstream.route.profile.profileName,
|
||||||
insecureTls: dispatcher !== undefined,
|
insecureTls: dispatcher !== undefined,
|
||||||
|
passthrough,
|
||||||
|
});
|
||||||
|
const upstreamUrl = resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl, {
|
||||||
|
passthrough: isAnthropicPassthroughProfile(upstream.route.profile.baseUrl, {
|
||||||
|
forcePassthrough: upstream.route.profile.passthrough === true,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const upstreamResponse = await fetch(
|
const upstreamResponse = await fetch(
|
||||||
resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl),
|
upstreamUrl,
|
||||||
buildFetchInit(upstream.route.profile, upstream.body, controller.signal, dispatcher)
|
buildFetchInit(
|
||||||
|
upstream.route.profile,
|
||||||
|
upstream.body,
|
||||||
|
controller.signal,
|
||||||
|
req.headers,
|
||||||
|
dispatcher
|
||||||
|
)
|
||||||
);
|
);
|
||||||
logger.stage('upstream', 'upstream.response', 'Received upstream response', {
|
logger.stage('upstream', 'upstream.response', 'Received upstream response', {
|
||||||
profileName: profile.profileName,
|
profileName: profile.profileName,
|
||||||
routedProfileName: upstream.route.profile.profileName,
|
routedProfileName: upstream.route.profile.profileName,
|
||||||
status: upstreamResponse.status,
|
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);
|
await pipeWebResponseToNode(response, res);
|
||||||
logger.stage('respond', 'request.respond', 'Proxy response written', undefined, {
|
logger.stage('respond', 'request.respond', 'Proxy response written', undefined, {
|
||||||
latencyMs: Date.now() - startedAt,
|
latencyMs: Date.now() - startedAt,
|
||||||
|
|||||||
+121
-3
@@ -1,3 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 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, 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
function normalizePathname(pathname: string): string {
|
function normalizePathname(pathname: string): string {
|
||||||
const trimmed = pathname.replace(/\/+$/, '');
|
const trimmed = pathname.replace(/\/+$/, '');
|
||||||
return trimmed || '';
|
return trimmed || '';
|
||||||
@@ -9,16 +33,88 @@ function ensureSupportedProtocol(parsed: URL): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAnthropicApiHost(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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isOpenRouterHost(hostname: string): boolean {
|
function isOpenRouterHost(hostname: string): boolean {
|
||||||
const normalized = hostname.toLowerCase();
|
const normalized = hostname.toLowerCase();
|
||||||
return normalized === 'openrouter.ai' || normalized.endsWith('.openrouter.ai');
|
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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
const parsed = new URL(baseUrl);
|
||||||
ensureSupportedProtocol(parsed);
|
ensureSupportedProtocol(parsed);
|
||||||
|
|
||||||
const pathname = normalizePathname(parsed.pathname);
|
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)) {
|
if (pathname.endsWith(suffix)) {
|
||||||
return parsed.toString();
|
return parsed.toString();
|
||||||
}
|
}
|
||||||
@@ -37,10 +133,32 @@ function buildResolvedUrl(baseUrl: string, suffix: string): string {
|
|||||||
return parsed.toString();
|
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');
|
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');
|
return buildResolvedUrl(baseUrl, '/models');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,4 +93,36 @@ describe('resolveOpenAICompatProfileConfig', () => {
|
|||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result?.provider).toBe('generic-chat-completion-api');
|
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 { describe, expect, it } from 'bun:test';
|
||||||
import {
|
import {
|
||||||
|
isAnthropicPassthroughProfile,
|
||||||
resolveOpenAIChatCompletionsUrl,
|
resolveOpenAIChatCompletionsUrl,
|
||||||
resolveOpenAIModelsUrl,
|
resolveOpenAIModelsUrl,
|
||||||
} from '../../../src/proxy/upstream-url';
|
} from '../../../src/proxy/upstream-url';
|
||||||
@@ -29,3 +30,52 @@ 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 Kimi and MiniMax hosts as Anthropic-style', () => {
|
||||||
|
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 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.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user