mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-19 12:16:10 +00:00
feat(proxy): complete openai routing scope
This commit is contained in:
1 parent
b346693c3a
commit
17187c4abd
24 files changed
+1819
-53
No files matched your search
@@ -68,20 +68,42 @@ describe('proxy command e2e', () => {
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const started = runCli(['proxy', 'start', 'hf', '--port', String(port)]);
|
||||
const started = runCli(['proxy', 'start', 'hf', '--port', String(port), '--host', '127.0.0.1']);
|
||||
expect(started.status).toBe(0);
|
||||
|
||||
const status = runCli(['proxy', 'status']);
|
||||
expect(status.stdout).toContain(`Proxy running on port ${port}`);
|
||||
expect(status.stdout).toContain('Host: 127.0.0.1');
|
||||
expect(status.stdout).toContain('Profile: hf');
|
||||
|
||||
const activate = runCli(['proxy', 'activate', '--shell', 'bash']);
|
||||
expect(activate.stdout).toContain(`export ANTHROPIC_BASE_URL='http://127.0.0.1:${port}'`);
|
||||
expect(activate.stdout).toMatch(/export ANTHROPIC_AUTH_TOKEN='[a-f0-9]{48}'/);
|
||||
expect(activate.stdout).toContain("export DISABLE_TELEMETRY='1'");
|
||||
expect(activate.stdout).toContain("export DISABLE_COST_WARNINGS='1'");
|
||||
expect(activate.stdout).toContain("export API_TIMEOUT_MS='600000'");
|
||||
expect(activate.stdout).toContain("export NO_PROXY='127.0.0.1,localhost'");
|
||||
|
||||
const activateFish = runCli(['proxy', 'activate', '--fish']);
|
||||
expect(activateFish.stdout).toContain(`set -gx ANTHROPIC_BASE_URL 'http://127.0.0.1:${port}'`);
|
||||
|
||||
const health = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
expect(health.status).toBe(200);
|
||||
|
||||
const info = await fetch(`http://127.0.0.1:${port}/`);
|
||||
expect(info.status).toBe(200);
|
||||
await expect(info.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
service: 'ccs-openai-compat-proxy',
|
||||
bind: {
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
},
|
||||
profile: {
|
||||
name: 'hf',
|
||||
},
|
||||
});
|
||||
|
||||
const stopped = runCli(['proxy', 'stop']);
|
||||
expect(stopped.status).toBe(0);
|
||||
}, 35000);
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import getPort from 'get-port';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server';
|
||||
import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router';
|
||||
|
||||
let proxyServer: http.Server;
|
||||
let upstreamServer: http.Server;
|
||||
let upstreamSockets = new Set<import('net').Socket>();
|
||||
let upstreamPort: number;
|
||||
let proxyPort: number;
|
||||
let tempDir: string;
|
||||
let originalTimeoutEnv: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
async function startUpstream(
|
||||
handler: (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void> | void
|
||||
): Promise<void> {
|
||||
upstreamServer = http.createServer((req, res) => {
|
||||
void Promise.resolve(handler(req, res));
|
||||
});
|
||||
upstreamServer.on('connection', (socket) => {
|
||||
upstreamSockets.add(socket);
|
||||
socket.on('close', () => {
|
||||
upstreamSockets.delete(socket);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve())
|
||||
);
|
||||
}
|
||||
|
||||
async function requestProxy(payload: unknown, signal?: AbortSignal): Promise<Response> {
|
||||
return fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': 'test-proxy-token',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
upstreamPort = await getPort();
|
||||
proxyPort = await getPort();
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-edge-'));
|
||||
originalTimeoutEnv = process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalTimeoutEnv !== undefined) {
|
||||
process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS = originalTimeoutEnv;
|
||||
} else {
|
||||
delete process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (proxyServer) {
|
||||
await new Promise<void>((resolve) => proxyServer.close(() => resolve()));
|
||||
}
|
||||
if (upstreamServer) {
|
||||
for (const socket of upstreamSockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
upstreamSockets = new Set();
|
||||
await new Promise<void>((resolve) => upstreamServer.close(() => resolve()));
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('openai proxy message edge cases', () => {
|
||||
async function startProxyWithHandler(
|
||||
handler: (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void> | void
|
||||
) {
|
||||
await startUpstream(handler);
|
||||
const profile: OpenAICompatProfileConfig = {
|
||||
profileName: 'hf',
|
||||
settingsPath: '/tmp/hf.settings.json',
|
||||
baseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
apiKey: 'hf_token',
|
||||
provider: 'generic-chat-completion-api',
|
||||
model: 'hf-model',
|
||||
};
|
||||
proxyServer = startOpenAICompatProxyServer({
|
||||
profile,
|
||||
port: proxyPort,
|
||||
authToken: 'test-proxy-token',
|
||||
});
|
||||
}
|
||||
|
||||
it('preserves rate-limit errors from the upstream provider', async () => {
|
||||
await startProxyWithHandler((_req, res) => {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '9',
|
||||
});
|
||||
res.end(JSON.stringify({ error: { message: 'rate limited' } }));
|
||||
});
|
||||
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.headers.get('retry-after')).toBe('9');
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'rate_limit_error',
|
||||
message: 'rate limited',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns api_error when the upstream JSON response has no usable choices', async () => {
|
||||
await startProxyWithHandler((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ id: 'chatcmpl_empty', choices: [] }));
|
||||
});
|
||||
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: 'Failed to translate Cursor JSON response',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('streams thinking deltas and chunked tool-call arguments back as Anthropic SSE', async () => {
|
||||
await startProxyWithHandler((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant"}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"reasoning_content":"Need to search first."}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\""}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"docs\\"}"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":8,"completion_tokens":6}}\n\n'
|
||||
);
|
||||
res.end('data: [DONE]\n\n');
|
||||
});
|
||||
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'search docs' }],
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toContain('"type":"thinking_delta"');
|
||||
expect(body).toContain('"type":"tool_use"');
|
||||
expect(body).toContain('"partial_json":"{\\"q\\":\\""');
|
||||
expect(body).toContain('"partial_json":"docs\\"}"');
|
||||
expect(body).toContain('event: message_stop');
|
||||
});
|
||||
|
||||
it('returns a timeout error when the upstream does not respond in time', async () => {
|
||||
process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS = '50';
|
||||
await startProxyWithHandler(async (req, _res) => {
|
||||
await new Promise<void>((resolve) => req.on('close', () => resolve()));
|
||||
});
|
||||
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: 'The upstream provider did not respond within 50ms',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('aborts the upstream request when the client disconnects mid-flight', async () => {
|
||||
await startProxyWithHandler(() => {});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const request = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: proxyPort,
|
||||
path: '/v1/messages',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': 'test-proxy-token',
|
||||
},
|
||||
},
|
||||
() => resolve()
|
||||
);
|
||||
|
||||
request.write(
|
||||
JSON.stringify({
|
||||
model: 'hf-model',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
})
|
||||
);
|
||||
request.end();
|
||||
|
||||
setTimeout(() => {
|
||||
request.destroy(new Error('client aborted'));
|
||||
resolve();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
const logPath = path.join(tempDir, '.ccs', 'logs', 'current.jsonl');
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
if (fs.existsSync(logPath)) {
|
||||
const content = fs.readFileSync(logPath, 'utf8');
|
||||
if (content.includes('"event":"request.disconnect"')) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - startedAt > 1500) {
|
||||
clearInterval(timer);
|
||||
reject(new Error('proxy did not log disconnect cleanup'));
|
||||
}
|
||||
}, 50);
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -173,16 +173,44 @@ describe('openai proxy messages endpoint', () => {
|
||||
expect(body.error?.type).toBe('authentication_error');
|
||||
});
|
||||
|
||||
it('returns invalid_request_error for unsupported Anthropic content blocks', async () => {
|
||||
it('translates supported Anthropic image blocks into OpenAI image_url content', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
messages: [{ role: 'user', content: [{ type: 'image' }] }],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Describe this' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'ZmFrZQ==',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const body = (await response.json()) as { error?: { type?: string; message?: string } };
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error?.type).toBe('invalid_request_error');
|
||||
expect(body.error?.message).toContain('is not supported');
|
||||
expect(response.status).toBe(200);
|
||||
const parsedUpstream = upstreamBody as {
|
||||
messages?: Array<{
|
||||
role: string;
|
||||
content: Array<{ type: string; text?: string; image_url?: { url?: string } }>;
|
||||
}>;
|
||||
};
|
||||
expect(parsedUpstream.messages?.[0]).toEqual({
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Describe this' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,ZmFrZQ==' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts query strings and trailing slashes on the messages route', async () => {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import getPort from 'get-port';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server';
|
||||
import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
let proxyServer: http.Server;
|
||||
let upstreamServers: http.Server[] = [];
|
||||
let proxyPort: number;
|
||||
|
||||
function startMockUpstream(
|
||||
port: number,
|
||||
hitLabel: string,
|
||||
hits: string[],
|
||||
bodies: Array<{ label: string; body: unknown }>
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let body = '';
|
||||
for await (const chunk of req) {
|
||||
body += chunk.toString();
|
||||
}
|
||||
hits.push(hitLabel);
|
||||
bodies.push({ label: hitLabel, body: JSON.parse(body) });
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: `chatcmpl_${hitLabel}`,
|
||||
model: hitLabel,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: `Reply from ${hitLabel}` },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 2, completion_tokens: 3 },
|
||||
})
|
||||
);
|
||||
});
|
||||
upstreamServers.push(server);
|
||||
server.listen(port, '127.0.0.1', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function writeSettings(profileName: string, env: Record<string, string>): string {
|
||||
const settingsPath = path.join(tempDir, '.ccs', `${profileName}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2), 'utf8');
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
async function requestProxy(payload: unknown): Promise<Response> {
|
||||
return fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': 'test-proxy-token',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-routing-'));
|
||||
fs.mkdirSync(path.join(tempDir, '.ccs'), { recursive: true });
|
||||
process.env.CCS_HOME = tempDir;
|
||||
proxyPort = await getPort();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
upstreamServers.map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
})
|
||||
)
|
||||
);
|
||||
upstreamServers = [];
|
||||
if (proxyServer) {
|
||||
await new Promise<void>((resolve) => proxyServer.close(() => resolve()));
|
||||
}
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('openai proxy request routing', () => {
|
||||
it('routes explicit profile:model selectors to the matching upstream profile', async () => {
|
||||
const primaryPort = await getPort();
|
||||
const secondaryPort = await getPort();
|
||||
const hits: string[] = [];
|
||||
const bodies: Array<{ label: string; body: unknown }> = [];
|
||||
await startMockUpstream(primaryPort, 'primary', hits, bodies);
|
||||
await startMockUpstream(secondaryPort, 'secondary', hits, bodies);
|
||||
|
||||
const primarySettings = writeSettings('hf', {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`,
|
||||
ANTHROPIC_AUTH_TOKEN: 'hf_token',
|
||||
ANTHROPIC_MODEL: 'hf-default',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
const secondarySettings = writeSettings('deepseek', {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${secondaryPort}`,
|
||||
ANTHROPIC_AUTH_TOKEN: 'deepseek_token',
|
||||
ANTHROPIC_MODEL: 'deepseek-chat',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, '.ccs', 'config.json'),
|
||||
JSON.stringify({ profiles: { hf: primarySettings, deepseek: secondarySettings } }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile: OpenAICompatProfileConfig = {
|
||||
profileName: 'hf',
|
||||
settingsPath: primarySettings,
|
||||
baseUrl: `http://127.0.0.1:${primaryPort}`,
|
||||
apiKey: 'hf_token',
|
||||
provider: 'generic-chat-completion-api',
|
||||
model: 'hf-default',
|
||||
};
|
||||
proxyServer = startOpenAICompatProxyServer({
|
||||
profile,
|
||||
port: proxyPort,
|
||||
authToken: 'test-proxy-token',
|
||||
});
|
||||
|
||||
const response = await requestProxy({
|
||||
model: 'deepseek:deepseek-reasoner',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
content: [{ type: 'text', text: 'Reply from secondary' }],
|
||||
});
|
||||
expect(hits).toEqual(['secondary']);
|
||||
expect(bodies[0]?.body).toMatchObject({ model: 'deepseek-reasoner' });
|
||||
});
|
||||
|
||||
it('routes thinking requests through the configured think scenario', async () => {
|
||||
const primaryPort = await getPort();
|
||||
const thinkPort = await getPort();
|
||||
const hits: string[] = [];
|
||||
const bodies: Array<{ label: string; body: unknown }> = [];
|
||||
await startMockUpstream(primaryPort, 'primary', hits, bodies);
|
||||
await startMockUpstream(thinkPort, 'thinker', hits, bodies);
|
||||
|
||||
const primarySettings = writeSettings('hf', {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`,
|
||||
ANTHROPIC_AUTH_TOKEN: 'hf_token',
|
||||
ANTHROPIC_MODEL: 'hf-default',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
const thinkSettings = writeSettings('thinker', {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${thinkPort}`,
|
||||
ANTHROPIC_AUTH_TOKEN: 'think_token',
|
||||
ANTHROPIC_MODEL: 'deepseek-reasoner',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, '.ccs', 'config.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
profiles: { hf: primarySettings, thinker: thinkSettings },
|
||||
proxy: {
|
||||
routing: {
|
||||
think: 'thinker:deepseek-reasoner',
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile: OpenAICompatProfileConfig = {
|
||||
profileName: 'hf',
|
||||
settingsPath: primarySettings,
|
||||
baseUrl: `http://127.0.0.1:${primaryPort}`,
|
||||
apiKey: 'hf_token',
|
||||
provider: 'generic-chat-completion-api',
|
||||
model: 'hf-default',
|
||||
};
|
||||
proxyServer = startOpenAICompatProxyServer({
|
||||
profile,
|
||||
port: proxyPort,
|
||||
authToken: 'test-proxy-token',
|
||||
});
|
||||
|
||||
const response = await requestProxy({
|
||||
model: 'hf-default',
|
||||
thinking: { type: 'enabled', budget_tokens: 9000 },
|
||||
messages: [{ role: 'user', content: 'think hard' }],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
content: [{ type: 'text', text: 'Reply from thinker' }],
|
||||
});
|
||||
expect(hits).toEqual(['thinker']);
|
||||
expect(bodies[0]?.body).toMatchObject({ model: 'deepseek-reasoner' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { resolveProxyRequestRoute } from '../../../src/proxy/request-router';
|
||||
import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router';
|
||||
import { loadSettings } from '../../../src/utils/config-manager';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
function writeSettings(profileName: string, env: Record<string, string>): string {
|
||||
const settingsPath = path.join(tempDir, '.ccs', `${profileName}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2), 'utf8');
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
function buildProfile(profileName: string) {
|
||||
const settingsPath = path.join(tempDir, '.ccs', `${profileName}.settings.json`);
|
||||
const profile = resolveOpenAICompatProfileConfig(
|
||||
profileName,
|
||||
settingsPath,
|
||||
loadSettings(settingsPath).env || {}
|
||||
);
|
||||
expect(profile).toBeTruthy();
|
||||
return profile!;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-router-'));
|
||||
fs.mkdirSync(path.join(tempDir, '.ccs'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveProxyRequestRoute', () => {
|
||||
beforeEach(() => {
|
||||
process.env.CCS_HOME = tempDir;
|
||||
|
||||
const profiles = {
|
||||
hf: writeSettings('hf', {
|
||||
ANTHROPIC_BASE_URL: 'https://router.huggingface.co/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'hf_token',
|
||||
ANTHROPIC_MODEL: 'hf-default',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
}),
|
||||
deepseek: writeSettings('deepseek', {
|
||||
ANTHROPIC_BASE_URL: 'https://api.deepseek.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'deepseek_token',
|
||||
ANTHROPIC_MODEL: 'deepseek-chat',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
}),
|
||||
thinker: writeSettings('thinker', {
|
||||
ANTHROPIC_BASE_URL: 'https://thinking.example.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'think_token',
|
||||
ANTHROPIC_MODEL: 'deepseek-reasoner',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
}),
|
||||
search: writeSettings('search', {
|
||||
ANTHROPIC_BASE_URL: 'https://search.example.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'search_token',
|
||||
ANTHROPIC_MODEL: 'sonar-pro',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
}),
|
||||
background: writeSettings('background', {
|
||||
ANTHROPIC_BASE_URL: 'https://background.example.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'background_token',
|
||||
ANTHROPIC_MODEL: 'qwen-small',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
}),
|
||||
long: writeSettings('long', {
|
||||
ANTHROPIC_BASE_URL: 'https://long.example.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'long_token',
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-pro',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
}),
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, '.ccs', 'config.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
profiles,
|
||||
proxy: {
|
||||
routing: {
|
||||
default: 'hf:hf-default',
|
||||
background: 'background:qwen-small',
|
||||
think: 'thinker:deepseek-reasoner',
|
||||
longContext: 'long:gemini-2.5-pro',
|
||||
longContextThreshold: 10,
|
||||
webSearch: 'search:sonar-pro',
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses explicit profile:model selectors', () => {
|
||||
const route = resolveProxyRequestRoute(buildProfile('hf'), {
|
||||
model: 'deepseek:deepseek-reasoner',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'plan this' }],
|
||||
});
|
||||
|
||||
expect(route.profile.profileName).toBe('deepseek');
|
||||
expect(route.model).toBe('deepseek-reasoner');
|
||||
expect(route.source).toBe('explicit-profile');
|
||||
});
|
||||
|
||||
it('matches plain model ids to the profile that owns them', () => {
|
||||
const route = resolveProxyRequestRoute(buildProfile('hf'), {
|
||||
model: 'deepseek-chat',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
|
||||
expect(route.profile.profileName).toBe('deepseek');
|
||||
expect(route.model).toBe('deepseek-chat');
|
||||
expect(route.source).toBe('profile-model-match');
|
||||
});
|
||||
|
||||
it('routes thinking requests through the configured think scenario', () => {
|
||||
const route = resolveProxyRequestRoute(buildProfile('hf'), {
|
||||
model: 'hf-default',
|
||||
stream: true,
|
||||
reasoning_effort: 'high',
|
||||
reasoning: { enabled: true, effort: 'high' },
|
||||
messages: [{ role: 'user', content: 'work this out carefully' }],
|
||||
});
|
||||
|
||||
expect(route.profile.profileName).toBe('thinker');
|
||||
expect(route.model).toBe('deepseek-reasoner');
|
||||
expect(route.scenario).toBe('think');
|
||||
expect(route.source).toBe('scenario');
|
||||
});
|
||||
|
||||
it('routes long-context requests through the configured longContext scenario', () => {
|
||||
const route = resolveProxyRequestRoute(buildProfile('hf'), {
|
||||
model: 'hf-default',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'x'.repeat(120) }],
|
||||
});
|
||||
|
||||
expect(route.profile.profileName).toBe('long');
|
||||
expect(route.model).toBe('gemini-2.5-pro');
|
||||
expect(route.scenario).toBe('longContext');
|
||||
expect(route.estimatedTokens).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('routes web_search tool requests through the configured webSearch scenario', () => {
|
||||
const route = resolveProxyRequestRoute(buildProfile('hf'), {
|
||||
model: 'hf-default',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'search the docs' }],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'web_search',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(route.profile.profileName).toBe('search');
|
||||
expect(route.model).toBe('sonar-pro');
|
||||
expect(route.scenario).toBe('webSearch');
|
||||
});
|
||||
|
||||
it('routes haiku requests through the configured background scenario', () => {
|
||||
const route = resolveProxyRequestRoute(buildProfile('hf'), {
|
||||
model: 'claude-3-haiku',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'run this in the background' }],
|
||||
});
|
||||
|
||||
expect(route.profile.profileName).toBe('background');
|
||||
expect(route.model).toBe('qwen-small');
|
||||
expect(route.scenario).toBe('background');
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ describe('ProxyRequestTransformer', () => {
|
||||
|
||||
expect(result.stream).toBe(true);
|
||||
expect(result.reasoning_effort).toBe('high');
|
||||
expect(result.reasoning).toEqual({ enabled: true, effort: 'high' });
|
||||
expect(result.max_tokens).toBe(1024);
|
||||
expect(result.temperature).toBe(0.2);
|
||||
expect(result.top_p).toBe(0.9);
|
||||
@@ -42,6 +43,43 @@ describe('ProxyRequestTransformer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('translates base64 image blocks into OpenAI image_url parts', () => {
|
||||
const transformer = new ProxyRequestTransformer();
|
||||
const result = transformer.transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Describe this image' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'ZmFrZS1pbWFnZS1ieXRlcw==',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Describe this image' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'data:image/png;base64,ZmFrZS1pbWFnZS1ieXRlcw==',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops malformed optional fields but preserves the translated core request', () => {
|
||||
const transformer = new ProxyRequestTransformer();
|
||||
const result = transformer.transform({
|
||||
|
||||
Reference in new issue
Block a user