fix(apicompat): align direct bridge edge semantics with double conversion

Adversarial review of the direct bridge found divergences from the
double-conversion chain it replaces; all are now aligned and covered by
tests that fail against the previous implementation:

- flush argument fragments buffered before a deferred tool announcement,
  and announce name-less tools at finalize, so no tool arguments are lost
  when upstreams stream arguments before the name
- fold text-only user array content into a single string; parts form only
  when an image requires it (strict chat upstreams reject array content)
- drop tool_choice pointing at undeclared tools and unknown choice types
- treat cache_write_tokens/cache_creation_tokens as alternate spellings
  (prefer write), not additive
- generate a response id when the upstream omits one
- derive stop_reason from blocks for content_filter/unknown finish reasons
- emit input_json_delta "{}" when a tool block closes without argument
  deltas
This commit is contained in:
shaw
2026-07-15 11:29:22 +08:00
parent 1eb79df0d3
commit 430fd8f20a
2 changed files with 439 additions and 102 deletions
@@ -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
@@ -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)
}