400 lines
14 KiB
TypeScript
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']);
|
|
});
|
|
});
|
|
});
|