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 */
912import type { ChatCompletionResponse } from '../src' ;
1013import type { Server } from 'http' ;
1114
15+ import Anthropic from '@anthropic-ai/sdk' ;
1216// eslint-disable-next-line import/extensions
1317import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' ;
1418import OpenAI from 'openai' ;
@@ -18,8 +22,9 @@ import { Router } from '../src';
1822import runMcpServer from '../src/examples/simple-mcp-server' ;
1923import isModelSupportingTools from '../src/supported-models' ;
2024
21- const { OPENAI_API_KEY } = process . env ;
25+ const { OPENAI_API_KEY , ANTHROPIC_API_KEY } = process . env ;
2226const 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+
5078describeWithOpenAI ( '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 ( / A u t h e n t i c a t i o n f a i l e d | i n v a l i d x - a p i - k e y / 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