@@ -959,6 +959,122 @@ func TestErrorHandling(t *testing.T) {
959959 }
960960}
961961
962+ // TestStableRequestEncoding validates that a given intercepted request and a
963+ // given set of injected tools should result identical payloads.
964+ //
965+ // Should the payload vary, it may subvert any caching mechanisms the provider may have.
966+ func TestStableRequestEncoding (t * testing.T ) {
967+ t .Parallel ()
968+
969+ logger := slogtest .Make (t , & slogtest.Options {IgnoreErrors : false }).Leveled (slog .LevelDebug )
970+
971+ cases := []struct {
972+ name string
973+ fixture []byte
974+ createRequestFunc createRequestFunc
975+ configureFunc configureFunc
976+ }{
977+ {
978+ name : aibridge .ProviderAnthropic ,
979+ fixture : antSimple ,
980+ createRequestFunc : createAnthropicMessagesReq ,
981+ configureFunc : func (addr string , client aibridge.Recorder , srvProxyMgr * mcp.ServerProxyManager ) (* aibridge.RequestBridge , error ) {
982+ return aibridge .NewRequestBridge (t .Context (), []aibridge.Provider {aibridge .NewAnthropicProvider (cfg (addr , apiKey ))}, logger , client , srvProxyMgr )
983+ },
984+ },
985+ {
986+ name : aibridge .ProviderOpenAI ,
987+ fixture : oaiSimple ,
988+ createRequestFunc : createOpenAIChatCompletionsReq ,
989+ configureFunc : func (addr string , client aibridge.Recorder , srvProxyMgr * mcp.ServerProxyManager ) (* aibridge.RequestBridge , error ) {
990+ return aibridge .NewRequestBridge (t .Context (), []aibridge.Provider {aibridge .NewOpenAIProvider (cfg (addr , apiKey ))}, logger , client , srvProxyMgr )
991+ },
992+ },
993+ }
994+
995+ for _ , tc := range cases {
996+ t .Run (tc .name , func (t * testing.T ) {
997+ t .Parallel ()
998+
999+ ctx , cancel := context .WithTimeout (t .Context (), time .Second * 30 )
1000+ t .Cleanup (cancel )
1001+
1002+ // Setup MCP tools.
1003+ tools := setupMCPServerProxiesForTest (t )
1004+
1005+ // Configure the bridge with injected tools.
1006+ mcpMgr := mcp .NewServerProxyManager (tools )
1007+ require .NoError (t , mcpMgr .Init (ctx ))
1008+
1009+ arc := txtar .Parse (tc .fixture )
1010+ t .Logf ("%s: %s" , t .Name (), arc .Comment )
1011+
1012+ files := filesMap (arc )
1013+ require .Contains (t , files , fixtureRequest )
1014+ require .Contains (t , files , fixtureNonStreamingResponse )
1015+
1016+ var (
1017+ reference []byte
1018+ reqCount atomic.Int32
1019+ )
1020+
1021+ // Create a mock server that captures and compares request bodies.
1022+ mockSrv := httptest .NewUnstartedServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
1023+ reqCount .Add (1 )
1024+
1025+ // Capture the raw request body.
1026+ raw , err := io .ReadAll (r .Body )
1027+ defer r .Body .Close ()
1028+ require .NoError (t , err )
1029+ require .NotEmpty (t , raw )
1030+
1031+ // Store the first instance as the reference value.
1032+ if reference == nil {
1033+ reference = raw
1034+ } else {
1035+ // Compare all subsequent requests to the reference.
1036+ assert .JSONEq (t , string (reference ), string (raw ))
1037+ }
1038+
1039+ // Return a valid API response.
1040+ w .Header ().Set ("Content-Type" , "application/json" )
1041+ w .WriteHeader (http .StatusOK )
1042+ _ , _ = w .Write (files [fixtureNonStreamingResponse ])
1043+ }))
1044+ mockSrv .Config .BaseContext = func (_ net.Listener ) context.Context {
1045+ return ctx
1046+ }
1047+ mockSrv .Start ()
1048+ t .Cleanup (mockSrv .Close )
1049+
1050+ recorder := & mockRecorderClient {}
1051+ bridge , err := tc .configureFunc (mockSrv .URL , recorder , mcpMgr )
1052+ require .NoError (t , err )
1053+
1054+ // Invoke request to mocked API via aibridge.
1055+ bridgeSrv := httptest .NewUnstartedServer (bridge )
1056+ bridgeSrv .Config .BaseContext = func (_ net.Listener ) context.Context {
1057+ return aibridge .AsActor (ctx , userID , nil )
1058+ }
1059+ bridgeSrv .Start ()
1060+ t .Cleanup (bridgeSrv .Close )
1061+
1062+ // Make multiple requests and verify they all have identical payloads.
1063+ count := 10
1064+ for range count {
1065+ req := tc .createRequestFunc (t , bridgeSrv .URL , files [fixtureRequest ])
1066+ client := & http.Client {}
1067+ resp , err := client .Do (req )
1068+ require .NoError (t , err )
1069+ require .Equal (t , http .StatusOK , resp .StatusCode )
1070+ _ = resp .Body .Close ()
1071+ }
1072+
1073+ require .EqualValues (t , count , reqCount .Load ())
1074+ })
1075+ }
1076+ }
1077+
9621078func calculateTotalInputTokens (in []* aibridge.TokenUsageRecord ) int64 {
9631079 var total int64
9641080 for _ , el := range in {
@@ -1142,12 +1258,14 @@ func createMockMCPSrv(t *testing.T) http.Handler {
11421258 server .WithToolCapabilities (true ),
11431259 )
11441260
1145- tool := mcplib .NewTool (mockToolName ,
1146- mcplib .WithDescription (fmt .Sprintf ("Mock of the %s tool" , mockToolName )),
1147- )
1148- s .AddTool (tool , func (ctx context.Context , request mcplib.CallToolRequest ) (* mcplib.CallToolResult , error ) {
1149- return mcplib .NewToolResultText ("mock" ), nil
1150- })
1261+ for _ , name := range []string {mockToolName , "coder_list_templates" , "coder_template_version_parameters" , "coder_get_authenticated_user" , "coder_create_workspace_build" } {
1262+ tool := mcplib .NewTool (name ,
1263+ mcplib .WithDescription (fmt .Sprintf ("Mock of the %s tool" , name )),
1264+ )
1265+ s .AddTool (tool , func (ctx context.Context , request mcplib.CallToolRequest ) (* mcplib.CallToolResult , error ) {
1266+ return mcplib .NewToolResultText ("mock" ), nil
1267+ })
1268+ }
11511269
11521270 return server .NewStreamableHTTPServer (s )
11531271}
0 commit comments