perf: direct Anthropic↔ChatCompletions bridge for force-chat accounts

Skip the Responses API intermediate representation on the /v1/messages
force-chat path. Previously every streaming token ran through two state
machines (CC→Responses→Anthropic); now it runs through one (CC→Anthropic).

New file backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go:
- AnthropicToChatCompletionsRequest: request-side direct conversion
- ChatCompletionsResponseToAnthropic: non-stream response direct conversion
- ChatCompletionsChunkToAnthropicEvents + Finalize: single streaming state machine

openai_gateway_messages_chat_fallback.go rewired to use the direct bridge.
Existing 7 ForceChatCompletions end-to-end tests pass unchanged; 22 new
unit tests added including equivalence tests vs the double-conversion path.
This commit is contained in:
蔡及
2026-07-15 00:44:13 +08:00
parent da85cc7e47
commit 3504b8cc88
3 changed files with 1688 additions and 52 deletions
@@ -0,0 +1,847 @@
package apicompat
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// This file implements a DIRECT bridge between Anthropic Messages and OpenAI
// Chat Completions, skipping the Responses API intermediate representation.
//
// The existing chat-fallback path (forwardAnthropicViaRawChatCompletions) chains
// two Responses-anchored bridges — Anthropic→Responses→ChatCompletions on the
// request side and CC→Responses→Anthropic on the response side — so every
// streaming token runs through two state machines. For force-chat accounts
// (third-party OpenAI-compatible upstreams that only speak /v1/chat/completions)
// the Responses layer is pure overhead: these upstreams never see Responses
// semantics, and the clients reaching them via /v1/messages use standard
// function tools (no custom/tool_search/namespace Codex constructs).
//
// The direct bridge collapses both directions into a single conversion each:
//
// Request: Anthropic Messages → Chat Completions
// Response: CC chunk/response → Anthropic events/response
//
// Helper functions from the Responses bridges (anthropicImageToDataURI,
// extractAnthropicTextFromBlocks, fromResponsesCallID, sanitizeAnthropicToolUseInput,
// parseAnthropicSystemContentParts, isReasoningModel, mapAnthropicEffortToResponses,
// normalizeToolParameters) are reused so the conversion semantics stay identical.
// ---------------------------------------------------------------------------
// Request: AnthropicRequest → ChatCompletionsRequest
// ---------------------------------------------------------------------------
// AnthropicToChatCompletionsRequest converts an Anthropic Messages request
// directly into a Chat Completions request, without transiting the Responses
// API. It is semantically equivalent to composing AnthropicToResponses +
// ResponsesToChatCompletionsRequest but avoids materializing the intermediate
// ResponsesRequest and the extra marshal/unmarshal cycle.
func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsRequest, error) {
if req == nil {
return nil, fmt.Errorf("anthropic request is nil")
}
messages, err := anthropicToChatMessages(req.System, req.Messages)
if err != nil {
return nil, err
}
out := &ChatCompletionsRequest{
Model: req.Model,
Messages: messages,
Stream: req.Stream,
}
// Sampling params: reasoning models (gpt-5.x) reject temperature/top_p.
if !isReasoningModel(req.Model) {
out.Temperature = req.Temperature
out.TopP = req.TopP
}
if req.MaxTokens > 0 {
v := req.MaxTokens
if v < minMaxOutputTokens {
v = minMaxOutputTokens
}
out.MaxCompletionTokens = &v
}
// Tools: Anthropic input_schema is a JSON Schema, directly usable as Chat
// function parameters. Server tools (web_search_*) have no Chat Completions
// equivalent and are dropped (mirrors responsesToolsToChatTools).
if len(req.Tools) > 0 {
tools := anthropicToolsToChatTools(req.Tools)
if len(tools) > 0 {
out.Tools = tools
}
}
// tool_choice is only forwarded when tools survived the conversion
// (upstream rejects tool_choice without tools).
if len(out.Tools) > 0 && len(req.ToolChoice) > 0 {
tc, err := convertAnthropicToolChoiceToChat(req.ToolChoice)
if err != nil {
return nil, fmt.Errorf("convert tool_choice: %w", err)
}
out.ToolChoice = tc
}
// Reasoning effort: output_config.effort maps 1:1 (max→xhigh). thinking.type
// itself is ignored (the Responses bridge behaves identically).
effort := "medium"
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
effort = req.OutputConfig.Effort
}
out.ReasoningEffort = mapAnthropicEffortToResponses(effort)
parallelToolCalls := true
out.ParallelToolCalls = &parallelToolCalls
return out, nil
}
// anthropicToChatMessages converts the Anthropic system field + message list
// into Chat Completions messages. It mirrors convertAnthropicToResponsesInput +
// responsesInputToChatMessages but produces ChatMessage directly.
func anthropicToChatMessages(system json.RawMessage, msgs []AnthropicMessage) ([]ChatMessage, error) {
var messages []ChatMessage
// System prompt → system message. parseAnthropicSystemContentParts handles
// both string and []block forms and filters the billing header.
if len(system) > 0 {
sysParts, err := parseAnthropicSystemContentParts(system)
if err != nil {
return nil, err
}
if len(sysParts) > 0 {
text := joinResponsesContentPartText(sysParts)
if text != "" {
content, _ := json.Marshal(text)
messages = append(messages, ChatMessage{Role: "system", Content: content})
}
}
}
for _, m := range msgs {
converted, err := anthropicMsgToChatMessages(m)
if err != nil {
return nil, err
}
messages = append(messages, converted...)
}
return normalizeChatMessages(messages), nil
}
// anthropicMsgToChatMessages converts one Anthropic message into one or more
// Chat messages. tool_result blocks become standalone "tool" role messages
// (the Chat Completions convention); text/image blocks stay in a user message;
// assistant tool_use blocks become tool_calls on the assistant message.
func anthropicMsgToChatMessages(m AnthropicMessage) ([]ChatMessage, error) {
switch m.Role {
case "assistant":
return anthropicAssistantToChatMessages(m.Content)
default: // "user" and any unknown role
return anthropicUserToChatMessages(m.Content)
}
}
// anthropicUserToChatMessages handles an Anthropic user message. Content may be
// a plain string or an array of blocks. tool_result blocks are extracted into
// standalone "tool" role messages; images inside tool_results are lifted into a
// follow-up user message as image_url parts (the Responses bridge does the same
// — function_call_output only accepts strings, so images must travel separately).
func anthropicUserToChatMessages(raw json.RawMessage) ([]ChatMessage, error) {
// Plain string → single user message.
var s string
if err := json.Unmarshal(raw, &s); err == nil {
content, _ := json.Marshal(s)
return []ChatMessage{{Role: "user", Content: content}}, nil
}
var blocks []AnthropicContentBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
return nil, err
}
var out []ChatMessage
var toolResultImageParts []ChatContentPart
// tool_result → "tool" role messages, text extracted; images deferred.
for _, b := range blocks {
if b.Type != "tool_result" {
continue
}
text, imageParts := convertToolResultOutput(b)
content, _ := json.Marshal(text)
out = append(out, ChatMessage{
Role: "tool",
Content: content,
ToolCallID: b.ToolUseID,
})
for _, ip := range imageParts {
toolResultImageParts = append(toolResultImageParts, ChatContentPart{
Type: "image_url",
ImageURL: &ChatImageURL{URL: ip.ImageURL},
})
}
}
// Remaining text + image blocks → user message with content parts.
var parts []ChatContentPart
for _, b := range blocks {
switch b.Type {
case "text":
if b.Text != "" {
parts = append(parts, ChatContentPart{Type: "text", Text: b.Text})
}
case "image":
if uri := anthropicImageToDataURI(b.Source); uri != "" {
parts = append(parts, ChatContentPart{
Type: "image_url",
ImageURL: &ChatImageURL{URL: uri},
})
}
}
}
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})
}
return out, nil
}
// anthropicAssistantToChatMessages handles an Anthropic assistant message.
// Text content → assistant message content; tool_use blocks → tool_calls on the
// same assistant message; thinking blocks are dropped (Chat Completions has no
// inbound thinking field, matching anthropicAssistantToResponses).
func anthropicAssistantToChatMessages(raw json.RawMessage) ([]ChatMessage, error) {
// Plain string → single assistant message.
var s string
if err := json.Unmarshal(raw, &s); err == nil {
content, _ := json.Marshal(s)
return []ChatMessage{{Role: "assistant", Content: content}}, nil
}
var blocks []AnthropicContentBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
return nil, err
}
msg := ChatMessage{Role: "assistant"}
text := extractAnthropicTextFromBlocks(blocks)
if text != "" {
content, _ := json.Marshal(text)
msg.Content = content
}
for _, b := range blocks {
if b.Type != "tool_use" {
continue
}
args := "{}"
if len(b.Input) > 0 {
args = string(b.Input)
}
msg.ToolCalls = append(msg.ToolCalls, ChatToolCall{
ID: b.ID,
Type: "function",
Function: ChatFunctionCall{
Name: b.Name,
Arguments: args,
},
})
}
return []ChatMessage{msg}, nil
}
// anthropicToolsToChatTools maps Anthropic tool definitions to Chat Completions
// function tools. Server-side tools (web_search_*) are dropped — they have no
// Chat Completions equivalent.
func anthropicToolsToChatTools(tools []AnthropicTool) []ChatTool {
var out []ChatTool
for _, t := range tools {
if strings.HasPrefix(t.Type, "web_search") {
continue
}
out = append(out, ChatTool{
Type: "function",
Function: &ChatFunction{
Name: t.Name,
Description: t.Description,
Parameters: normalizeToolParameters(t.InputSchema),
Strict: boolPtr(false),
},
})
}
return out
}
// convertAnthropicToolChoiceToChat maps Anthropic tool_choice to Chat
// Completions tool_choice.
//
// {"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) {
var tc struct {
Type string `json:"type"`
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &tc); err != nil {
return nil, err
}
switch tc.Type {
case "auto":
return json.Marshal("auto")
case "any":
return json.Marshal("required")
case "none":
return json.Marshal("none")
case "tool":
return json.Marshal(map[string]any{
"type": "function",
"function": map[string]string{"name": tc.Name},
})
default:
return raw, nil
}
}
// joinResponsesContentPartText concatenates the text of input_text parts. Used
// for the system prompt where parseAnthropicSystemContentParts returns
// ResponsesContentPart values.
func joinResponsesContentPartText(parts []ResponsesContentPart) string {
var texts []string
for _, p := range parts {
if p.Type == "input_text" && p.Text != "" {
texts = append(texts, p.Text)
}
}
return strings.Join(texts, "\n\n")
}
// ---------------------------------------------------------------------------
// Non-streaming response: ChatCompletionsResponse → AnthropicResponse
// ---------------------------------------------------------------------------
// ChatCompletionsResponseToAnthropic converts a Chat Completions response
// directly into an Anthropic Messages response, without materializing a
// ResponsesResponse. It is semantically equivalent to composing
// ChatCompletionsResponseToResponses + ResponsesToAnthropic.
func ChatCompletionsResponseToAnthropic(resp *ChatCompletionsResponse, model string) *AnthropicResponse {
out := &AnthropicResponse{
Type: "message",
Role: "assistant",
Model: model,
}
if resp != nil {
if out.ID == "" {
out.ID = resp.ID
}
if out.Model == "" {
out.Model = resp.Model
}
if len(resp.Choices) > 0 {
choice := resp.Choices[0]
out.Content = chatMessageToAnthropicBlocks(choice.Message)
out.StopReason = chatFinishReasonToAnthropicStopReason(choice.FinishReason, out.Content)
if choice.FinishReason == "length" {
// Anthropic conveys max-tokens via stop_reason only; no separate
// incomplete_details field. stop_sequence stays nil.
}
}
if resp.Usage != nil {
out.Usage = chatUsageToAnthropicUsage(resp.Usage)
}
}
if len(out.Content) == 0 {
out.Content = []AnthropicContentBlock{{Type: "text", Text: ""}}
}
return out
}
// chatMessageToAnthropicBlocks converts a Chat Completions message into
// Anthropic content blocks. Reasoning content → thinking block; text content →
// text block; tool_calls → tool_use blocks. Mirrors chatMessageToResponsesOutput
// + the reasoning→thinking mapping in ResponsesToAnthropic.
func chatMessageToAnthropicBlocks(message ChatMessage) []AnthropicContentBlock {
var blocks []AnthropicContentBlock
if message.ReasoningContent != "" {
blocks = append(blocks, AnthropicContentBlock{
Type: "thinking",
Thinking: message.ReasoningContent,
})
}
text := chatMessageContentText(message.Content)
// DeepSeek reasoning-only fallback: when there is no text and no tool calls,
// surface the reasoning content as visible text so the turn isn't empty.
if text == "" && strings.TrimSpace(message.ReasoningContent) != "" && len(message.ToolCalls) == 0 {
text = message.ReasoningContent
}
if text != "" || len(message.ToolCalls) == 0 {
blocks = append(blocks, AnthropicContentBlock{Type: "text", Text: text})
}
for _, toolCall := range message.ToolCalls {
arguments := toolCall.Function.Arguments
if strings.TrimSpace(arguments) == "" {
arguments = "{}"
}
blocks = append(blocks, AnthropicContentBlock{
Type: "tool_use",
ID: fromResponsesCallID(toolCall.ID),
Name: toolCall.Function.Name,
Input: sanitizeAnthropicToolUseInput(toolCall.Function.Name, arguments),
})
}
return blocks
}
// 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"
func chatFinishReasonToAnthropicStopReason(reason string, blocks []AnthropicContentBlock) string {
switch reason {
case "length":
return "max_tokens"
case "tool_calls":
return "tool_use"
case "stop":
if containsAnthropicToolUseBlock(blocks) {
return "tool_use"
}
return "end_turn"
default:
return "end_turn"
}
}
// chatUsageToAnthropicUsage converts Chat Completions token usage to Anthropic
// usage shape. Mirrors ChatUsageToResponsesUsage + anthropicUsageFromResponsesUsage.
func chatUsageToAnthropicUsage(usage *ChatUsage) AnthropicUsage {
if usage == nil {
return AnthropicUsage{}
}
cachedTokens := 0
cacheCreationTokens := 0
if usage.PromptTokensDetails != nil {
cachedTokens = usage.PromptTokensDetails.CachedTokens
cacheCreationTokens = usage.PromptTokensDetails.CacheCreationTokens +
usage.PromptTokensDetails.CacheWriteTokens
}
inputTokens := usage.PromptTokens - cachedTokens - cacheCreationTokens
if inputTokens < 0 {
inputTokens = 0
}
return AnthropicUsage{
InputTokens: inputTokens,
OutputTokens: usage.CompletionTokens,
CacheReadInputTokens: cachedTokens,
CacheCreationInputTokens: cacheCreationTokens,
}
}
// ---------------------------------------------------------------------------
// Streaming: ChatCompletionsChunk → []AnthropicStreamEvent (stateful converter)
// ---------------------------------------------------------------------------
// ChatCompletionsToAnthropicStreamState tracks state while converting Chat
// Completions SSE chunks directly into Anthropic SSE events. It collapses the
// ChatCompletionsToResponsesStreamState + ResponsesEventToAnthropicState pair
// into one state machine.
type ChatCompletionsToAnthropicStreamState struct {
MessageStartSent bool
MessageStopSent bool
// Current content block lifecycle.
ContentBlockIndex int
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.
toolBlockIndex map[int]int
toolAnnounced map[int]bool
toolName map[int]string
pendingToolCallID map[int]string // call ID received before the name (deferred announce)
// Reasoning (DeepSeek-style): reasoning_content streamed before content.
// No separate reasoning block index — it uses ContentBlockIndex like the
// Responses bridge's ReasoningIndex, but since blocks are sequential we
// reuse the single ContentBlockIndex counter.
FinishReason string
InputTokens int
OutputTokens int
CacheReadInputTokens int
CacheCreationInputTokens int
ResponseID string
Model string
Created int64
}
// NewChatCompletionsToAnthropicStreamState returns an initialized stream state.
func NewChatCompletionsToAnthropicStreamState(model string) *ChatCompletionsToAnthropicStreamState {
return &ChatCompletionsToAnthropicStreamState{
ResponseID: generateResponsesID(),
Model: model,
Created: time.Now().Unix(),
toolBlockIndex: make(map[int]int),
toolAnnounced: make(map[int]bool),
toolName: make(map[int]string),
pendingToolCallID: make(map[int]string),
}
}
// ChatCompletionsChunkToAnthropicEvents converts one Chat Completions stream
// chunk into zero or more Anthropic stream events, updating state as it goes.
func ChatCompletionsChunkToAnthropicEvents(
chunk *ChatCompletionsChunk,
state *ChatCompletionsToAnthropicStreamState,
) []AnthropicStreamEvent {
if chunk == nil || state == nil {
return nil
}
if chunk.ID != "" {
state.ResponseID = chunk.ID
}
if state.Model == "" && chunk.Model != "" {
state.Model = chunk.Model
}
// Usage in a streaming chunk (include_usage) arrives in its own chunk,
// often with empty choices. Capture it for the finalize message_delta.
if chunk.Usage != nil {
u := chatUsageToAnthropicUsage(chunk.Usage)
state.InputTokens = u.InputTokens
state.OutputTokens = u.OutputTokens
state.CacheReadInputTokens = u.CacheReadInputTokens
state.CacheCreationInputTokens = u.CacheCreationInputTokens
}
var events []AnthropicStreamEvent
events = append(events, ensureCCAnthropicMessageStart(state)...)
for _, choice := range chunk.Choices {
// Reasoning content → thinking block.
if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" {
events = append(events, ensureCCAnthropicThinkingBlock(state)...)
events = append(events, ccAnthropicDelta(state, &AnthropicDelta{
Type: "thinking_delta",
Thinking: *choice.Delta.ReasoningContent,
})...)
}
// Text content → text block (closes any open thinking block first).
if choice.Delta.Content != nil && *choice.Delta.Content != "" {
events = append(events, closeCCAnthropicBlockIfOpen(state, "thinking")...)
events = append(events, ensureCCAnthropicTextBlock(state)...)
events = append(events, ccAnthropicDelta(state, &AnthropicDelta{
Type: "text_delta",
Text: *choice.Delta.Content,
})...)
}
// Tool calls → tool_use blocks.
for _, toolCall := range choice.Delta.ToolCalls {
events = append(events, closeCCAnthropicBlockIfOpen(state, "thinking")...)
events = append(events, handleCCAnthropicToolCall(state, &toolCall)...)
}
if choice.FinishReason != nil && *choice.FinishReason != "" {
state.FinishReason = *choice.FinishReason
}
}
return events
}
// FinalizeChatCompletionsAnthropicStream emits terminal Anthropic events
// (close open blocks + message_delta + message_stop) when the stream ends.
func FinalizeChatCompletionsAnthropicStream(state *ChatCompletionsToAnthropicStreamState) []AnthropicStreamEvent {
if state == nil || state.MessageStopSent {
return nil
}
var events []AnthropicStreamEvent
if !state.MessageStartSent {
events = append(events, ensureCCAnthropicMessageStart(state)...)
}
events = append(events, closeCCAnthropicBlock(state)...)
stopReason := ccFinishReasonToAnthropicStopReason(state.FinishReason, state.HasToolCall)
events = append(events,
AnthropicStreamEvent{
Type: "message_delta",
Delta: &AnthropicDelta{
StopReason: stopReason,
},
Usage: &AnthropicUsage{
InputTokens: state.InputTokens,
OutputTokens: state.OutputTokens,
CacheReadInputTokens: state.CacheReadInputTokens,
CacheCreationInputTokens: state.CacheCreationInputTokens,
},
},
AnthropicStreamEvent{Type: "message_stop"},
)
state.MessageStopSent = true
return events
}
// ensureCCAnthropicMessageStart emits message_start on the first event.
func ensureCCAnthropicMessageStart(state *ChatCompletionsToAnthropicStreamState) []AnthropicStreamEvent {
if state.MessageStartSent {
return nil
}
state.MessageStartSent = true
return []AnthropicStreamEvent{{
Type: "message_start",
Message: &AnthropicResponse{
ID: state.ResponseID,
Type: "message",
Role: "assistant",
Content: []AnthropicContentBlock{},
Model: state.Model,
Usage: AnthropicUsage{InputTokens: 0, OutputTokens: 0},
},
}}
}
// ensureCCAnthropicThinkingBlock opens a thinking block if none is open.
func ensureCCAnthropicThinkingBlock(state *ChatCompletionsToAnthropicStreamState) []AnthropicStreamEvent {
if state.ContentBlockOpen && state.CurrentBlockType == "thinking" {
return nil
}
events := closeCCAnthropicBlock(state)
idx := state.ContentBlockIndex
state.ContentBlockOpen = true
state.CurrentBlockType = "thinking"
events = append(events, AnthropicStreamEvent{
Type: "content_block_start",
Index: &idx,
ContentBlock: &AnthropicContentBlock{
Type: "thinking",
Thinking: "",
},
})
return events
}
// ensureCCAnthropicTextBlock opens a text block if none is open.
func ensureCCAnthropicTextBlock(state *ChatCompletionsToAnthropicStreamState) []AnthropicStreamEvent {
if state.ContentBlockOpen && state.CurrentBlockType == "text" {
return nil
}
events := closeCCAnthropicBlock(state)
idx := state.ContentBlockIndex
state.ContentBlockOpen = true
state.CurrentBlockType = "text"
events = append(events, AnthropicStreamEvent{
Type: "content_block_start",
Index: &idx,
ContentBlock: &AnthropicContentBlock{
Type: "text",
Text: "",
},
})
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.
func handleCCAnthropicToolCall(state *ChatCompletionsToAnthropicStreamState, toolCall *ChatToolCall) []AnthropicStreamEvent {
idx := 0
if toolCall.Index != nil {
idx = *toolCall.Index
}
var events []AnthropicStreamEvent
if _, ok := state.toolBlockIndex[idx]; !ok {
// New tool call. Close any open non-tool block first.
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("{}"),
},
})
} 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("{}"),
},
})
}
}
// Argument fragment → input_json_delta on this tool's block.
if toolCall.Function.Arguments != "" {
state.CurrentToolArgs += toolCall.Function.Arguments
state.CurrentToolHadDelta = true
if blockIdx, ok := state.toolBlockIndex[idx]; ok && state.toolAnnounced[idx] {
events = append(events, AnthropicStreamEvent{
Type: "content_block_delta",
Index: &blockIdx,
Delta: &AnthropicDelta{
Type: "input_json_delta",
PartialJSON: toolCall.Function.Arguments,
},
})
}
}
return events
}
// ccAnthropicDelta emits a content_block_delta on the current block.
func ccAnthropicDelta(state *ChatCompletionsToAnthropicStreamState, delta *AnthropicDelta) []AnthropicStreamEvent {
if !state.ContentBlockOpen {
return nil
}
idx := state.ContentBlockIndex
return []AnthropicStreamEvent{{
Type: "content_block_delta",
Index: &idx,
Delta: delta,
}}
}
// closeCCAnthropicBlockIfOpen closes the current block only if it matches the
// given type (used to close a thinking block before opening text/tool).
func closeCCAnthropicBlockIfOpen(state *ChatCompletionsToAnthropicStreamState, blockType string) []AnthropicStreamEvent {
if !state.ContentBlockOpen || state.CurrentBlockType != blockType {
return nil
}
return closeCCAnthropicBlock(state)
}
// closeCCAnthropicBlock closes the currently open content block.
func closeCCAnthropicBlock(state *ChatCompletionsToAnthropicStreamState) []AnthropicStreamEvent {
if !state.ContentBlockOpen {
return nil
}
idx := state.ContentBlockIndex
state.ContentBlockOpen = false
state.ContentBlockIndex++
state.CurrentBlockType = ""
state.CurrentToolName = ""
state.CurrentToolArgs = ""
state.CurrentToolHadDelta = false
return []AnthropicStreamEvent{{
Type: "content_block_stop",
Index: &idx,
}}
}
// ccFinishReasonToAnthropicStopReason maps a Chat Completions finish_reason
// (captured during streaming) to an Anthropic stop_reason for message_delta.
func ccFinishReasonToAnthropicStopReason(reason string, hasToolCall bool) string {
switch reason {
case "length":
return "max_tokens"
case "tool_calls":
return "tool_use"
case "stop":
if hasToolCall {
return "tool_use"
}
return "end_turn"
default:
if hasToolCall {
return "tool_use"
}
return "end_turn"
}
}
@@ -0,0 +1,812 @@
package apicompat
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func strPtr(s string) *string { return &s }
func intPtr(v int) *int { return &v }
// 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 := tc["function"].(map[string]any)
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", 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", 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", 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)
}
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)
}
// ---------------------------------------------------------------------------
// 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, direct.StopReason, 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, 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)
}
}
}
// ---------------------------------------------------------------------------
// 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)
}
@@ -18,16 +18,15 @@ import (
// forwardAnthropicViaRawChatCompletions serves /v1/messages clients through
// an OpenAI-compatible upstream that only supports /v1/chat/completions.
//
// Conversion chain:
// Conversion chain (direct, no Responses intermediary):
//
// Request: Anthropic Messages → Responses (AnthropicToResponses)
// → Chat Completions (ResponsesToChatCompletionsRequest)
// Response: CC chunk → Responses events (ChatCompletionsChunkToResponsesEvents)
// → Anthropic events (ResponsesEventToAnthropicEvents)
// Request: Anthropic Messages → Chat Completions (AnthropicToChatCompletionsRequest)
// Response: CC chunk/response → Anthropic events/response (direct bridge)
//
// This is the /v1/messages counterpart of forwardResponsesViaRawChatCompletions
// (which serves /v1/responses clients). The same conversion bridges are reused;
// only the inbound/outbound framing differs.
// (which serves /v1/responses clients). Unlike the Responses path, the direct
// bridge skips the Responses API intermediate representation entirely — every
// streaming token runs through a single state machine instead of two.
func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions(
ctx context.Context,
c *gin.Context,
@@ -51,22 +50,16 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions(
applyOpenAICompatModelNormalization(&anthropicReq)
clientStream := anthropicReq.Stream
// 2. Anthropic → Responses → Chat Completions
responsesReq, err := apicompat.AnthropicToResponses(&anthropicReq)
// 2. Anthropic → Chat Completions (direct, no Responses intermediary)
chatReq, err := apicompat.AnthropicToChatCompletionsRequest(&anthropicReq)
if err != nil {
writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return nil, fmt.Errorf("convert anthropic to responses: %w", err)
return nil, fmt.Errorf("convert anthropic to chat completions: %w", err)
}
billingModel := resolveOpenAIForwardModel(account, anthropicReq.Model, defaultMappedModel)
upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
responsesReq.Model = upstreamModel
chatReq, err := apicompat.ResponsesToChatCompletionsRequest(responsesReq)
if err != nil {
writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return nil, fmt.Errorf("convert responses to chat completions: %w", err)
}
chatReq.Model = upstreamModel
chatReq.Stream = clientStream
if clientStream {
chatReq.StreamOptions = &apicompat.ChatStreamOptions{IncludeUsage: true}
@@ -140,9 +133,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsAnthropic(
if err != nil {
return nil, err
}
responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, nil, false, nil)
anthropicResp := apicompat.ResponsesToAnthropic(responsesResp, originalModel)
anthropicResp := apicompat.ChatCompletionsResponseToAnthropic(ccResp, originalModel)
if s.responseHeaderFilter != nil {
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
@@ -175,34 +166,29 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic(
requestID := resp.Header.Get("x-request-id")
writeStreamHeaders := s.newStreamHeaderWriter(c, resp.Header)
ccState := apicompat.NewChatCompletionsToResponsesStreamState(originalModel)
anthropicState := apicompat.NewResponsesEventToAnthropicState()
anthropicState.Model = originalModel
anthropicState := apicompat.NewChatCompletionsToAnthropicStreamState(originalModel)
clientDisconnected := false
// 与 responses 兄弟不同:客户端断开后仍继续做事件转换(喂 anthropicState),
// 仅跳过写出,保证 finalize 阶段的 usage 汇总不受断开影响。
emitChunk := func(chunk *apicompat.ChatCompletionsChunk) {
// CC chunk → Responses events → Anthropic events
responsesEvents := apicompat.ChatCompletionsChunkToResponsesEvents(chunk, ccState)
for _, rEvent := range responsesEvents {
anthropicEvents := apicompat.ResponsesEventToAnthropicEvents(&rEvent, anthropicState)
if clientDisconnected {
// CC chunk → Anthropic events (direct, single state machine)
anthropicEvents := apicompat.ChatCompletionsChunkToAnthropicEvents(chunk, anthropicState)
if clientDisconnected {
return
}
for _, aEvt := range anthropicEvents {
sse, err := apicompat.ResponsesAnthropicEventToSSE(aEvt)
if err != nil {
continue
}
for _, aEvt := range anthropicEvents {
sse, err := apicompat.ResponsesAnthropicEventToSSE(aEvt)
if err != nil {
continue
}
writeStreamHeaders()
if _, err := fmt.Fprint(c.Writer, sse); err != nil {
clientDisconnected = true
break
}
writeStreamHeaders()
if _, err := fmt.Fprint(c.Writer, sse); err != nil {
clientDisconnected = true
break
}
}
if !clientDisconnected && len(responsesEvents) > 0 {
if !clientDisconnected && len(anthropicEvents) > 0 {
c.Writer.Flush()
}
}
@@ -229,17 +215,10 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic(
}, fmt.Errorf("stream usage incomplete: %w", scan.Err)
}
// Finalize CC→Responses stream (emit response.completed)
finalEvents := apicompat.FinalizeChatCompletionsResponsesStream(ccState)
for _, rEvent := range finalEvents {
if rEvent.Response != nil && rEvent.Response.Usage != nil {
usage = copyOpenAIUsageFromResponsesUsage(rEvent.Response.Usage)
}
if clientDisconnected {
continue
}
anthropicEvents := apicompat.ResponsesEventToAnthropicEvents(&rEvent, anthropicState)
for _, aEvt := range anthropicEvents {
// Finalize: close open blocks + emit message_delta/message_stop.
finalEvents := apicompat.FinalizeChatCompletionsAnthropicStream(anthropicState)
if !clientDisconnected {
for _, aEvt := range finalEvents {
sse, err := apicompat.ResponsesAnthropicEventToSSE(aEvt)
if err != nil {
continue
@@ -250,8 +229,6 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic(
break
}
}
}
if !clientDisconnected {
c.Writer.Flush()
}
if !scan.SawDone {