Files
waggle-os/packages/server/tests/local/anthropic-proxy.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

400 lines
14 KiB
TypeScript

/**
* 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<string, string> | 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']);
});
});
});