mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 18:18:21 +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.
82 lines
3.2 KiB
TypeScript
82 lines
3.2 KiB
TypeScript
import { describe, expect, it } from 'bun:test';
|
|
import {
|
|
isAnthropicPassthroughProfile,
|
|
resolveOpenAIChatCompletionsUrl,
|
|
resolveOpenAIModelsUrl,
|
|
} from '../../../src/proxy/upstream-url';
|
|
|
|
describe('OpenAI-compatible upstream URL resolution', () => {
|
|
it('routes current OpenRouter API roots through /api/v1', () => {
|
|
expect(resolveOpenAIChatCompletionsUrl('https://openrouter.ai/api/v1')).toBe(
|
|
'https://openrouter.ai/api/v1/chat/completions'
|
|
);
|
|
expect(resolveOpenAIModelsUrl('https://openrouter.ai/api/v1')).toBe(
|
|
'https://openrouter.ai/api/v1/models'
|
|
);
|
|
});
|
|
|
|
it('repairs legacy OpenRouter /api roots before appending OpenAI endpoints', () => {
|
|
expect(resolveOpenAIChatCompletionsUrl('https://openrouter.ai/api')).toBe(
|
|
'https://openrouter.ai/api/v1/chat/completions'
|
|
);
|
|
expect(resolveOpenAIModelsUrl('https://openrouter.ai/api')).toBe(
|
|
'https://openrouter.ai/api/v1/models'
|
|
);
|
|
});
|
|
|
|
it('does not rewrite non-OpenRouter /api roots', () => {
|
|
expect(resolveOpenAIChatCompletionsUrl('https://example.test/api')).toBe(
|
|
'https://example.test/api/chat/completions'
|
|
);
|
|
});
|
|
});
|
|
|
|
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.
|
|
});
|
|
});
|