mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user