Skip to content

Commit 4bd08ce

Browse files
alban bertoliniclaude
andcommitted
test(ai-proxy): add Anthropic model compatibility integration tests
Add Anthropic API integration tests mirroring the existing OpenAI tests: - Basic chat, tool calls, tool_choice: required, multi-turn conversations - Error handling for invalid API keys - Model discovery via anthropic.models.list() with tool support verification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b7956eb commit 4bd08ce

1 file changed

Lines changed: 300 additions & 5 deletions

File tree

packages/ai-proxy/test/llm.integration.test.ts

Lines changed: 300 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
/**
2-
* End-to-end integration tests with real OpenAI API and MCP server.
2+
* End-to-end integration tests with real OpenAI and Anthropic APIs and MCP server.
33
*
4-
* These tests require a valid OPENAI_API_KEY environment variable.
5-
* They are skipped if the key is not present.
4+
* These tests require valid API key environment variables:
5+
* - OPENAI_API_KEY for OpenAI tests
6+
* - ANTHROPIC_API_KEY for Anthropic tests
67
*
7-
* Run with: yarn workspace @forestadmin/ai-proxy test openai.integration
8+
* Tests are skipped if the corresponding key is not present.
9+
*
10+
* Run with: yarn workspace @forestadmin/ai-proxy test llm.integration
811
*/
912
import type { ChatCompletionResponse } from '../src';
1013
import type { Server } from 'http';
1114

15+
import Anthropic from '@anthropic-ai/sdk';
1216
// eslint-disable-next-line import/extensions
1317
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1418
import OpenAI from 'openai';
@@ -18,8 +22,9 @@ import { Router } from '../src';
1822
import runMcpServer from '../src/examples/simple-mcp-server';
1923
import isModelSupportingTools from '../src/supported-models';
2024

21-
const { OPENAI_API_KEY } = process.env;
25+
const { OPENAI_API_KEY, ANTHROPIC_API_KEY } = process.env;
2226
const describeWithOpenAI = OPENAI_API_KEY ? describe : describe.skip;
27+
const describeWithAnthropic = ANTHROPIC_API_KEY ? describe : describe.skip;
2328

