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