diff --git a/tests/e2e/openai-provider-routing.e2e.test.ts b/tests/e2e/openai-provider-routing.e2e.test.ts new file mode 100644 index 00000000..cc2d823e --- /dev/null +++ b/tests/e2e/openai-provider-routing.e2e.test.ts @@ -0,0 +1,188 @@ +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 { + 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): 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); +}); diff --git a/tests/e2e/proxy-command.e2e.test.ts b/tests/e2e/proxy-command.e2e.test.ts new file mode 100644 index 00000000..550ee8a2 --- /dev/null +++ b/tests/e2e/proxy-command.e2e.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { 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; + +function runCli(args: string[], extraEnv: Record = {}) { + return spawnSync(process.execPath, [DIST_ENTRY, ...args], { + encoding: 'utf8', + env: { + ...process.env, + CCS_HOME: tempDir, + ...extraEnv, + }, + }); +} + +beforeAll(() => { + const result = spawnSync(process.execPath, ['run', 'build'], { + encoding: 'utf8', + env: process.env, + }); + expect(result.status).toBe(0); +}); + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-e2e-')); +}); + +afterEach(() => { + runCli(['proxy', 'stop']); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('proxy command e2e', () => { + it('starts, reports status, activates, and stops via the built CLI', async () => { + const port = await getPort(); + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { 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:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const started = runCli(['proxy', 'start', 'hf', '--port', String(port)]); + expect(started.status).toBe(0); + + const status = runCli(['proxy', 'status']); + expect(status.stdout).toContain(`Proxy running on port ${port}`); + 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}'/); + + const health = await fetch(`http://127.0.0.1:${port}/health`); + expect(health.status).toBe(200); + + const stopped = runCli(['proxy', 'stop']); + expect(stopped.status).toBe(0); + }, 35000); +}); diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts new file mode 100644 index 00000000..5a5aa86d --- /dev/null +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -0,0 +1,127 @@ +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 getPort from 'get-port'; +import { + getOpenAICompatProxyStatus, + startOpenAICompatProxy, + stopOpenAICompatProxy, +} from '../../../src/proxy/proxy-daemon'; +import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; + +let originalCcsHome: string | undefined; +let tempDir: string; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-openai-proxy-')); + process.env.CCS_HOME = tempDir; +}); + +afterEach(async () => { + await stopOpenAICompatProxy(); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('openai proxy daemon lifecycle', () => { + it('starts, reports status, serves health/models, and stops', async () => { + const port = await getPort(); + const settingsPath = path.join(tempDir, 'hf.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('hf', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected an OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile, { port }); + expect(started.success).toBe(true); + expect(started.authToken).toBeTruthy(); + + const status = await getOpenAICompatProxyStatus(); + expect(status.running).toBe(true); + expect(status.profileName).toBe('hf'); + expect(status.authToken).toBe(started.authToken); + + const health = await fetch(`http://127.0.0.1:${port}/health`); + expect(health.status).toBe(200); + + const models = (await ( + await fetch(`http://127.0.0.1:${port}/v1/models`, { + headers: { 'x-api-key': started.authToken! }, + }) + ).json()) as { data?: Array<{ id: string }> }; + expect(models.data?.map((entry) => entry.id)).toEqual(['qwen3-coder']); + + const stopped = await stopOpenAICompatProxy(); + expect(stopped.success).toBe(true); + expect((await getOpenAICompatProxyStatus()).running).toBe(false); + }, 35000); + + it('refuses to replace a running proxy for a different profile', async () => { + const firstPort = await getPort(); + const firstSettingsPath = path.join(tempDir, 'hf.settings.json'); + fs.writeFileSync( + firstSettingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + const firstProfile = resolveOpenAICompatProfileConfig('hf', firstSettingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!firstProfile) { + throw new Error('Expected first OpenAI-compatible profile'); + } + + const firstStart = await startOpenAICompatProxy(firstProfile, { port: firstPort }); + expect(firstStart.success).toBe(true); + + const secondProfile = resolveOpenAICompatProfileConfig('openai', path.join(tempDir, 'openai.settings.json'), { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-openai', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + if (!secondProfile) { + throw new Error('Expected second OpenAI-compatible profile'); + } + + const secondStart = await startOpenAICompatProxy(secondProfile, { port: await getPort() }); + expect(secondStart.success).toBe(false); + expect(secondStart.error).toContain('Proxy already running for profile "hf"'); + + const health = await fetch(`http://127.0.0.1:${firstPort}/health`); + expect(health.status).toBe(200); + }); +}); diff --git a/tests/integration/proxy/messages-endpoint.test.ts b/tests/integration/proxy/messages-endpoint.test.ts new file mode 100644 index 00000000..ee96ce4c --- /dev/null +++ b/tests/integration/proxy/messages-endpoint.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import getPort from 'get-port'; +import * as http from 'http'; +import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server'; +import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; + +let upstreamServer: http.Server; +let proxyServer: http.Server; +let upstreamBody: unknown; +let upstreamPort: number; +let proxyPort: number; + +function startMockUpstream(): Promise { + return new Promise((resolve) => { + upstreamServer = http.createServer(async (req, res) => { + if (req.method !== 'POST' || req.url !== '/v1/chat/completions') { + res.writeHead(404).end(); + return; + } + + let body = ''; + for await (const chunk of req) { + body += chunk.toString(); + } + upstreamBody = JSON.parse(body); + const parsed = upstreamBody as { stream?: boolean }; + + if (parsed.stream) { + 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'); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'chatcmpl_1', + model: 'hf-model', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Plain answer' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 3 }, + }) + ); + }); + + upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve()); + }); +} + +async function requestProxy(payload: unknown): Promise { + return fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': 'test-proxy-token', + }, + body: JSON.stringify(payload), + }); +} + +beforeEach(async () => { + upstreamPort = await getPort(); + proxyPort = await getPort(); + upstreamBody = undefined; + await startMockUpstream(); + 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', + }); +}); + +afterEach(async () => { + await new Promise((resolve) => proxyServer.close(() => resolve())); + await new Promise((resolve) => upstreamServer.close(() => resolve())); +}); + +describe('openai proxy messages endpoint', () => { + it('translates Anthropic requests to OpenAI upstream and streams Anthropic SSE back', async () => { + const response = await requestProxy({ + model: 'hf-model', + stream: true, + messages: [{ role: 'user', content: [{ type: 'text', text: 'Find docs' }] }], + tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }], + }); + + const body = await response.text(); + expect(response.status).toBe(200); + expect(body).toContain('event: message_start'); + expect(body).toContain('content_block_delta'); + expect(body).toContain('tool_use'); + expect(body).toContain('message_stop'); + + const parsedUpstream = upstreamBody as { + messages?: Array<{ role: string; content: string }>; + tools?: Array<{ type: string; function: { name: string } }>; + }; + expect(parsedUpstream.messages?.[0]).toEqual({ role: 'user', content: 'Find docs' }); + expect(parsedUpstream.tools?.[0]?.type).toBe('function'); + expect(parsedUpstream.tools?.[0]?.function.name).toBe('search'); + }); + + it('falls back to Anthropic JSON for non-streaming requests', async () => { + const response = await requestProxy({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }); + const body = (await response.json()) as { + type?: string; + content?: Array<{ type?: string; text?: string }>; + }; + + expect(response.status).toBe(200); + expect(body.type).toBe('message'); + expect(body.content?.[0]).toEqual({ type: 'text', text: 'Plain answer' }); + }); + + it('returns invalid_request_error for malformed JSON', async () => { + const response = await fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': 'test-proxy-token', + }, + body: '{bad-json', + }); + 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('Invalid JSON'); + }); + + it('rejects requests without the local proxy auth token', async () => { + const response = await fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + const body = (await response.json()) as { error?: { type?: string } }; + + expect(response.status).toBe(401); + expect(body.error?.type).toBe('authentication_error'); + }); + + it('returns invalid_request_error for unsupported Anthropic content blocks', async () => { + const response = await requestProxy({ + model: 'hf-model', + messages: [{ role: 'user', content: [{ type: 'image' }] }], + }); + 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'); + }); + + it('accepts query strings and trailing slashes on the messages route', async () => { + const response = await fetch(`http://127.0.0.1:${proxyPort}/v1/messages/?beta=test`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + authorization: 'Bearer test-proxy-token', + }, + body: JSON.stringify({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + + expect(response.status).toBe(200); + }); +}); diff --git a/tests/unit/glmt/sse-parser.test.ts b/tests/unit/glmt/sse-parser.test.ts new file mode 100644 index 00000000..10f14177 --- /dev/null +++ b/tests/unit/glmt/sse-parser.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'bun:test'; +import { SSEParser } from '../../../src/glmt/sse-parser'; + +describe('SSEParser', () => { + it('merges multi-line data fields into one JSON payload', () => { + const parser = new SSEParser({ throwOnMalformedJson: true }); + const events = parser.parse( + [ + 'event: message', + 'data: {"choices":[', + 'data: {"delta":{"content":"Hello"}}', + 'data: ]}', + '', + '', + ].join('\n') + ); + + expect(events).toHaveLength(1); + expect( + (events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0] + ?.delta?.content + ).toBe('Hello'); + }); + + it('keeps incomplete events buffered until the separator arrives', () => { + const parser = new SSEParser({ throwOnMalformedJson: true }); + expect(parser.parse('data: {"choices":[{"delta":{"content":"Hi"}}]}')).toEqual([]); + const events = parser.parse('\n\n'); + + expect(events).toHaveLength(1); + expect((events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0]?.delta?.content).toBe( + 'Hi' + ); + }); +}); diff --git a/tests/unit/proxy/profile-router.test.ts b/tests/unit/proxy/profile-router.test.ts new file mode 100644 index 00000000..76bfe7be --- /dev/null +++ b/tests/unit/proxy/profile-router.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'bun:test'; +import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; + +describe('resolveOpenAICompatProfileConfig', () => { + it('detects generic OpenAI-compatible profiles from base URL and provider hint', () => { + const result = resolveOpenAICompatProfileConfig('hf', '/tmp/hf.settings.json', { + ANTHROPIC_BASE_URL: 'https://router.huggingface.co/v1', + ANTHROPIC_AUTH_TOKEN: 'hf_token', + ANTHROPIC_MODEL: 'openai/gpt-oss-120b:fastest', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + + expect(result).not.toBeNull(); + expect(result?.provider).toBe('generic-chat-completion-api'); + expect(result?.profileName).toBe('hf'); + }); + + it('detects official OpenAI-style endpoints', () => { + const result = resolveOpenAICompatProfileConfig('openai', '/tmp/openai.settings.json', { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-openai', + ANTHROPIC_MODEL: 'gpt-4.1', + CCS_OPENAI_PROXY_INSECURE: 'true', + }); + + expect(result?.provider).toBe('openai'); + expect(result?.model).toBe('gpt-4.1'); + expect(result?.insecure).toBe(true); + }); + + it('ignores Anthropic-compatible profiles', () => { + const result = resolveOpenAICompatProfileConfig('glm', '/tmp/glm.settings.json', { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', + ANTHROPIC_AUTH_TOKEN: 'glm-key', + ANTHROPIC_MODEL: 'glm-5', + }); + + expect(result).toBeNull(); + }); +}); diff --git a/tests/unit/proxy/transformers/request-transformer.test.ts b/tests/unit/proxy/transformers/request-transformer.test.ts new file mode 100644 index 00000000..e3d6afad --- /dev/null +++ b/tests/unit/proxy/transformers/request-transformer.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import { ProxyRequestTransformer } from '../../../../src/proxy/transformers/request-transformer'; + +describe('ProxyRequestTransformer', () => { + it('translates Anthropic messages into OpenAI-compatible chat payloads', () => { + const transformer = new ProxyRequestTransformer(); + const result = transformer.transform({ + model: 'claude-sonnet-4.5', + stream: true, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Find release notes' }] }, + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'release' } }], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'v7.69.1' }], + }, + ], + thinking: { type: 'enabled', budget_tokens: 9000 }, + max_tokens: 1024, + temperature: 0.2, + top_p: 0.9, + stop_sequences: ['STOP'], + metadata: { trace: 'abc' }, + }); + + expect(result.stream).toBe(true); + expect(result.reasoning_effort).toBe('high'); + expect(result.max_tokens).toBe(1024); + expect(result.temperature).toBe(0.2); + expect(result.top_p).toBe(0.9); + expect(result.stop).toEqual(['STOP']); + expect(result.metadata).toEqual({ trace: 'abc' }); + expect(result.messages[0]).toEqual({ role: 'user', content: 'Find release notes' }); + expect(result.messages[1]?.tool_calls?.[0]?.function.name).toBe('search'); + expect(result.messages[2]).toEqual({ + role: 'tool', + tool_call_id: 'toolu_1', + content: 'v7.69.1', + }); + }); + + it('drops malformed optional fields but preserves the translated core request', () => { + const transformer = new ProxyRequestTransformer(); + const result = transformer.transform({ + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 'bad', + temperature: 'bad', + top_p: 'bad', + stop_sequences: ['A', 1], + metadata: 'bad', + }); + + expect(result.messages).toEqual([{ role: 'user', content: 'hello' }]); + expect(result.max_tokens).toBeUndefined(); + expect(result.temperature).toBeUndefined(); + expect(result.top_p).toBeUndefined(); + expect(result.stop).toEqual(['A']); + expect(result.metadata).toBeUndefined(); + }); +});