diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go index b434180573..1890cccf44 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -3,6 +3,7 @@ package apicompat import ( "encoding/json" "fmt" + "sort" "strings" "time" ) @@ -79,13 +80,23 @@ func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsR } // tool_choice is only forwarded when tools survived the conversion - // (upstream rejects tool_choice without tools). + // (upstream rejects tool_choice without tools), and a named choice only when + // it points at a declared tool — mirroring responsesToolChoiceToChatToolChoice, + // chat upstreams 400 on tool_choice referencing an unknown tool. if len(out.Tools) > 0 && len(req.ToolChoice) > 0 { - tc, err := convertAnthropicToolChoiceToChat(req.ToolChoice) + declared := make(map[string]bool, len(out.Tools)) + for _, tool := range out.Tools { + if tool.Function != nil { + declared[tool.Function.Name] = true + } + } + tc, err := convertAnthropicToolChoiceToChat(req.ToolChoice, declared) if err != nil { return nil, fmt.Errorf("convert tool_choice: %w", err) } - out.ToolChoice = tc + if len(tc) > 0 { + out.ToolChoice = tc + } } // Reasoning effort: output_config.effort maps 1:1 (max→xhigh). thinking.type @@ -189,16 +200,24 @@ func anthropicUserToChatMessages(raw json.RawMessage) ([]ChatMessage, error) { } } - // Remaining text + image blocks → user message with content parts. + // Remaining text + image blocks → user message. The double-conversion path + // (responsesContentPartsToChatContent) folds text-only content into a single + // string joined with "\n\n" and only uses the parts-array form when an image + // is present — strict chat upstreams reject array content — so the direct + // bridge preserves that folding. + var textParts []string var parts []ChatContentPart + hasImage := false for _, b := range blocks { switch b.Type { case "text": if b.Text != "" { + textParts = append(textParts, b.Text) parts = append(parts, ChatContentPart{Type: "text", Text: b.Text}) } case "image": if uri := anthropicImageToDataURI(b.Source); uri != "" { + hasImage = true parts = append(parts, ChatContentPart{ Type: "image_url", ImageURL: &ChatImageURL{URL: uri}, @@ -206,19 +225,25 @@ func anthropicUserToChatMessages(raw json.RawMessage) ([]ChatMessage, error) { } } } - parts = append(parts, toolResultImageParts...) - - if len(parts) > 0 { - // Mixed/structured content → array form; single text → string form - // (normalizeChatMessages will collapse a single-text-part array to a - // plain string if the upstream prefers it). - content, err := json.Marshal(parts) - if err != nil { - return nil, err - } - out = append(out, ChatMessage{Role: "user", Content: content}) + if len(toolResultImageParts) > 0 { + hasImage = true + parts = append(parts, toolResultImageParts...) } + if !hasImage { + if len(textParts) > 0 { + content, _ := json.Marshal(strings.Join(textParts, "\n\n")) + out = append(out, ChatMessage{Role: "user", Content: content}) + } + return out, nil + } + + content, err := json.Marshal(parts) + if err != nil { + return nil, err + } + out = append(out, ChatMessage{Role: "user", Content: content}) + return out, nil } @@ -290,13 +315,16 @@ func anthropicToolsToChatTools(tools []AnthropicTool) []ChatTool { } // convertAnthropicToolChoiceToChat maps Anthropic tool_choice to Chat -// Completions tool_choice. +// Completions tool_choice. A nil result means the choice is dropped: like the +// double-conversion path (responsesToolChoiceToChatToolChoice), a named choice +// pointing at an undeclared tool or an unknown choice type is not forwarded, +// because chat upstreams reject it. // // {"type":"auto"} → "auto" // {"type":"any"} → "required" // {"type":"none"} → "none" -// {"type":"tool","name":"X"} → {"type":"function","function":{"name":"X"}} -func convertAnthropicToolChoiceToChat(raw json.RawMessage) (json.RawMessage, error) { +// {"type":"tool","name":"X"} → {"type":"function","function":{"name":"X"}} (X declared) +func convertAnthropicToolChoiceToChat(raw json.RawMessage, declared map[string]bool) (json.RawMessage, error) { var tc struct { Type string `json:"type"` Name string `json:"name"` @@ -313,12 +341,15 @@ func convertAnthropicToolChoiceToChat(raw json.RawMessage) (json.RawMessage, err case "none": return json.Marshal("none") case "tool": + if tc.Name == "" || !declared[tc.Name] { + return nil, nil + } return json.Marshal(map[string]any{ "type": "function", "function": map[string]string{"name": tc.Name}, }) default: - return raw, nil + return nil, nil } } @@ -351,9 +382,7 @@ func ChatCompletionsResponseToAnthropic(resp *ChatCompletionsResponse, model str } if resp != nil { - if out.ID == "" { - out.ID = resp.ID - } + out.ID = resp.ID if out.Model == "" { out.Model = resp.Model } @@ -373,6 +402,11 @@ func ChatCompletionsResponseToAnthropic(resp *ChatCompletionsResponse, model str if len(out.Content) == 0 { out.Content = []AnthropicContentBlock{{Type: "text", Text: ""}} } + // The double-conversion path generates a response id when the upstream + // omits one (ChatCompletionsResponseToResponses); clients treat it as required. + if out.ID == "" { + out.ID = generateResponsesID() + } return out } @@ -420,23 +454,23 @@ func chatMessageToAnthropicBlocks(message ChatMessage) []AnthropicContentBlock { // chatFinishReasonToAnthropicStopReason maps Chat Completions finish_reason to // Anthropic stop_reason. // -// "stop" → "end_turn" (or "tool_use" if tool_use blocks present) -// "length" → "max_tokens" -// "tool_calls" → "tool_use" -// "content_filter" → "end_turn" +// "length" → "max_tokens" +// "tool_calls" → "tool_use" +// other → "end_turn" (or "tool_use" if tool_use blocks present) +// +// "stop", "content_filter", and unknown reasons all map to a completed response +// in the double-conversion path, which then derives stop_reason from the blocks. func chatFinishReasonToAnthropicStopReason(reason string, blocks []AnthropicContentBlock) string { switch reason { case "length": return "max_tokens" case "tool_calls": return "tool_use" - case "stop": + default: if containsAnthropicToolUseBlock(blocks) { return "tool_use" } return "end_turn" - default: - return "end_turn" } } @@ -451,8 +485,14 @@ func chatUsageToAnthropicUsage(usage *ChatUsage) AnthropicUsage { cacheCreationTokens := 0 if usage.PromptTokensDetails != nil { cachedTokens = usage.PromptTokensDetails.CachedTokens - cacheCreationTokens = usage.PromptTokensDetails.CacheCreationTokens + - usage.PromptTokensDetails.CacheWriteTokens + // cache_write_tokens and cache_creation_tokens are alternate spellings of + // the same quantity, not additive; the double-conversion path + // (ChatUsageToResponsesUsage) prefers write and falls back to creation. + if usage.PromptTokensDetails.CacheWriteTokens > 0 { + cacheCreationTokens = usage.PromptTokensDetails.CacheWriteTokens + } else { + cacheCreationTokens = usage.PromptTokensDetails.CacheCreationTokens + } } inputTokens := usage.PromptTokens - cachedTokens - cacheCreationTokens @@ -485,17 +525,20 @@ type ChatCompletionsToAnthropicStreamState struct { ContentBlockOpen bool CurrentBlockType string // "text" | "thinking" | "tool_use" CurrentToolName string - CurrentToolArgs string CurrentToolHadDelta bool HasToolCall bool // Tool calls keyed by the upstream tool_call index. The Anthropic block - // index assigned at content_block_start time is stored so later argument - // deltas for the same tool land on the right block. + // index is assigned when the tool block is announced (content_block_start), + // which is deferred until the tool's name has arrived. Argument fragments + // and the call ID seen before the name are buffered and flushed with the + // announcement; tools whose name never arrives are announced with an empty + // name at finalize so their arguments are not lost. toolBlockIndex map[int]int toolAnnounced map[int]bool toolName map[int]string - pendingToolCallID map[int]string // call ID received before the name (deferred announce) + pendingToolCallID map[int]string + pendingToolArgs map[int]string // Reasoning (DeepSeek-style): reasoning_content streamed before content. // No separate reasoning block index — it uses ContentBlockIndex like the @@ -524,6 +567,7 @@ func NewChatCompletionsToAnthropicStreamState(model string) *ChatCompletionsToAn toolAnnounced: make(map[int]bool), toolName: make(map[int]string), pendingToolCallID: make(map[int]string), + pendingToolArgs: make(map[int]string), } } @@ -601,6 +645,24 @@ func FinalizeChatCompletionsAnthropicStream(state *ChatCompletionsToAnthropicStr if !state.MessageStartSent { events = append(events, ensureCCAnthropicMessageStart(state)...) } + + // Announce tools whose name never arrived so their buffered arguments are + // not silently dropped. The double-conversion path announced these + // immediately with an empty name; the deferred announcement keeps that data + // preservation while still delivering correct names when they do arrive. + if len(state.pendingToolCallID) > 0 { + idxs := make([]int, 0, len(state.pendingToolCallID)) + for idx := range state.pendingToolCallID { + idxs = append(idxs, idx) + } + sort.Ints(idxs) + for _, idx := range idxs { + callID := state.pendingToolCallID[idx] + events = append(events, closeCCAnthropicBlock(state)...) + events = append(events, announceCCAnthropicToolBlock(state, idx, callID, "")...) + } + } + events = append(events, closeCCAnthropicBlock(state)...) stopReason := ccFinishReasonToAnthropicStopReason(state.FinishReason, state.HasToolCall) @@ -683,9 +745,11 @@ func ensureCCAnthropicTextBlock(state *ChatCompletionsToAnthropicStreamState) [] return events } -// handleCCAnthropicToolCall processes one upstream tool_call delta. A new index -// opens a tool_use block (deferred if the name hasn't arrived yet); argument -// fragments emit input_json_delta on the tool's block. +// handleCCAnthropicToolCall processes one upstream tool_call delta. The +// content_block_start for a tool is deferred until its name has arrived (some +// upstreams stream id/arguments before the name); argument fragments seen +// before the announcement are buffered and flushed with it, later fragments +// stream as input_json_delta on the tool's block. func handleCCAnthropicToolCall(state *ChatCompletionsToAnthropicStreamState, toolCall *ChatToolCall) []AnthropicStreamEvent { idx := 0 if toolCall.Index != nil { @@ -694,81 +758,39 @@ func handleCCAnthropicToolCall(state *ChatCompletionsToAnthropicStreamState, too var events []AnthropicStreamEvent - if _, ok := state.toolBlockIndex[idx]; !ok { - // New tool call. Close any open non-tool block first. + if _, seen := state.toolAnnounced[idx]; !seen { + // New tool call: it ends whatever block is currently streaming. events = append(events, closeCCAnthropicBlock(state)...) - blockIdx := state.ContentBlockIndex - state.toolBlockIndex[idx] = blockIdx state.HasToolCall = true - // Open the tool_use block immediately if we have an ID + name; otherwise - // defer the content_block_start until the name arrives. callID := toolCall.ID if callID == "" { callID = generateItemID() } - name := toolCall.Function.Name - if name != "" { - state.toolAnnounced[idx] = true - state.toolName[idx] = name - state.CurrentToolName = name - state.ContentBlockOpen = true - state.CurrentBlockType = "tool_use" - events = append(events, AnthropicStreamEvent{ - Type: "content_block_start", - Index: &blockIdx, - ContentBlock: &AnthropicContentBlock{ - Type: "tool_use", - ID: fromResponsesCallID(callID), - Name: name, - Input: json.RawMessage("{}"), - }, - }) + if name := toolCall.Function.Name; name != "" { + events = append(events, announceCCAnthropicToolBlock(state, idx, callID, name)...) } else { state.toolAnnounced[idx] = false - // Store the call ID so we can emit content_block_start when the - // name arrives. We stash it in toolName prefixed with the ID marker - // is unnecessary — keep the pending ID separately is cleaner, but - // to avoid another map we re-derive: the next delta for this idx - // with a name will announce. We still need the ID though. - // Store ID in toolName as "id\x00" sentinel? No — add a field. state.pendingToolCallID[idx] = callID } - } else { - // Existing tool call: update ID/name if provided. - if toolCall.Function.Name != "" && !state.toolAnnounced[idx] { - blockIdx := state.toolBlockIndex[idx] - name := toolCall.Function.Name - state.toolAnnounced[idx] = true - state.toolName[idx] = name - state.CurrentToolName = name - state.ContentBlockOpen = true - state.CurrentBlockType = "tool_use" - callID := state.pendingToolCallID[idx] - if toolCall.ID != "" { - callID = toolCall.ID - } - if callID == "" { - callID = generateItemID() - } - events = append(events, AnthropicStreamEvent{ - Type: "content_block_start", - Index: &blockIdx, - ContentBlock: &AnthropicContentBlock{ - Type: "tool_use", - ID: fromResponsesCallID(callID), - Name: name, - Input: json.RawMessage("{}"), - }, - }) + } else if !state.toolAnnounced[idx] && toolCall.Function.Name != "" { + // Deferred announcement: the name has arrived. + callID := state.pendingToolCallID[idx] + if toolCall.ID != "" { + callID = toolCall.ID } + events = append(events, closeCCAnthropicBlock(state)...) + events = append(events, announceCCAnthropicToolBlock(state, idx, callID, toolCall.Function.Name)...) } - // Argument fragment → input_json_delta on this tool's block. + // Argument fragment → input_json_delta on the tool's block once announced, + // buffered until the deferred announcement otherwise. if toolCall.Function.Arguments != "" { - state.CurrentToolArgs += toolCall.Function.Arguments - state.CurrentToolHadDelta = true - if blockIdx, ok := state.toolBlockIndex[idx]; ok && state.toolAnnounced[idx] { + if state.toolAnnounced[idx] { + blockIdx := state.toolBlockIndex[idx] + if state.ContentBlockOpen && blockIdx == state.ContentBlockIndex { + state.CurrentToolHadDelta = true + } events = append(events, AnthropicStreamEvent{ Type: "content_block_delta", Index: &blockIdx, @@ -777,12 +799,53 @@ func handleCCAnthropicToolCall(state *ChatCompletionsToAnthropicStreamState, too PartialJSON: toolCall.Function.Arguments, }, }) + } else { + state.pendingToolArgs[idx] += toolCall.Function.Arguments } } return events } +// announceCCAnthropicToolBlock assigns the next Anthropic block index to the +// tool, emits its content_block_start, and flushes any argument fragments +// buffered while the announcement was deferred. +func announceCCAnthropicToolBlock(state *ChatCompletionsToAnthropicStreamState, idx int, callID, name string) []AnthropicStreamEvent { + blockIdx := state.ContentBlockIndex + state.toolBlockIndex[idx] = blockIdx + state.toolAnnounced[idx] = true + state.toolName[idx] = name + state.CurrentToolName = name + state.CurrentToolHadDelta = false + state.ContentBlockOpen = true + state.CurrentBlockType = "tool_use" + delete(state.pendingToolCallID, idx) + + events := []AnthropicStreamEvent{{ + Type: "content_block_start", + Index: &blockIdx, + ContentBlock: &AnthropicContentBlock{ + Type: "tool_use", + ID: fromResponsesCallID(callID), + Name: name, + Input: json.RawMessage("{}"), + }, + }} + if pending := state.pendingToolArgs[idx]; pending != "" { + delete(state.pendingToolArgs, idx) + state.CurrentToolHadDelta = true + events = append(events, AnthropicStreamEvent{ + Type: "content_block_delta", + Index: &blockIdx, + Delta: &AnthropicDelta{ + Type: "input_json_delta", + PartialJSON: pending, + }, + }) + } + return events +} + // ccAnthropicDelta emits a content_block_delta on the current block. func ccAnthropicDelta(state *ChatCompletionsToAnthropicStreamState, delta *AnthropicDelta) []AnthropicStreamEvent { if !state.ContentBlockOpen { @@ -805,22 +868,35 @@ func closeCCAnthropicBlockIfOpen(state *ChatCompletionsToAnthropicStreamState, b return closeCCAnthropicBlock(state) } -// closeCCAnthropicBlock closes the currently open content block. +// closeCCAnthropicBlock closes the currently open content block. A tool_use +// block that streamed no argument delta gets a final input_json_delta "{}" +// first — the double-conversion path normalizes empty tool arguments to "{}", +// and some clients assemble tool input exclusively from deltas. func closeCCAnthropicBlock(state *ChatCompletionsToAnthropicStreamState) []AnthropicStreamEvent { if !state.ContentBlockOpen { return nil } idx := state.ContentBlockIndex + var events []AnthropicStreamEvent + if state.CurrentBlockType == "tool_use" && !state.CurrentToolHadDelta { + events = append(events, AnthropicStreamEvent{ + Type: "content_block_delta", + Index: &idx, + Delta: &AnthropicDelta{ + Type: "input_json_delta", + PartialJSON: "{}", + }, + }) + } state.ContentBlockOpen = false state.ContentBlockIndex++ state.CurrentBlockType = "" state.CurrentToolName = "" - state.CurrentToolArgs = "" state.CurrentToolHadDelta = false - return []AnthropicStreamEvent{{ + return append(events, AnthropicStreamEvent{ Type: "content_block_stop", Index: &idx, - }} + }) } // ccFinishReasonToAnthropicStopReason maps a Chat Completions finish_reason diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index c569d818a2..bd850aa3ab 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -808,3 +808,264 @@ func TestChatCompletionsToAnthropicStreamState_ToolCallNameArrivesLate(t *testin } 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 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", out.StopReason) +}