mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 16:16:37 +00:00
189 lines
5.9 KiB
TypeScript
189 lines
5.9 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as http from 'http';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { spawn, spawnSync } from 'child_process';
|
|
import getPort from 'get-port';
|
|
|
|
const DIST_ENTRY = path.join(process.cwd(), 'dist', 'ccs.js');
|
|
|
|
let originalCcsHome: string | undefined;
|
|
let tempDir: string;
|
|
let upstreamServer: http.Server;
|
|
let upstreamBody: unknown;
|
|
|
|
beforeEach(() => {
|
|
originalCcsHome = process.env.CCS_HOME;
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-e2e-'));
|
|
upstreamBody = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
try {
|
|
upstreamServer?.close();
|
|
} catch {
|
|
// Best-effort cleanup.
|
|
}
|
|
spawnSync(process.execPath, [DIST_ENTRY, 'proxy', 'stop'], {
|
|
env: { ...process.env, CCS_HOME: tempDir },
|
|
});
|
|
if (originalCcsHome !== undefined) {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
} else {
|
|
delete process.env.CCS_HOME;
|
|
}
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function startMockUpstream(port: number): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
upstreamServer = http.createServer(async (req, res) => {
|
|
let body = '';
|
|
for await (const chunk of req) {
|
|
body += chunk.toString();
|
|
}
|
|
upstreamBody = JSON.parse(body);
|
|
|
|
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
res.write(
|
|
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\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\\":\\"docs\\"}"}}]}}]}\n\n'
|
|
);
|
|
res.write(
|
|
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n'
|
|
);
|
|
res.end('data: [DONE]\n\n');
|
|
});
|
|
upstreamServer.listen(port, '127.0.0.1', () => resolve());
|
|
});
|
|
}
|
|
|
|
function runCli(args: string[], env: Record<string, string>): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn(process.execPath, [DIST_ENTRY, ...args], {
|
|
env: {
|
|
...process.env,
|
|
...env,
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
child.on('close', (code) => {
|
|
resolve({ code, stdout, stderr });
|
|
});
|
|
});
|
|
}
|
|
|
|
beforeAll(() => {
|
|
const result = spawnSync(process.execPath, ['run', 'build'], {
|
|
encoding: 'utf8',
|
|
env: process.env,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
});
|
|
|
|
describe('openai provider routing e2e', () => {
|
|
it('routes a settings profile through the local proxy into an OpenAI-compatible upstream', async () => {
|
|
const upstreamPort = await getPort();
|
|
await startMockUpstream(upstreamPort);
|
|
|
|
const ccsDir = path.join(tempDir, '.ccs');
|
|
const binDir = path.join(tempDir, 'bin');
|
|
const outputPath = path.join(tempDir, 'claude-output.json');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
|
|
const settingsPath = path.join(ccsDir, 'hf.settings.json');
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'config.json'),
|
|
JSON.stringify({ profiles: { hf: settingsPath } }, null, 2),
|
|
'utf8'
|
|
);
|
|
fs.writeFileSync(
|
|
settingsPath,
|
|
JSON.stringify({
|
|
env: {
|
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
|
|
ANTHROPIC_AUTH_TOKEN: 'hf_token',
|
|
ANTHROPIC_MODEL: 'hf-model',
|
|
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
|
},
|
|
}),
|
|
'utf8'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(binDir, 'claude'),
|
|
`#!/usr/bin/env node
|
|
const fs = require('fs');
|
|
(async () => {
|
|
const response = await fetch(\`\${process.env.ANTHROPIC_BASE_URL}/v1/messages\`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'anthropic-version': '2023-06-01',
|
|
'x-api-key': process.env.ANTHROPIC_AUTH_TOKEN,
|
|
},
|
|
body: JSON.stringify({
|
|
model: process.env.ANTHROPIC_MODEL,
|
|
stream: true,
|
|
tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }],
|
|
messages: [{ role: 'user', content: 'Find docs' }],
|
|
}),
|
|
});
|
|
const text = await response.text();
|
|
fs.writeFileSync(process.env.CCS_E2E_OUTPUT, JSON.stringify({
|
|
status: response.status,
|
|
baseUrl: process.env.ANTHROPIC_BASE_URL,
|
|
authToken: process.env.ANTHROPIC_AUTH_TOKEN,
|
|
text
|
|
}, null, 2));
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
`,
|
|
{ mode: 0o755 }
|
|
);
|
|
|
|
const result = await runCli(['hf'], {
|
|
...process.env,
|
|
CCS_HOME: tempDir,
|
|
CCS_E2E_OUTPUT: outputPath,
|
|
PATH: `${binDir}:${process.env.PATH || ''}`,
|
|
});
|
|
|
|
expect(result.code).toBe(0);
|
|
const payload = JSON.parse(fs.readFileSync(outputPath, 'utf8')) as {
|
|
status: number;
|
|
baseUrl: string;
|
|
authToken: string;
|
|
text: string;
|
|
};
|
|
|
|
expect(payload.status).toBe(200);
|
|
expect(payload.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
|
expect(payload.authToken).toMatch(/^[a-f0-9]{48}$/);
|
|
expect(payload.text).toContain('event: message_start');
|
|
expect(payload.text).toContain('tool_use');
|
|
expect(payload.text).toContain('message_stop');
|
|
|
|
const parsedUpstream = upstreamBody as {
|
|
messages?: Array<{ role: string; content: string }>;
|
|
tools?: Array<{ type: string }>;
|
|
};
|
|
expect(parsedUpstream.messages?.[0]).toEqual({ role: 'user', content: 'Find docs' });
|
|
expect(parsedUpstream.tools?.[0]?.type).toBe('function');
|
|
}, 35000);
|
|
});
|