package openai import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" edgeservice "iop/apps/edge/internal/service" "iop/packages/go/config" iop "iop/proto/gen/iop" ) func TestAnthropicChatBridgeMixedContentToolsAndResponse(t *testing.T) { var fixture struct { Request json.RawMessage `json:"request"` ProviderResponse json.RawMessage `json:"provider_response"` } if err := json.Unmarshal(mustReadAnthropicFixture(t, "chat_bridge_cases.json"), &fixture); err != nil { t.Fatal(err) } candidate := anthropicTestCandidate(t, "openai") candidate.ActualModel = "served-chat" fake := &providerFakeRunService{ poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel), poolSelectedCandidate: candidate, tunnelFrames: anthropicTunnelFrames(http.StatusOK, "application/json", fixture.ProviderResponse[:41], fixture.ProviderResponse[41:]), } srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}}) w := serveAnthropicRequest(srv, "/v1/messages", string(fixture.Request)) if w.Code != http.StatusOK { t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) } var response anthropicMessageResponse if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatal(err) } if response.ID != "chatcmpl_fixture" || response.Model != "claude-route" || response.StopReason == nil || *response.StopReason != "tool_use" { t.Fatalf("response envelope mismatch: %+v", response) } if response.Usage.InputTokens != 31 || response.Usage.OutputTokens != 7 || len(response.Content) != 2 { t.Fatalf("response content or usage mismatch: %+v", response) } if response.Content[0]["type"] != "text" || response.Content[0]["text"] != "Done." || response.Content[1]["type"] != "tool_use" || response.Content[1]["id"] != "call_2" { t.Fatalf("response block mapping mismatch: %+v", response.Content) } requests := fake.tunnelReqsSnapshot() bodies := fake.tunnelBodiesSnapshot() if len(requests) != 1 || len(bodies) != 1 || requests[0].Operation != string(config.OperationChatCompletions) { t.Fatalf("Chat tunnel evidence mismatch: requests=%+v bodies=%d", requests, len(bodies)) } var chat map[string]any if err := json.Unmarshal(bodies[0], &chat); err != nil { t.Fatal(err) } if chat["model"] != "served-chat" || chat["max_tokens"] != float64(256) || chat["stream"] != false { t.Fatalf("Chat request envelope mismatch: %+v", chat) } messages := anthropicAnySlice(t, chat["messages"]) if len(messages) != 4 { t.Fatalf("Chat messages=%d, want 4: %+v", len(messages), messages) } system := anthropicAnyMap(t, messages[0]) user := anthropicAnyMap(t, messages[1]) assistant := anthropicAnyMap(t, messages[2]) toolResult := anthropicAnyMap(t, messages[3]) if system["role"] != "system" || system["content"] != "Use tools carefully." { t.Fatalf("system mapping mismatch: %+v", system) } userContent := anthropicAnySlice(t, user["content"]) image := anthropicAnyMap(t, userContent[1]) imageURL := anthropicAnyMap(t, image["image_url"]) if user["role"] != "user" || len(userContent) != 2 || image["type"] != "image_url" || imageURL["url"] != "data:image/png;base64,aW1hZ2U=" { t.Fatalf("mixed user content mapping mismatch: %+v", user) } toolCalls := anthropicAnySlice(t, assistant["tool_calls"]) call := anthropicAnyMap(t, toolCalls[0]) function := anthropicAnyMap(t, call["function"]) if assistant["role"] != "assistant" || call["id"] != "toolu_1" || function["name"] != "inspect" || function["arguments"] != `{"detail":"high"}` { t.Fatalf("assistant tool mapping mismatch: %+v", assistant) } if toolResult["role"] != "tool" || toolResult["tool_call_id"] != "toolu_1" || toolResult["content"] != "clear" { t.Fatalf("tool result mapping mismatch: %+v", toolResult) } tools := anthropicAnySlice(t, chat["tools"]) tool := anthropicAnyMap(t, tools[0]) toolFunction := anthropicAnyMap(t, tool["function"]) choice := anthropicAnyMap(t, chat["tool_choice"]) choiceFunction := anthropicAnyMap(t, choice["function"]) if toolFunction["name"] != "inspect" || choiceFunction["name"] != "inspect" || chat["parallel_tool_calls"] != true { t.Fatalf("tool declaration or choice mismatch: tool=%+v choice=%+v", tool, choice) } } func TestAnthropicChatBridgeThinkingCapabilityAndResponse(t *testing.T) { candidate := anthropicTestCandidate(t, "openai") candidate.ActualModel = "served-chat" profile := candidate.ProtocolProfile.Clone() profile.Extensions = map[string]any{"thinking": true} candidate.ProtocolProfile = &profile providerResponse := []byte(`{"id":"chat_think","choices":[{"message":{"role":"assistant","content":"answer","reasoning_content":"hidden"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}`) fake := &providerFakeRunService{ poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel), poolSelectedCandidate: candidate, tunnelFrames: anthropicTunnelFrames(http.StatusOK, "application/json", providerResponse), } srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}}) body := `{"model":"claude-route","max_tokens":64,"thinking":{"type":"enabled","budget_tokens":24},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"prior"},{"type":"text","text":"draft"}]},{"role":"user","content":"continue"}]}` w := serveAnthropicRequest(srv, "/v1/messages", body) if w.Code != http.StatusOK { t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) } var response anthropicMessageResponse if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatal(err) } if len(response.Content) != 2 || response.Content[0]["type"] != "thinking" || response.Content[0]["thinking"] != "hidden" || response.Content[1]["text"] != "answer" { t.Fatalf("thinking response mapping mismatch: %+v", response.Content) } var chat map[string]any if err := json.Unmarshal(fake.tunnelBodiesSnapshot()[0], &chat); err != nil { t.Fatal(err) } if chat["think"] != true || chat["include_reasoning"] != true || chat["thinking_token_budget"] != float64(24) { t.Fatalf("thinking request options mismatch: %+v", chat) } messages := anthropicAnySlice(t, chat["messages"]) assistant := anthropicAnyMap(t, messages[0]) if assistant["reasoning_content"] != "prior" { t.Fatalf("thinking input mapping mismatch: %+v", assistant) } } func TestAnthropicChatBridgeRejectsUnsupportedBeforeWire(t *testing.T) { for _, tc := range []struct { name string body string beta string }{ {name: "top k", body: `{"model":"claude-route","max_tokens":16,"top_k":4,"messages":[{"role":"user","content":"hello"}]}`}, {name: "unknown block", body: `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":[{"type":"search_result","content":"unknown"}]}]}`}, {name: "unknown field", body: `{"model":"claude-route","max_tokens":16,"vendor_extension":true,"messages":[{"role":"user","content":"hello"}]}`}, {name: "thinking capability", body: `{"model":"claude-route","max_tokens":16,"thinking":{"type":"enabled","budget_tokens":8},"messages":[{"role":"user","content":"hello"}]}`}, {name: "beta", body: `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`, beta: "prompt-caching-2024-07-31"}, } { t.Run(tc.name, func(t *testing.T) { candidate := anthropicTestCandidate(t, "openai") candidate.ActualModel = "served-chat" fake := &providerFakeRunService{ poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel), poolSelectedCandidate: candidate, tunnelFrames: anthropicTunnelFrames(http.StatusOK, "application/json", []byte(`{"ok":true}`)), } srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}}) req := newAnthropicRequest(http.MethodPost, "/v1/messages", tc.body) if tc.beta != "" { req.Header.Set(anthropicBetaHeader, tc.beta) } w := serveAnthropicHTTPRequest(srv, req) if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), `"type":"invalid_request_error"`) { t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) } if got := len(fake.tunnelReqsSnapshot()); got != 0 { t.Fatalf("unsupported request reached provider wire: %d requests", got) } }) } } func TestAnthropicChatBridgeProviderError(t *testing.T) { candidate := anthropicTestCandidate(t, "openai") candidate.ActualModel = "served-chat" providerError := []byte(`{"error":{"type":"rate_limit_error","message":"slow down","code":429}}`) fake := &providerFakeRunService{ poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel), poolSelectedCandidate: candidate, tunnelFrames: anthropicTunnelFrames(http.StatusTooManyRequests, "application/json", providerError[:13], providerError[13:]), } srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}}) w := serveAnthropicRequest(srv, "/v1/messages", `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`) if w.Code != http.StatusTooManyRequests || !strings.Contains(w.Body.String(), `"type":"rate_limit_error"`) || !strings.Contains(w.Body.String(), `"message":"slow down"`) { t.Fatalf("provider error mapping mismatch: status=%d body=%s", w.Code, w.Body.String()) } } func TestAnthropicChatBridgeStreamFragmentationOrderAndTerminal(t *testing.T) { fixture := mustReadAnthropicFixture(t, "chat_bridge_stream.sse") parts := bytes.SplitN(fixture, []byte("---ANTHROPIC-OUTPUT---\n"), 2) if len(parts) != 2 { t.Fatal("chat bridge stream fixture is missing the Anthropic output section") } expectedParts := bytes.SplitN(parts[1], []byte("---END-ANTHROPIC-OUTPUT---"), 2) if len(expectedParts) != 2 { t.Fatal("chat bridge stream fixture is missing the Anthropic output terminator") } streamBody, expectedOutput := parts[0], expectedParts[0] frames := make(chan *iop.ProviderTunnelFrame, 9) frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: http.StatusOK, Headers: map[string]string{"Content-Type": "text/event-stream"}} for index, fragment := range splitAnthropicFixture(streamBody, 5, 67, 139, 251, 409, len(streamBody)-3) { frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Sequence: int64(index + 1), Body: fragment} } frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true} close(frames) candidate := anthropicTestCandidate(t, "openai") candidate.ActualModel = "served-chat" fake := &providerFakeRunService{ poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel), poolSelectedCandidate: candidate, tunnelFrames: frames, } srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}}) w := serveAnthropicRequest(srv, "/v1/messages", `{"model":"claude-route","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hello"}]}`) if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "text/event-stream" { t.Fatalf("stream response mismatch: status=%d headers=%v body=%s", w.Code, w.Header(), w.Body.String()) } if !bytes.Equal(w.Body.Bytes(), expectedOutput) { t.Fatalf("stream golden mismatch:\n got=%q\nwant=%q", w.Body.Bytes(), expectedOutput) } events := anthropicSSEEventNames(w.Body.Bytes()) wantEvents := []string{ "message_start", "content_block_start", "content_block_delta", "content_block_stop", "content_block_start", "content_block_delta", "content_block_stop", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop", } if strings.Join(events, ",") != strings.Join(wantEvents, ",") { t.Fatalf("stream event order mismatch:\n got=%v\nwant=%v\nbody=%s", events, wantEvents, w.Body.String()) } output := w.Body.String() for _, fragment := range []string{`"thinking":"checking "`, `"type":"thinking_delta"`, `"text":"hello "`, `"type":"text_delta"`, `"id":"call_1"`, `"name":"lookup"`, `"partial_json":"{\"q\":\"iop\"}"`, `"stop_reason":"tool_use"`, `"input_tokens":19`, `"output_tokens":5`} { if !strings.Contains(output, fragment) { t.Fatalf("stream output missing %q: %s", fragment, output) } } if strings.Count(output, "event: message_stop") != 1 { t.Fatalf("message_stop count mismatch: %s", output) } var chat map[string]any if err := json.Unmarshal(fake.tunnelBodiesSnapshot()[0], &chat); err != nil { t.Fatal(err) } streamOptions := anthropicAnyMap(t, chat["stream_options"]) if chat["stream"] != true || streamOptions["include_usage"] != true { t.Fatalf("stream request options mismatch: %+v", chat) } } func TestAnthropicChatBridgeStreamStopsAtTerminalWithinFrame(t *testing.T) { for _, tc := range []struct { name string body string wantEvents []string wantFinal string }{ { name: "DONE terminal", body: "data: {\"id\":\"chat_1\",\"choices\":[{\"delta\":{\"content\":\"first \"}}]}\n\n" + "data: [DONE]\n\n" + "data: {\"id\":\"chat_1\",\"choices\":[{\"delta\":{\"content\":\"late\"}}]}\n\n", wantEvents: []string{ "message_start", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop", }, wantFinal: "message_stop", }, { name: "Upstream error terminal", body: "data: {\"id\":\"chat_1\",\"choices\":[{\"delta\":{\"content\":\"first \"}}]}\n\n" + "data: {\"error\":{\"type\":\"server_error\",\"message\":\"boom\"}}\n\n" + "data: {\"id\":\"chat_1\",\"choices\":[{\"delta\":{\"content\":\"late\"}}]}\n\n", wantEvents: []string{ "message_start", "content_block_start", "content_block_delta", "error", }, wantFinal: "error", }, } { t.Run(tc.name, func(t *testing.T) { frames := make(chan *iop.ProviderTunnelFrame, 3) frames <- &iop.ProviderTunnelFrame{ Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: http.StatusOK, Headers: map[string]string{"Content-Type": "text/event-stream"}, } frames <- &iop.ProviderTunnelFrame{ Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Sequence: 1, Body: []byte(tc.body), } frames <- &iop.ProviderTunnelFrame{ Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true, } close(frames) candidate := anthropicTestCandidate(t, "openai") candidate.ActualModel = "served-chat" fake := &providerFakeRunService{ poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel), poolSelectedCandidate: candidate, tunnelFrames: frames, } srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}}) w := serveAnthropicRequest(srv, "/v1/messages", `{"model":"claude-route","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hello"}]}`) if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "text/event-stream" { t.Fatalf("stream response mismatch: status=%d headers=%v body=%s", w.Code, w.Header(), w.Body.String()) } output := w.Body.String() if strings.Contains(output, "late") { t.Fatalf("stream output contained late content after terminal: %s", output) } events := anthropicSSEEventNames(w.Body.Bytes()) if strings.Join(events, ",") != strings.Join(tc.wantEvents, ",") { t.Fatalf("stream event order mismatch:\n got=%v\nwant=%v\nbody=%s", events, tc.wantEvents, output) } if len(events) == 0 || events[len(events)-1] != tc.wantFinal { t.Fatalf("final event mismatch: got=%v wantFinal=%s", events, tc.wantFinal) } if count := strings.Count(output, "event: "+tc.wantFinal); count != 1 { t.Fatalf("terminal event count mismatch for %s: count=%d body=%s", tc.wantFinal, count, output) } }) } } func newAnthropicRequest(method, path, body string) *http.Request { req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) req.Header.Set(anthropicVersionHeader, anthropicSupportedVersion) return req } func serveAnthropicHTTPRequest(srv *Server, req *http.Request) *httptest.ResponseRecorder { w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) return w } func anthropicAnySlice(t *testing.T, value any) []any { t.Helper() items, ok := value.([]any) if !ok { t.Fatalf("value is %T, want []any: %+v", value, value) } return items } func anthropicAnyMap(t *testing.T, value any) map[string]any { t.Helper() item, ok := value.(map[string]any) if !ok { t.Fatalf("value is %T, want map[string]any: %+v", value, value) } return item } func anthropicSSEEventNames(body []byte) []string { var names []string for _, event := range bytes.Split(bytes.ReplaceAll(body, []byte("\r\n"), []byte("\n")), []byte("\n\n")) { for _, line := range bytes.Split(event, []byte("\n")) { if bytes.HasPrefix(line, []byte("event: ")) { names = append(names, string(bytes.TrimPrefix(line, []byte("event: ")))) break } } } return names }