diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go new file mode 100644 index 0000000000..a14e0ca202 --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -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 = ¶llelToolCalls + + 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" + } +} diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go new file mode 100644 index 0000000000..e4a2c6d8e8 --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -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) +} diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index a1a7023be4..82eee0e853 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -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 {