2429
/**
2530
* Fetches available models from OpenAI API.
@@ -47,6 +52,29 @@ async function fetchChatModelsFromOpenAI(): Promise<string[]> {
4752
.sort();
4853
}
4954

55+
/**
56+
* Fetches available models from Anthropic API.
57+
* Returns all model IDs sorted alphabetically.
58+
*
59+
* All Anthropic chat models support tools, so no filtering is needed.
60+
*/
61+
async function fetchChatModelsFromAnthropic(): Promise<string[]> {
62+
const anthropic = new Anthropic({ apiKey: ANTHROPIC_API_KEY });
63+
64+
let models;
65+
try {
66+
models = await anthropic.models.list({ limit: 1000 });
67+
} catch (error) {
68+
throw new Error(
69+
`Failed to fetch models from Anthropic API. ` +
70+
`Ensure ANTHROPIC_API_KEY is valid and network is available. ` +
71+
`Original error: ${error}`,
72+
);
73+
}
74+
75+
return models.data.map(m => m.id).sort();
76+
}
77+
5078
describeWithOpenAI('OpenAI Integration (real API)', () => {
5179
const router = new Router({
5280
aiConfigurations: [
@@ -802,3 +830,270 @@ describeWithOpenAI('OpenAI Integration (real API)', () => {
802830
}, 300000); // 5 minutes for all models
803831
});
804832
});
833+
834+
describeWithAnthropic('Anthropic Integration (real API)', () => {
835+
const router = new Router({
836+
aiConfigurations: [
837+
{
838+
name: 'test-claude',
839+
provider: 'anthropic',
840+
model: 'claude-3-5-haiku-latest', // Cheapest model with tool support
841+
apiKey: ANTHROPIC_API_KEY,
842+
},
843+
],
844+
});
845+
846+
describe('route: ai-query', () => {
847+
it('should complete a simple chat request', async () => {
848+
const response = (await router.route({
849+
route: 'ai-query',
850+
body: {
851+
messages: [
852+
{ role: 'system', content: 'You are a helpful assistant. Be very concise.' },
853+
{ role: 'user', content: 'What is 2+2? Reply with just the number.' },
854+
],
855+
},
856+
})) as ChatCompletionResponse;
857+
858+
// Anthropic responses are converted to OpenAI-compatible format
859+
expect(response).toMatchObject({
860+
object: 'chat.completion',
861+
model: 'claude-3-5-haiku-latest',
862+
choices: expect.arrayContaining([
863+
expect.objectContaining({
864+
index: 0,
865+
message: expect.objectContaining({
866+
role: 'assistant',
867+
content: expect.stringContaining('4'),
868+
}),
869+
finish_reason: 'stop',
870+
}),
871+
]),
872+
usage: expect.objectContaining({
873+
prompt_tokens: expect.any(Number),
874+
completion_tokens: expect.any(Number),
875+
total_tokens: expect.any(Number),
876+
}),
877+
});
878+
}, 10000);
879+
880+
it('should handle tool calls', async () => {
881+
const response = (await router.route({
882+
route: 'ai-query',
883+
body: {
884+
messages: [{ role: 'user', content: 'What is the weather in Paris?' }],
885+
tools: [
886+
{
887+
type: 'function',
888+
function: {
889+
name: 'get_weather',
890+
description: 'Get the current weather in a given location',
891+
parameters: {
892+
type: 'object',
893+
properties: {
894+
location: { type: 'string', description: 'The city name' },
895+
},
896+
required: ['location'],
897+
},
898+
},
899+
},
900+
],
901+
tool_choice: 'auto',
902+
},
903+
})) as ChatCompletionResponse;
904+
905+
expect(response.choices[0].finish_reason).toBe('tool_calls');
906+
expect(response.choices[0].message.tool_calls).toEqual(
907+
expect.arrayContaining([
908+
expect.objectContaining({
909+
type: 'function',
910+
function: expect.objectContaining({
911+
name: 'get_weather',
912+
arguments: expect.stringContaining('Paris'),
913+
}),
914+
}),
915+
]),
916+
);
917+
}, 10000);
918+
919+
it('should handle tool_choice: required', async () => {
920+
const response = (await router.route({
921+
route: 'ai-query',
922+
body: {
923+
messages: [{ role: 'user', content: 'Hello!' }],
924+
tools: [
925+
{
926+
type: 'function',
927+
function: {
928+
name: 'greet',
929+
description: 'Greet the user',
930+
parameters: { type: 'object', properties: {} },
931+
},
932+
},
933+
],
934+
tool_choice: 'required',
935+
},
936+
})) as ChatCompletionResponse;
937+
938+
expect(response.choices[0].finish_reason).toBe('tool_calls');
939+
const toolCall = response.choices[0].message.tool_calls?.[0] as {
940+
function: { name: string };
941+
};
942+
expect(toolCall.function.name).toBe('greet');
943+
}, 10000);
944+
945+
it('should complete multi-turn conversation with tool results', async () => {
946+
const addTool = {
947+
type: 'function' as const,
948+
function: {
949+
name: 'calculate',
950+
description: 'Calculate a math expression',
951+
parameters: {
952+
type: 'object',
953+
properties: { expression: { type: 'string' } },
954+
required: ['expression'],
955+
},
956+
},
957+
};
958+
959+
// First turn: get tool call
960+
const response1 = (await router.route({
961+
route: 'ai-query',
962+
body: {
963+
messages: [{ role: 'user', content: 'What is 5 + 3?' }],
964+
tools: [addTool],
965+
tool_choice: 'required',
966+
},
967+
})) as ChatCompletionResponse;
968+
969+
expect(response1.choices[0].finish_reason).toBe('tool_calls');
970+
const toolCall = response1.choices[0].message.tool_calls?.[0];
971+
expect(toolCall).toBeDefined();
972+
973+
// Second turn: provide tool result and get final answer
974+
const response2 = (await router.route({
975+
route: 'ai-query',
976+
body: {
977+
messages: [
978+
{ role: 'user', content: 'What is 5 + 3?' },
979+
response1.choices[0].message,
980+
{
981+
role: 'tool',
982+
tool_call_id: toolCall!.id,
983+
content: '8',
984+
},
985+
],
986+
},
987+
})) as ChatCompletionResponse;
988+
989+
expect(response2.choices[0].finish_reason).toBe('stop');
990+
expect(response2.choices[0].message.content).toContain('8');
991+
}, 15000);
992+
});
993+
994+
describe('error handling', () => {
995+
it('should throw authentication error with invalid API key', async () => {
996+
const invalidRouter = new Router({
997+
aiConfigurations: [
998+
{
999+
name: 'invalid',
1000+
provider: 'anthropic',
1001+
model: 'claude-3-5-haiku-latest',
1002+
apiKey: 'sk-ant-invalid-key',
1003+
},
1004+
],
1005+
});
1006+
1007+
await expect(
1008+
invalidRouter.route({
1009+
route: 'ai-query',
1010+
body: {
1011+
messages: [{ role: 'user', content: 'test' }],
1012+
},
1013+
}),
1014+
).rejects.toThrow(/Authentication failed|invalid x-api-key/i);
1015+
}, 10000);
1016+
});
1017+
1018+
describe('Model tool support verification', () => {
1019+
let modelsToTest: string[];
1020+
1021+
beforeAll(async () => {
1022+
modelsToTest = await fetchChatModelsFromAnthropic();
1023+
});
1024+
1025+
it('should have found models from Anthropic API', () => {
1026+
expect(modelsToTest.length).toBeGreaterThan(0);
1027+
// eslint-disable-next-line no-console
1028+
console.log(`Testing ${modelsToTest.length} Anthropic models:`, modelsToTest);
1029+
});
1030+
1031+
it('all models should support tool calls', async () => {
1032+
const results: { model: string; success: boolean; error?: string }[] = [];
1033+
1034+
for (const model of modelsToTest) {
1035+
const modelRouter = new Router({
1036+
aiConfigurations: [
1037+
{ name: 'test', provider: 'anthropic', model, apiKey: ANTHROPIC_API_KEY },
1038+
],
1039+
});
1040+
1041+
try {
1042+
const response = (await modelRouter.route({
1043+
route: 'ai-query',
1044+
body: {
1045+
messages: [{ role: 'user', content: 'What is 2+2?' }],
1046+
tools: [
1047+
{
1048+
type: 'function',
1049+
function: {
1050+
name: 'calculate',
1051+
description: 'Calculate a math expression',
1052+
parameters: { type: 'object', properties: { result: { type: 'number' } } },
1053+
},
1054+
},
1055+
],
1056+
tool_choice: 'required',
1057+
},
1058+
})) as ChatCompletionResponse;
1059+
1060+
const success =
1061+
response.choices[0].finish_reason === 'tool_calls' &&
1062+
response.choices[0].message.tool_calls !== undefined;
1063+
1064+
results.push({ model, success });
1065+
} catch (error) {
1066+
const errorMessage = String(error);
1067+
1068+
// Infrastructure errors should fail the test immediately
1069+
const isInfrastructureError =
1070+
errorMessage.includes('rate limit') ||
1071+
errorMessage.includes('429') ||
1072+
errorMessage.includes('401') ||
1073+
errorMessage.includes('Authentication') ||
1074+
errorMessage.includes('ECONNREFUSED') ||
1075+
errorMessage.includes('ETIMEDOUT') ||
1076+
errorMessage.includes('getaddrinfo');
1077+
1078+
if (isInfrastructureError) {
1079+
throw new Error(`Infrastructure error testing model ${model}: ${errorMessage}`);
1080+
}
1081+
1082+
results.push({ model, success: false, error: errorMessage });
1083+
}
1084+
}
1085+
1086+
const failures = results.filter(r => !r.success);
1087+
if (failures.length > 0) {
1088+
const failedModelNames = failures.map(f => f.model).join(', ');
1089+
// eslint-disable-next-line no-console
1090+
console.error(
1091+
`\n❌ ${failures.length} Anthropic model(s) failed tool support: ${failedModelNames}\n`,
1092+
failures,
1093+
);
1094+
}
1095+
1096+
expect(failures).toEqual([]);
1097+
}, 300000); // 5 minutes for all models
1098+
});
1099+
});

0 commit comments

Comments
 (0)