package apicompat import ( "encoding/json" "strings" "testing" "github.com/stretchr/testify/require" ) // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // collectAnthropicStreamEvents feeds CC chunks through the direct bridge and // appends finalize events, returning the full Anthropic event sequence. func collectAnthropicStreamEvents(t *testing.T, chunks []string) []AnthropicStreamEvent { t.Helper() state := NewChatCompletionsToAnthropicStreamState("deepseek-v4-pro") var events []AnthropicStreamEvent for _, payload := range chunks { var chunk ChatCompletionsChunk require.NoError(t, json.Unmarshal([]byte(payload), &chunk)) events = append(events, ChatCompletionsChunkToAnthropicEvents(&chunk, state)...) } events = append(events, FinalizeChatCompletionsAnthropicStream(state)...) return events } // anthropicEventTypes extracts the sequence of event types for concise assertions. func anthropicEventTypes(events []AnthropicStreamEvent) []string { out := make([]string, 0, len(events)) for _, e := range events { out = append(out, e.Type) } return out } // --------------------------------------------------------------------------- // Request: AnthropicToChatCompletionsRequest // --------------------------------------------------------------------------- func TestAnthropicToChatCompletionsRequest_BasicText(t *testing.T) { req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", MaxTokens: 1024, Messages: []AnthropicMessage{ {Role: "user", Content: json.RawMessage(`"hello"`)}, }, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.Equal(t, "claude-sonnet-4-20250514", out.Model) require.Len(t, out.Messages, 1) require.Equal(t, "user", out.Messages[0].Role) require.Equal(t, `"hello"`, string(out.Messages[0].Content)) require.NotNil(t, out.MaxCompletionTokens) require.Equal(t, 1024, *out.MaxCompletionTokens) } func TestAnthropicToChatCompletionsRequest_SystemPrompt(t *testing.T) { req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", MaxTokens: 100, System: json.RawMessage(`"You are helpful"`), Messages: []AnthropicMessage{ {Role: "user", Content: json.RawMessage(`"hi"`)}, }, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.Len(t, out.Messages, 2) require.Equal(t, "system", out.Messages[0].Role) require.Equal(t, `"You are helpful"`, string(out.Messages[0].Content)) } func TestAnthropicToChatCompletionsRequest_ToolUseInAssistant(t *testing.T) { req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", MaxTokens: 100, Messages: []AnthropicMessage{ {Role: "user", Content: json.RawMessage(`"check weather"`)}, {Role: "assistant", Content: json.RawMessage(`[{"type":"text","text":"Let me check."},{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}}]`)}, {Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":"sunny"}]`)}, }, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) // user + assistant(with tool_calls) + tool reply require.GreaterOrEqual(t, len(out.Messages), 2) // Find the assistant message with tool_calls var assistant *ChatMessage for i := range out.Messages { if out.Messages[i].Role == "assistant" && len(out.Messages[i].ToolCalls) > 0 { assistant = &out.Messages[i] } } require.NotNil(t, assistant, "assistant message with tool_calls should survive normalization") require.Len(t, assistant.ToolCalls, 1) require.Equal(t, "toolu_1", assistant.ToolCalls[0].ID) require.Equal(t, "function", assistant.ToolCalls[0].Type) require.Equal(t, "get_weather", assistant.ToolCalls[0].Function.Name) require.Equal(t, `{"city":"SF"}`, assistant.ToolCalls[0].Function.Arguments) } func TestAnthropicToChatCompletionsRequest_ToolResultBecomesToolMessage(t *testing.T) { req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", MaxTokens: 100, Messages: []AnthropicMessage{ {Role: "user", Content: json.RawMessage(`"check weather"`)}, {Role: "assistant", Content: json.RawMessage(`[{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}}]`)}, {Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":"sunny, 72F"}]`)}, }, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) // Find the tool reply message var toolMsg *ChatMessage for i := range out.Messages { if out.Messages[i].Role == "tool" { toolMsg = &out.Messages[i] } } require.NotNil(t, toolMsg, "tool_result should become a tool role message") require.Equal(t, "toolu_1", toolMsg.ToolCallID) require.Equal(t, `"sunny, 72F"`, string(toolMsg.Content)) } func TestAnthropicToChatCompletionsRequest_ThinkingDropped(t *testing.T) { req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", MaxTokens: 100, Messages: []AnthropicMessage{ {Role: "assistant", Content: json.RawMessage(`[{"type":"thinking","thinking":"secret thoughts"},{"type":"text","text":"answer"}]`)}, }, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.Len(t, out.Messages, 1) // Only text survives; thinking is dropped require.Equal(t, `"answer"`, string(out.Messages[0].Content)) } func TestAnthropicToChatCompletionsRequest_ToolChoiceAuto(t *testing.T) { req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", MaxTokens: 100, Tools: []AnthropicTool{ {Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object","properties":{}}`)}, }, ToolChoice: json.RawMessage(`{"type":"auto"}`), Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.Len(t, out.Tools, 1) require.Equal(t, `"auto"`, string(out.ToolChoice)) } func TestAnthropicToChatCompletionsRequest_ToolChoiceAny(t *testing.T) { req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", MaxTokens: 100, Tools: []AnthropicTool{ {Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object","properties":{}}`)}, }, ToolChoice: json.RawMessage(`{"type":"any"}`), Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.Equal(t, `"required"`, string(out.ToolChoice)) } func TestAnthropicToChatCompletionsRequest_ToolChoiceSpecificTool(t *testing.T) { req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", MaxTokens: 100, Tools: []AnthropicTool{ {Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object","properties":{}}`)}, }, ToolChoice: json.RawMessage(`{"type":"tool","name":"get_weather"}`), Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) var tc map[string]any require.NoError(t, json.Unmarshal(out.ToolChoice, &tc)) require.Equal(t, "function", tc["type"]) fn, ok := tc["function"].(map[string]any) require.True(t, ok, "tool_choice function should be a map") require.Equal(t, "get_weather", fn["name"]) } func TestAnthropicToChatCompletionsRequest_TemperatureStrippedForReasoningModel(t *testing.T) { temp := 0.7 topP := 0.9 req := &AnthropicRequest{ Model: "gpt-5.4", MaxTokens: 100, Temperature: &temp, TopP: &topP, Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.Nil(t, out.Temperature, "temperature should be stripped for reasoning models") require.Nil(t, out.TopP, "top_p should be stripped for reasoning models") } func TestAnthropicToChatCompletionsRequest_TemperaturePreservedForNonReasoningModel(t *testing.T) { temp := 0.7 topP := 0.9 req := &AnthropicRequest{ Model: "deepseek-v4-pro", MaxTokens: 100, Temperature: &temp, TopP: &topP, Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.NotNil(t, out.Temperature) require.Equal(t, 0.7, *out.Temperature) require.NotNil(t, out.TopP) require.Equal(t, 0.9, *out.TopP) } func TestAnthropicToChatCompletionsRequest_MaxTokensFloor(t *testing.T) { req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", MaxTokens: 10, // below minMaxOutputTokens (128) Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.NotNil(t, out.MaxCompletionTokens) require.Equal(t, minMaxOutputTokens, *out.MaxCompletionTokens) } func TestAnthropicToChatCompletionsRequest_ReasoningEffortMapping(t *testing.T) { req := &AnthropicRequest{ Model: "gpt-5.4", MaxTokens: 100, OutputConfig: &AnthropicOutputConfig{Effort: "max"}, Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.Equal(t, "xhigh", out.ReasoningEffort) } func TestAnthropicToChatCompletionsRequest_ReasoningEffortDefaultMedium(t *testing.T) { req := &AnthropicRequest{ Model: "gpt-5.4", MaxTokens: 100, Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.Equal(t, "medium", out.ReasoningEffort) } func TestAnthropicToChatCompletionsRequest_ServerToolDropped(t *testing.T) { req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", MaxTokens: 100, Tools: []AnthropicTool{ {Type: "web_search_20250305", Name: "web_search"}, {Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object","properties":{}}`)}, }, Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.Len(t, out.Tools, 1, "web_search server tool should be dropped") require.Equal(t, "get_weather", out.Tools[0].Function.Name) } // --------------------------------------------------------------------------- // Non-streaming response: ChatCompletionsResponseToAnthropic // --------------------------------------------------------------------------- func TestChatCompletionsResponseToAnthropic_TextOnly(t *testing.T) { resp := &ChatCompletionsResponse{ ID: "chatcmpl-1", Model: "deepseek-v4-pro", Choices: []ChatChoice{{ Index: 0, Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hello world"`)}, FinishReason: "stop", }}, Usage: &ChatUsage{PromptTokens: 5, CompletionTokens: 2, TotalTokens: 7}, } out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") require.Equal(t, "chatcmpl-1", out.ID) require.Equal(t, "claude-sonnet-4-20250514", out.Model) require.Len(t, out.Content, 1) require.Equal(t, "text", out.Content[0].Type) require.Equal(t, "hello world", out.Content[0].Text) require.Equal(t, "end_turn", AnthropicStopReasonString(out.StopReason)) require.Equal(t, 5, out.Usage.InputTokens) require.Equal(t, 2, out.Usage.OutputTokens) } func TestChatCompletionsResponseToAnthropic_ToolUse(t *testing.T) { resp := &ChatCompletionsResponse{ ID: "chatcmpl-2", Model: "deepseek-v4-pro", Choices: []ChatChoice{{ Index: 0, Message: ChatMessage{ Role: "assistant", ToolCalls: []ChatToolCall{{ ID: "call_1", Type: "function", Function: ChatFunctionCall{ Name: "get_weather", Arguments: `{"city":"SF"}`, }, }}, }, FinishReason: "tool_calls", }}, } out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") require.Len(t, out.Content, 1) require.Equal(t, "tool_use", out.Content[0].Type) require.Equal(t, "call_1", out.Content[0].ID) require.Equal(t, "get_weather", out.Content[0].Name) require.Equal(t, `{"city":"SF"}`, string(out.Content[0].Input)) require.Equal(t, "tool_use", AnthropicStopReasonString(out.StopReason)) } func TestChatCompletionsResponseToAnthropic_ReasoningOnlyFallback(t *testing.T) { resp := &ChatCompletionsResponse{ ID: "chatcmpl-3", Model: "deepseek-v4-pro", Choices: []ChatChoice{{ Index: 0, Message: ChatMessage{ Role: "assistant", ReasoningContent: "I should think about this", }, FinishReason: "stop", }}, } out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") // thinking block + text block (fallback uses reasoning as visible text) require.Len(t, out.Content, 2) require.Equal(t, "thinking", out.Content[0].Type) require.Equal(t, "I should think about this", out.Content[0].Thinking) require.Equal(t, "text", out.Content[1].Type) require.Equal(t, "I should think about this", out.Content[1].Text) } func TestChatCompletionsResponseToAnthropic_FinishReasonLength(t *testing.T) { resp := &ChatCompletionsResponse{ ID: "chatcmpl-4", Model: "deepseek-v4-pro", Choices: []ChatChoice{{ Index: 0, Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"truncated"`)}, FinishReason: "length", }}, } out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") require.Equal(t, "max_tokens", AnthropicStopReasonString(out.StopReason)) } func TestChatCompletionsResponseToAnthropic_EmptyChoices(t *testing.T) { resp := &ChatCompletionsResponse{ ID: "chatcmpl-5", Model: "deepseek-v4-pro", Choices: []ChatChoice{}, } out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") require.Len(t, out.Content, 1) require.Equal(t, "text", out.Content[0].Type) require.Equal(t, "", out.Content[0].Text) require.Equal(t, "end_turn", AnthropicStopReasonString(out.StopReason), "empty choices must not produce an empty stop_reason") } func TestChatCompletionsResponseToAnthropic_CacheTokens(t *testing.T) { resp := &ChatCompletionsResponse{ ID: "chatcmpl-6", Model: "deepseek-v4-pro", Choices: []ChatChoice{{ Index: 0, Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}, FinishReason: "stop", }}, Usage: &ChatUsage{ PromptTokens: 100, CompletionTokens: 5, TotalTokens: 105, PromptTokensDetails: &ChatTokenDetails{ CachedTokens: 30, CacheCreationTokens: 10, }, }, } out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") // input = prompt(100) - cached(30) - cacheCreation(10) = 60 require.Equal(t, 60, out.Usage.InputTokens) require.Equal(t, 5, out.Usage.OutputTokens) require.Equal(t, 30, out.Usage.CacheReadInputTokens) require.Equal(t, 10, out.Usage.CacheCreationInputTokens) } func TestChatCompletionsResponseToAnthropic_NilResponse(t *testing.T) { out := ChatCompletionsResponseToAnthropic(nil, "claude-sonnet-4-20250514") require.Len(t, out.Content, 1) require.Equal(t, "text", out.Content[0].Type) require.Equal(t, "end_turn", AnthropicStopReasonString(out.StopReason), "nil response must not produce an empty stop_reason") require.NotEmpty(t, out.ID) } // --------------------------------------------------------------------------- // Streaming: ChatCompletionsChunkToAnthropicEvents // --------------------------------------------------------------------------- func TestChatCompletionsChunkToAnthropicEvents_TextOnly(t *testing.T) { events := collectAnthropicStreamEvents(t, []string{ `{"choices":[{"index":0,"delta":{"role":"assistant","content":"hello"}}]}`, `{"choices":[{"index":0,"delta":{"content":" world"}}]}`, `{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`, }) types := anthropicEventTypes(events) // message_start → content_block_start(text) → 2× content_block_delta → content_block_stop → message_delta → message_stop require.Equal(t, []string{ "message_start", "content_block_start", "content_block_delta", "content_block_delta", "content_block_stop", "message_delta", "message_stop", }, types) // Verify deltas var texts []string for _, e := range events { if e.Type == "content_block_delta" && e.Delta != nil { texts = append(texts, e.Delta.Text) } } require.Equal(t, []string{"hello", " world"}, texts) // Verify stop reason for _, e := range events { if e.Type == "message_delta" { require.Equal(t, "end_turn", e.Delta.StopReason) require.Equal(t, 5, e.Usage.InputTokens) require.Equal(t, 2, e.Usage.OutputTokens) } } } func TestChatCompletionsChunkToAnthropicEvents_ReasoningThenContent(t *testing.T) { events := collectAnthropicStreamEvents(t, []string{ `{"choices":[{"index":0,"delta":{"reasoning_content":"thinking..."}}]}`, `{"choices":[{"index":0,"delta":{"content":"answer"}}]}`, `{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`, }) types := anthropicEventTypes(events) // message_start → thinking block start → thinking_delta → thinking block stop // → text block start → text_delta → text block stop → message_delta → message_stop require.Equal(t, []string{ "message_start", "content_block_start", "content_block_delta", "content_block_stop", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop", }, types) // First content_block_start should be thinking, second text var blockTypes []string for _, e := range events { if e.Type == "content_block_start" && e.ContentBlock != nil { blockTypes = append(blockTypes, e.ContentBlock.Type) } } require.Equal(t, []string{"thinking", "text"}, blockTypes) } func TestChatCompletionsChunkToAnthropicEvents_ToolCallAggregation(t *testing.T) { events := collectAnthropicStreamEvents(t, []string{ `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}`, `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]}}]}`, `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"SF\"}"}}]}}]}`, `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`, }) types := anthropicEventTypes(events) // message_start → content_block_start(tool_use) → 2× input_json_delta (empty first arg skipped) → content_block_stop → message_delta(tool_use) → message_stop require.Equal(t, []string{ "message_start", "content_block_start", "content_block_delta", "content_block_delta", "content_block_stop", "message_delta", "message_stop", }, types) // Verify tool_use block for _, e := range events { if e.Type == "content_block_start" && e.ContentBlock != nil { require.Equal(t, "tool_use", e.ContentBlock.Type) require.Equal(t, "call_1", e.ContentBlock.ID) require.Equal(t, "get_weather", e.ContentBlock.Name) } if e.Type == "message_delta" { require.Equal(t, "tool_use", e.Delta.StopReason) } } // Verify arguments assembled (empty first fragment skipped) var partials []string for _, e := range events { if e.Type == "content_block_delta" && e.Delta != nil { partials = append(partials, e.Delta.PartialJSON) } } require.Equal(t, []string{`{"city":`, `"SF"}`}, partials) } func TestChatCompletionsChunkToAnthropicEvents_LengthMapsToMaxTokens(t *testing.T) { events := collectAnthropicStreamEvents(t, []string{ `{"choices":[{"index":0,"delta":{"content":"partial"}}]}`, `{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"length"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`, }) for _, e := range events { if e.Type == "message_delta" { require.Equal(t, "max_tokens", e.Delta.StopReason) } } } func TestChatCompletionsChunkToAnthropicEvents_EmptyStream(t *testing.T) { events := collectAnthropicStreamEvents(t, []string{ `{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":0,"total_tokens":1}}`, }) types := anthropicEventTypes(events) // Even with no content, message_start + message_delta + message_stop should fire. require.Contains(t, types, "message_start") require.Contains(t, types, "message_stop") } func TestChatCompletionsChunkToAnthropicEvents_MessageStartEmittedOnce(t *testing.T) { events := collectAnthropicStreamEvents(t, []string{ `{"choices":[{"index":0,"delta":{"content":"a"}}]}`, `{"choices":[{"index":0,"delta":{"content":"b"}}]}`, `{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`, }) count := 0 for _, e := range events { if e.Type == "message_start" { count++ } } require.Equal(t, 1, count, "message_start should only be emitted once") } func TestChatCompletionsChunkToAnthropicEvents_ParallelToolCalls(t *testing.T) { events := collectAnthropicStreamEvents(t, []string{ `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"tool_a","arguments":"{}"}}]}}]}`, `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"tool_b","arguments":"{}"}}]}}]}`, `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`, }) // Two tool_use blocks should be opened var toolBlocks []string for _, e := range events { if e.Type == "content_block_start" && e.ContentBlock != nil && e.ContentBlock.Type == "tool_use" { toolBlocks = append(toolBlocks, e.ContentBlock.Name) } } require.Equal(t, []string{"tool_a", "tool_b"}, toolBlocks) for _, e := range events { if e.Type == "message_delta" { require.Equal(t, "tool_use", e.Delta.StopReason) } } } func TestFinalizeChatCompletionsAnthropicStream_NoOpAfterStop(t *testing.T) { state := NewChatCompletionsToAnthropicStreamState("test") state.MessageStopSent = true events := FinalizeChatCompletionsAnthropicStream(state) require.Nil(t, events, "finalize should be a no-op after message_stop") } func TestFinalizeChatCompletionsAnthropicStream_EmitsMessageStartIfMissing(t *testing.T) { state := NewChatCompletionsToAnthropicStreamState("test") // Never fed any chunks — message_start not yet sent events := FinalizeChatCompletionsAnthropicStream(state) types := anthropicEventTypes(events) require.Contains(t, types, "message_start") require.Contains(t, types, "message_stop") } // --------------------------------------------------------------------------- // Equivalence: direct bridge matches the double-conversion bridge // --------------------------------------------------------------------------- // TestDirectBridge_NonStreamingMatchesDoubleConversion verifies that // ChatCompletionsResponseToAnthropic produces the same Anthropic response as the // existing ChatCompletionsResponseToResponses + ResponsesToAnthropic chain. func TestDirectBridge_NonStreamingMatchesDoubleConversion(t *testing.T) { resp := &ChatCompletionsResponse{ ID: "chatcmpl-eq", Model: "deepseek-v4-pro", Choices: []ChatChoice{{ Index: 0, Message: ChatMessage{ Role: "assistant", Content: json.RawMessage(`"hello"`), ReasoningContent: "reasoning text", ToolCalls: []ChatToolCall{{ ID: "call_eq", Type: "function", Function: ChatFunctionCall{ Name: "search", Arguments: `{"q":"test"}`, }, }}, }, FinishReason: "tool_calls", }}, Usage: &ChatUsage{ PromptTokens: 50, CompletionTokens: 10, TotalTokens: 60, PromptTokensDetails: &ChatTokenDetails{CachedTokens: 5}, }, } // Direct bridge direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") // Double-conversion bridge responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil) double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") // Compare key fields require.Equal(t, AnthropicStopReasonString(direct.StopReason), AnthropicStopReasonString(double.StopReason)) require.Equal(t, direct.Model, double.Model) require.Len(t, direct.Content, len(double.Content)) for i := range direct.Content { require.Equal(t, double.Content[i].Type, direct.Content[i].Type, "block %d type mismatch", i) require.Equal(t, double.Content[i].Text, direct.Content[i].Text, "block %d text mismatch", i) require.Equal(t, double.Content[i].Thinking, direct.Content[i].Thinking, "block %d thinking mismatch", i) require.Equal(t, double.Content[i].Name, direct.Content[i].Name, "block %d name mismatch", i) require.Equal(t, double.Content[i].ID, direct.Content[i].ID, "block %d id mismatch", i) require.Equal(t, string(double.Content[i].Input), string(direct.Content[i].Input), "block %d input mismatch", i) } require.Equal(t, double.Usage.InputTokens, direct.Usage.InputTokens) require.Equal(t, double.Usage.OutputTokens, direct.Usage.OutputTokens) require.Equal(t, double.Usage.CacheReadInputTokens, direct.Usage.CacheReadInputTokens) require.Equal(t, double.Usage.CacheCreationInputTokens, direct.Usage.CacheCreationInputTokens) } // TestDirectBridge_RequestMatchesDoubleConversion verifies that // AnthropicToChatCompletionsRequest produces an equivalent Chat Completions // request as the AnthropicToResponses + ResponsesToChatCompletionsRequest chain. func TestDirectBridge_RequestMatchesDoubleConversion(t *testing.T) { temp := 0.5 req := &AnthropicRequest{ Model: "deepseek-v4-pro", MaxTokens: 500, Temperature: &temp, System: json.RawMessage(`"be helpful"`), Tools: []AnthropicTool{ {Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}}}`)}, }, ToolChoice: json.RawMessage(`{"type":"auto"}`), Messages: []AnthropicMessage{ {Role: "user", Content: json.RawMessage(`"what's the weather?"`)}, {Role: "assistant", Content: json.RawMessage(`[{"type":"text","text":"checking"},{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}}]`)}, {Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":"sunny"}]`)}, }, } // Direct bridge direct, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) // Double-conversion bridge responsesReq, err := AnthropicToResponses(req) require.NoError(t, err) double, err := ResponsesToChatCompletionsRequest(responsesReq) require.NoError(t, err) // Compare key fields require.Equal(t, double.Model, direct.Model) require.Equal(t, double.Temperature, direct.Temperature) require.Equal(t, double.MaxCompletionTokens, direct.MaxCompletionTokens) require.Equal(t, double.ReasoningEffort, direct.ReasoningEffort) require.Equal(t, string(double.ToolChoice), string(direct.ToolChoice)) require.Len(t, direct.Tools, len(double.Tools)) // Compare messages — same count, same roles, same content require.Len(t, direct.Messages, len(double.Messages), "message count mismatch") for i := range direct.Messages { require.Equal(t, double.Messages[i].Role, direct.Messages[i].Role, "msg %d role mismatch", i) // Normalize content for comparison (both should be valid JSON) var dContent, dblContent any _ = json.Unmarshal(double.Messages[i].Content, &dblContent) _ = json.Unmarshal(direct.Messages[i].Content, &dContent) require.Equal(t, dblContent, dContent, "msg %d content mismatch", i) require.Equal(t, double.Messages[i].ToolCallID, direct.Messages[i].ToolCallID, "msg %d tool_call_id mismatch", i) require.Len(t, direct.Messages[i].ToolCalls, len(double.Messages[i].ToolCalls), "msg %d tool_calls count mismatch", i) for j := range direct.Messages[i].ToolCalls { require.Equal(t, double.Messages[i].ToolCalls[j].ID, direct.Messages[i].ToolCalls[j].ID, "msg %d tool %d id mismatch", i, j) require.Equal(t, double.Messages[i].ToolCalls[j].Function.Name, direct.Messages[i].ToolCalls[j].Function.Name, "msg %d tool %d name mismatch", i, j) require.Equal(t, double.Messages[i].ToolCalls[j].Function.Arguments, direct.Messages[i].ToolCalls[j].Function.Arguments, "msg %d tool %d arguments mismatch", i, j) } } } // --------------------------------------------------------------------------- // Edge cases // --------------------------------------------------------------------------- func TestChatCompletionsChunkToAnthropicEvents_ImageInToolResult(t *testing.T) { // A multi-turn conversation: assistant calls a tool, user replies with a // tool_result containing text + an image. The image should be lifted into // a follow-up user message as an image_url part. req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", MaxTokens: 100, Messages: []AnthropicMessage{ {Role: "user", Content: json.RawMessage(`"check this image"`)}, {Role: "assistant", Content: json.RawMessage(`[{"type":"text","text":"let me look"},{"type":"tool_use","id":"toolu_1","name":"analyze","input":{"x":1}}]`)}, {Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":[{"type":"text","text":"result"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}]}]`)}, }, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) // user + assistant(tool_use) + tool + user(image) require.GreaterOrEqual(t, len(out.Messages), 3) // Find the user message with image content var foundImage bool for _, m := range out.Messages { if m.Role != "user" { continue } var parts []ChatContentPart if err := json.Unmarshal(m.Content, &parts); err == nil { for _, p := range parts { if p.Type == "image_url" && p.ImageURL != nil { foundImage = true require.True(t, strings.HasPrefix(p.ImageURL.URL, "data:image/png;base64,")) } } } } require.True(t, foundImage, "image from tool_result should appear in user message") } func TestChatCompletionsToAnthropicStreamState_ToolCallNameArrivesLate(t *testing.T) { // Some upstreams send the tool_call index + arguments before the name. events := collectAnthropicStreamEvents(t, []string{ `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_late"}]}}]}`, `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"late_tool"}}]}}]}`, `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}`, `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`, }) // The tool_use block should still be opened with the correct name. var toolName string for _, e := range events { if e.Type == "content_block_start" && e.ContentBlock != nil && e.ContentBlock.Type == "tool_use" { toolName = e.ContentBlock.Name } } require.Equal(t, "late_tool", toolName) } // assembleToolUseBlocks rebuilds tool_use blocks from a stream the way an // Anthropic client does: content_block_start announces id/name, input_json_delta // fragments concatenate into the input JSON. type assembledToolUse struct { ID string Name string Input string } func assembleToolUseBlocks(events []AnthropicStreamEvent) []assembledToolUse { blockByIdx := map[int]int{} // anthropic block index → position in out var out []assembledToolUse for _, e := range events { switch e.Type { case "content_block_start": if e.ContentBlock != nil && e.ContentBlock.Type == "tool_use" && e.Index != nil { blockByIdx[*e.Index] = len(out) out = append(out, assembledToolUse{ID: e.ContentBlock.ID, Name: e.ContentBlock.Name}) } case "content_block_delta": if e.Delta != nil && e.Delta.Type == "input_json_delta" && e.Index != nil { if pos, ok := blockByIdx[*e.Index]; ok { out[pos].Input += e.Delta.PartialJSON } } } } return out } func TestChatCompletionsToAnthropicStreamState_ToolCallArgsArriveBeforeName(t *testing.T) { // Some upstreams stream argument fragments before the tool name. The // fragments buffered while the announcement is deferred must be flushed // with the content_block_start, so the client rebuilds complete JSON. events := collectAnthropicStreamEvents(t, []string{ `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_early","function":{"arguments":"{\"city\":"}}]}}]}`, `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"SF\""}}]}}]}`, `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"get_weather","arguments":"}"}}]}}]}`, `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`, }) tools := assembleToolUseBlocks(events) require.Len(t, tools, 1) require.Equal(t, "call_early", tools[0].ID) require.Equal(t, "get_weather", tools[0].Name) require.JSONEq(t, `{"city":"SF"}`, tools[0].Input) // No delta may precede the block's content_block_start. started := map[int]bool{} for _, e := range events { switch e.Type { case "content_block_start": started[*e.Index] = true case "content_block_delta": require.True(t, started[*e.Index], "delta before content_block_start on index %d", *e.Index) } } } func TestChatCompletionsToAnthropicStreamState_ToolCallNameNeverArrives(t *testing.T) { // If the name never arrives, the tool is announced at finalize with an // empty name (like the double-conversion path) so its arguments are not // silently dropped — stop_reason still reports tool_use. events := collectAnthropicStreamEvents(t, []string{ `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_anon","function":{"arguments":"{\"a\":1}"}}]}}]}`, `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`, }) tools := assembleToolUseBlocks(events) require.Len(t, tools, 1) require.Equal(t, "call_anon", tools[0].ID) require.Equal(t, "", tools[0].Name) require.JSONEq(t, `{"a":1}`, tools[0].Input) // Block lifecycle must stay balanced and terminate before message_stop. types := anthropicEventTypes(events) require.Equal(t, []string{ "message_start", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop", }, types) } func TestChatCompletionsToAnthropicStreamState_EmptyArgsToolEmitsPlaceholderDelta(t *testing.T) { // A tool call whose arguments never arrive gets a final input_json_delta // "{}" before its stop — the double-conversion path normalizes empty // arguments to "{}", and some clients assemble input only from deltas. events := collectAnthropicStreamEvents(t, []string{ `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_empty","type":"function","function":{"name":"noop","arguments":""}}]}}]}`, `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`, }) tools := assembleToolUseBlocks(events) require.Len(t, tools, 1) require.Equal(t, "noop", tools[0].Name) require.JSONEq(t, `{}`, tools[0].Input) } func TestAnthropicToChatCompletionsRequest_UserArrayContentFoldsToString(t *testing.T) { // Text-only array content folds into a single string joined with "\n\n", // like the double-conversion path — strict chat upstreams reject array // content when no image forces the parts form. req := &AnthropicRequest{ Model: "deepseek-v4-pro", MaxTokens: 100, Messages: []AnthropicMessage{ {Role: "user", Content: json.RawMessage(`[{"type":"text","text":"first"},{"type":"text","text":"second"}]`)}, }, } out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.Len(t, out.Messages, 1) require.Equal(t, `"first\n\nsecond"`, string(out.Messages[0].Content)) } func TestDirectBridge_RequestMatchesDoubleConversion_ArrayUserContent(t *testing.T) { // Array-form user content: text-only folds to a string, image-bearing stays // in parts form — both must match the double-conversion chain exactly. req := &AnthropicRequest{ Model: "deepseek-v4-pro", MaxTokens: 100, Messages: []AnthropicMessage{ {Role: "user", Content: json.RawMessage(`[{"type":"text","text":"first"},{"type":"text","text":"second"}]`)}, {Role: "assistant", Content: json.RawMessage(`"ok"`)}, {Role: "user", Content: json.RawMessage(`[{"type":"text","text":"look"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}]`)}, }, } direct, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) responsesReq, err := AnthropicToResponses(req) require.NoError(t, err) double, err := ResponsesToChatCompletionsRequest(responsesReq) require.NoError(t, err) require.Len(t, direct.Messages, len(double.Messages), "message count mismatch") for i := range direct.Messages { require.Equal(t, double.Messages[i].Role, direct.Messages[i].Role, "msg %d role mismatch", i) var dContent, dblContent any require.NoError(t, json.Unmarshal(double.Messages[i].Content, &dblContent)) require.NoError(t, json.Unmarshal(direct.Messages[i].Content, &dContent)) require.Equal(t, dblContent, dContent, "msg %d content mismatch", i) } } func TestDirectBridge_NonStreamingMatchesDoubleConversion_CacheWriteTokens(t *testing.T) { // cache_write_tokens and cache_creation_tokens are alternate spellings, not // additive — when both are set, the double-conversion path prefers write. resp := &ChatCompletionsResponse{ ID: "chatcmpl-cache", Model: "deepseek-v4-pro", Choices: []ChatChoice{{ Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}, FinishReason: "stop", }}, Usage: &ChatUsage{ PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, PromptTokensDetails: &ChatTokenDetails{ CachedTokens: 20, CacheCreationTokens: 7, CacheWriteTokens: 9, }, }, } direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil) double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") require.Equal(t, double.Usage.InputTokens, direct.Usage.InputTokens) require.Equal(t, double.Usage.OutputTokens, direct.Usage.OutputTokens) require.Equal(t, double.Usage.CacheReadInputTokens, direct.Usage.CacheReadInputTokens) require.Equal(t, double.Usage.CacheCreationInputTokens, direct.Usage.CacheCreationInputTokens) require.Equal(t, 9, direct.Usage.CacheCreationInputTokens) } func TestChatCompletionsResponseToAnthropic_GeneratesIDWhenMissing(t *testing.T) { resp := &ChatCompletionsResponse{ Model: "deepseek-v4-pro", Choices: []ChatChoice{{ Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}, FinishReason: "stop", }}, } out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") require.NotEmpty(t, out.ID, "response id must be generated when the upstream omits one") } func TestAnthropicToChatCompletionsRequest_ToolChoiceUndeclaredDropped(t *testing.T) { // A named tool_choice pointing at a dropped/unknown tool is not forwarded — // chat upstreams 400 on tool_choice referencing an undeclared tool. base := AnthropicRequest{ Model: "deepseek-v4-pro", MaxTokens: 100, Tools: []AnthropicTool{ {Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object"}`)}, {Name: "web_search_20250305", Type: "web_search_20250305", InputSchema: json.RawMessage(`{"type":"object"}`)}, }, Messages: []AnthropicMessage{ {Role: "user", Content: json.RawMessage(`"hi"`)}, }, } undeclared := base undeclared.ToolChoice = json.RawMessage(`{"type":"tool","name":"nonexistent"}`) out, err := AnthropicToChatCompletionsRequest(&undeclared) require.NoError(t, err) require.Empty(t, out.ToolChoice, "tool_choice for an undeclared tool must be dropped") droppedServerTool := base droppedServerTool.ToolChoice = json.RawMessage(`{"type":"tool","name":"web_search_20250305"}`) out, err = AnthropicToChatCompletionsRequest(&droppedServerTool) require.NoError(t, err) require.Empty(t, out.ToolChoice, "tool_choice for a dropped server tool must be dropped") unknownType := base unknownType.ToolChoice = json.RawMessage(`{"type":"mystery"}`) out, err = AnthropicToChatCompletionsRequest(&unknownType) require.NoError(t, err) require.Empty(t, out.ToolChoice, "unknown tool_choice types must be dropped") declared := base declared.ToolChoice = json.RawMessage(`{"type":"tool","name":"get_weather"}`) out, err = AnthropicToChatCompletionsRequest(&declared) require.NoError(t, err) require.JSONEq(t, `{"type":"function","function":{"name":"get_weather"}}`, string(out.ToolChoice)) } func TestDirectBridge_NonStreamingMatchesDoubleConversion_EmptyChoices(t *testing.T) { // An upstream 200 with empty choices must still report a valid stop_reason, // matching the double-conversion chain ("end_turn"). resp := &ChatCompletionsResponse{ID: "chatcmpl-empty", Model: "deepseek-v4-pro"} direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil) double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") require.Equal(t, AnthropicStopReasonString(double.StopReason), AnthropicStopReasonString(direct.StopReason)) require.Equal(t, "end_turn", AnthropicStopReasonString(direct.StopReason)) } func TestChatCompletionsResponseToAnthropic_ContentFilterWithToolUse(t *testing.T) { // content_filter (and unknown finish reasons) derive stop_reason from the // blocks, like the double-conversion path. resp := &ChatCompletionsResponse{ ID: "chatcmpl-cf", Model: "deepseek-v4-pro", Choices: []ChatChoice{{ Message: ChatMessage{ Role: "assistant", Content: json.RawMessage(`"partial"`), ToolCalls: []ChatToolCall{{ ID: "call_cf", Type: "function", Function: ChatFunctionCall{Name: "search", Arguments: `{"q":"x"}`}, }}, }, FinishReason: "content_filter", }}, } out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") require.Equal(t, "tool_use", AnthropicStopReasonString(out.StopReason)) }