/** * Anthropic Proxy Route Tests (PRQ-043) * * Tests the built-in OpenAI-to-Anthropic translation proxy: * GET /v1/health/liveliness — health check * POST /v1/chat/completions — translate OpenAI format to Anthropic (non-streaming) * * Uses a lightweight Fastify server with just the proxy routes registered, * mocking the external Anthropic API call via globalThis.fetch. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import { anthropicProxyRoutes } from '../../src/local/routes/anthropic-proxy.js'; function createTestServer(options: { vaultApiKey?: string; envApiKey?: string; configApiKey?: string; dataDir?: string; } = {}) { const server = Fastify({ logger: false }); // Mock vault if (options.vaultApiKey) { server.decorate('vault', { get: (name: string) => name === 'anthropic' ? { value: options.vaultApiKey } : null, }); } else { server.decorate('vault', null); } // Mock localConfig (needed by getAnthropicKey for config.json fallback) server.decorate('localConfig', { dataDir: options.dataDir ?? '/tmp/nonexistent-waggle-test', }); server.register(anthropicProxyRoutes); return server; } describe('Anthropic Proxy Routes', () => { let server: FastifyInstance; const originalFetch = globalThis.fetch; const originalApiKey = process.env.ANTHROPIC_API_KEY; beforeEach(() => { // Clear env var by default delete process.env.ANTHROPIC_API_KEY; }); afterEach(async () => { if (server) await server.close(); globalThis.fetch = originalFetch; // Restore env var if (originalApiKey !== undefined) { process.env.ANTHROPIC_API_KEY = originalApiKey; } else { delete process.env.ANTHROPIC_API_KEY; } }); // ── Health check ────────────────────────────────────────────── describe('GET /v1/health/liveliness', () => { it('returns healthy status', async () => { server = createTestServer(); const res = await server.inject({ method: 'GET', url: '/v1/health/liveliness', }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.status).toBe('healthy'); }); }); // ── POST /v1/chat/completions ───────────────────────────────── describe('POST /v1/chat/completions (non-streaming)', () => { it('returns 500 when no API key is configured', async () => { // No vault key, no env key, no config key server = createTestServer(); const res = await server.inject({ method: 'POST', url: '/v1/chat/completions', payload: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'Hello' }], stream: false, }, }); expect(res.statusCode).toBe(500); const body = res.json(); expect(body.error.message).toContain('No Anthropic API key'); }); it('translates OpenAI format to Anthropic format and returns response', async () => { process.env.ANTHROPIC_API_KEY = 'test-key-12345'; server = createTestServer(); // Mock the Anthropic API response globalThis.fetch = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ content: [ { type: 'text', text: 'Hello! How can I help you?' }, ], model: 'claude-sonnet-4-20250514', stop_reason: 'end_turn', usage: { input_tokens: 12, output_tokens: 8 }, }), })) as unknown as typeof globalThis.fetch; const res = await server.inject({ method: 'POST', url: '/v1/chat/completions', payload: { model: 'claude-sonnet-4-6', messages: [ { role: 'system', content: 'You are a helpful assistant.' }, { role: 'user', content: 'Hello' }, ], stream: false, }, }); expect(res.statusCode).toBe(200); const body = res.json(); // Verify OpenAI response format expect(body.choices).toHaveLength(1); expect(body.choices[0].message.role).toBe('assistant'); expect(body.choices[0].message.content).toBe('Hello! How can I help you?'); expect(body.choices[0].finish_reason).toBe('stop'); expect(body.usage.prompt_tokens).toBe(12); expect(body.usage.completion_tokens).toBe(8); expect(body.usage.total_tokens).toBe(20); // Verify the Anthropic API was called with correct parameters const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0]; expect(fetchCall[0]).toBe('https://api.anthropic.com/v1/messages'); const requestBody = JSON.parse(String(fetchCall[1]?.body ?? '')); // B3 cleanup (2026-04-22) — proxy now passes floating alias through // unchanged per decisions/2026-04-22-model-route-naming-locked.md §3. // Previous behavior rewrote to invalid -20250514 snapshot. expect(requestBody.model).toBe('claude-sonnet-4-6'); // system is either a string or an Anthropic cache-control block array — // extract the text in either case. const systemText = Array.isArray(requestBody.system) ? requestBody.system.map((b: { text?: string }) => b.text ?? '').join('\n') : String(requestBody.system ?? ''); expect(systemText).toContain('You are a helpful assistant'); expect(requestBody.stream).toBe(false); }); it('uses API key from vault when available', async () => { server = createTestServer({ vaultApiKey: 'vault-key-abc' }); globalThis.fetch = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ content: [{ type: 'text', text: 'Response' }], model: 'claude-sonnet-4-20250514', stop_reason: 'end_turn', usage: { input_tokens: 5, output_tokens: 3 }, }), })) as unknown as typeof globalThis.fetch; const res = await server.inject({ method: 'POST', url: '/v1/chat/completions', payload: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'Hi' }], stream: false, }, }); expect(res.statusCode).toBe(200); // Verify vault key was used in the request const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0]; const headers = fetchCall[1]?.headers as Record | undefined; expect(headers?.['x-api-key']).toBe('vault-key-abc'); }); it('forwards Anthropic API errors to client', async () => { process.env.ANTHROPIC_API_KEY = 'test-key'; server = createTestServer(); globalThis.fetch = vi.fn(async () => ({ ok: false, status: 401, text: async () => 'Invalid API key', })) as unknown as typeof globalThis.fetch; const res = await server.inject({ method: 'POST', url: '/v1/chat/completions', payload: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'Hi' }], stream: false, }, }); expect(res.statusCode).toBe(401); const body = res.json(); expect(body.error.message).toContain('Anthropic API error'); expect(body.error.message).toContain('Invalid API key'); }); it('translates tool_use response to OpenAI format', async () => { process.env.ANTHROPIC_API_KEY = 'test-key'; server = createTestServer(); globalThis.fetch = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ content: [ { type: 'text', text: 'Let me search for that.' }, { type: 'tool_use', id: 'toolu_123', name: 'web_search', input: { query: 'Waggle AI agent' }, }, ], model: 'claude-sonnet-4-20250514', stop_reason: 'tool_use', usage: { input_tokens: 20, output_tokens: 15 }, }), })) as unknown as typeof globalThis.fetch; const res = await server.inject({ method: 'POST', url: '/v1/chat/completions', payload: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'Search for Waggle' }], tools: [ { type: 'function', function: { name: 'web_search', description: 'Search the web', parameters: { type: 'object', properties: { query: { type: 'string' } }, }, }, }, ], stream: false, }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.choices[0].finish_reason).toBe('tool_calls'); expect(body.choices[0].message.content).toBe('Let me search for that.'); expect(body.choices[0].message.tool_calls).toHaveLength(1); expect(body.choices[0].message.tool_calls[0].id).toBe('toolu_123'); expect(body.choices[0].message.tool_calls[0].type).toBe('function'); expect(body.choices[0].message.tool_calls[0].function.name).toBe('web_search'); expect(JSON.parse(body.choices[0].message.tool_calls[0].function.arguments)).toEqual({ query: 'Waggle AI agent' }); }); }); // B3 cleanup regression guard per decisions/2026-04-22-model-route-naming-locked.md §4 describe('invalid snapshot regression guard (B3 cleanup 2026-04-22)', () => { it('does NOT inject -20250514 snapshot for any Claude 4.6 family floating alias', async () => { process.env.ANTHROPIC_API_KEY = 'test-key-snapshot-guard'; server = createTestServer(); const captures: Array<{ model: string }> = []; globalThis.fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { const body = init?.body ? JSON.parse(String(init.body)) : {}; captures.push({ model: body.model }); return { ok: true, status: 200, json: async () => ({ content: [{ type: 'text', text: 'ok' }], model: body.model, stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 }, }), }; }) as unknown as typeof globalThis.fetch; const floatingAliases = ['claude-sonnet-4-6', 'claude-opus-4-6', 'anthropic/claude-sonnet-4.6', 'anthropic/claude-opus-4.6']; for (const alias of floatingAliases) { await server.inject({ method: 'POST', url: '/v1/chat/completions', payload: { model: alias, messages: [{ role: 'user', content: 'test' }], stream: false, }, }); } // Every outbound model must NOT be the invalid -20250514 snapshot. for (const cap of captures) { expect(cap.model).not.toMatch(/-20250514$/); // Positive assertion: floating alias passes through as the canonical // dash-form (mapModel normalizes dots to dashes). expect(cap.model).toMatch(/^claude-(sonnet|opus)-4-6$/); } expect(captures).toHaveLength(floatingAliases.length); }); }); describe('non-Anthropic model guard', () => { it('rejects non-Claude models with 400 and never calls the Anthropic API', async () => { process.env.ANTHROPIC_API_KEY = 'test-key-model-guard'; server = createTestServer(); const outboundCalls: string[] = []; globalThis.fetch = vi.fn(async (url: string | URL | Request) => { outboundCalls.push(String(url)); return { ok: true, status: 200, json: async () => ({}) }; }) as unknown as typeof globalThis.fetch; const nonAnthropicModels = [ 'alibaba/qwen3.7-max-2026-06-08', 'gpt-4o', 'openrouter/moonshotai/kimi-k2.5', 'gemini/gemini-2.5-pro', ]; for (const model of nonAnthropicModels) { const res = await server.inject({ method: 'POST', url: '/v1/chat/completions', payload: { model, messages: [{ role: 'user', content: 'test' }], stream: false, }, }); expect(res.statusCode).toBe(400); const body = res.json(); expect(body.error.message).toContain(model); expect(body.error.message).toMatch(/Claude/); } // Guard must fire BEFORE any outbound Anthropic request. expect(outboundCalls).toHaveLength(0); }); it('still forwards Claude models (with and without provider prefix)', async () => { process.env.ANTHROPIC_API_KEY = 'test-key-model-guard-pass'; server = createTestServer(); const captures: string[] = []; globalThis.fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { const body = init?.body ? JSON.parse(String(init.body)) : {}; captures.push(body.model); return { ok: true, status: 200, json: async () => ({ content: [{ type: 'text', text: 'ok' }], model: body.model, stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 }, }), }; }) as unknown as typeof globalThis.fetch; for (const model of ['claude-fable-5', 'anthropic/claude-sonnet-5', 'openrouter/anthropic/claude-opus-4.8']) { const res = await server.inject({ method: 'POST', url: '/v1/chat/completions', payload: { model, messages: [{ role: 'user', content: 'test' }], stream: false, }, }); expect(res.statusCode).toBe(200); } expect(captures).toEqual(['claude-fable-5', 'claude-sonnet-5', 'claude-opus-4-8']); }); }); });