mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-04 18:13:17 +00:00
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.
99 lines
3.1 KiB
TypeScript
99 lines
3.1 KiB
TypeScript
import {
|
|
inferDroidProviderFromBaseUrl,
|
|
resolveDroidProvider,
|
|
type DroidProvider,
|
|
} from '../targets/droid-provider';
|
|
|
|
export interface OpenAICompatProfileConfig {
|
|
profileName: string;
|
|
settingsPath: string;
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
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;
|
|
haikuModel?: string;
|
|
}
|
|
|
|
export interface OpenAICompatProfileEnv {
|
|
ANTHROPIC_BASE_URL?: string;
|
|
ANTHROPIC_AUTH_TOKEN?: string;
|
|
ANTHROPIC_API_KEY?: string;
|
|
ANTHROPIC_MODEL?: string;
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL?: string;
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL?: string;
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL?: string;
|
|
ANTHROPIC_SMALL_FAST_MODEL?: string;
|
|
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 {
|
|
return provider === 'openai' || provider === 'generic-chat-completion-api';
|
|
}
|
|
|
|
function isTruthyEnv(value: string | undefined): boolean {
|
|
const normalized = value?.trim().toLowerCase();
|
|
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
|
|
}
|
|
|
|
export function resolveOpenAICompatProfileConfig(
|
|
profileName: string,
|
|
settingsPath: string,
|
|
env: OpenAICompatProfileEnv
|
|
): OpenAICompatProfileConfig | null {
|
|
const baseUrl = env.ANTHROPIC_BASE_URL?.trim() || '';
|
|
const apiKey = env.ANTHROPIC_AUTH_TOKEN?.trim() || env.ANTHROPIC_API_KEY?.trim() || '';
|
|
if (!baseUrl || !apiKey) {
|
|
return null;
|
|
}
|
|
|
|
const providerFromUrl = inferDroidProviderFromBaseUrl(baseUrl);
|
|
const provider = isOpenAICompatProvider(providerFromUrl)
|
|
? providerFromUrl
|
|
: resolveDroidProvider({
|
|
provider: env.CCS_DROID_PROVIDER,
|
|
baseUrl,
|
|
model: env.ANTHROPIC_MODEL,
|
|
});
|
|
if (!isOpenAICompatProvider(provider)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
profileName,
|
|
settingsPath,
|
|
baseUrl,
|
|
apiKey,
|
|
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,
|
|
haikuModel:
|
|
env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.trim() ||
|
|
env.ANTHROPIC_SMALL_FAST_MODEL?.trim() ||
|
|
undefined,
|
|
};
|
|
}
|