Files
sub2api/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

931 lines
31 KiB
Go

package apicompat
import (
"encoding/json"
"fmt"
"sort"
"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), and a named choice only when
// it points at a declared tool — mirroring responsesToolChoiceToChatToolChoice,
// chat upstreams 400 on tool_choice referencing an unknown tool.
if len(out.Tools) > 0 && len(req.ToolChoice) > 0 {
declared := make(map[string]bool, len(out.Tools))
for _, tool := range out.Tools {
if tool.Function != nil {
declared[tool.Function.Name] = true
}
}
tc, err := convertAnthropicToolChoiceToChat(req.ToolChoice, declared)
if err != nil {
return nil, fmt.Errorf("convert tool_choice: %w", err)
}
if len(tc) > 0 {
out.ToolChoice = tc
}
}
// Reasoning effort: output_config.effort maps 1:1 (max→xhigh). thinking.type
// itself is ignored (the Responses bridge behaves identically).
effort := "medium"
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
effort = req.OutputConfig.Effort
}
out.ReasoningEffort = mapAnthropicEffortToResponses(effort)
parallelToolCalls := true
out.ParallelToolCalls = &parallelToolCalls
return out, nil
}
// anthropicToChatMessages converts the Anthropic system field + message list
// into Chat Completions messages. It mirrors convertAnthropicToResponsesInput +
// responsesInputToChatMessages but produces ChatMessage directly.
func anthropicToChatMessages(system json.RawMessage, msgs []AnthropicMessage) ([]ChatMessage, error) {
var messages []ChatMessage
// System prompt → system message. parseAnthropicSystemContentParts handles
// both string and []block forms and filters the billing header.
if len(system) > 0 {
sysParts, err := parseAnthropicSystemContentParts(system)
if err != nil {
return nil, err
}
if len(sysParts) > 0 {
text := joinResponsesContentPartText(sysParts)
if text != "" {
content, _ := json.Marshal(text)
messages = append(messages, ChatMessage{Role: "system", Content: content})
}
}
}
for _, m := range msgs {
converted, err := anthropicMsgToChatMessages(m)
if err != nil {
return nil, err
}
messages = append(messages, converted...)
}
return normalizeChatMessages(messages), nil
}
// anthropicMsgToChatMessages converts one Anthropic message into one or more
// Chat messages. tool_result blocks become standalone "tool" role messages
// (the Chat Completions convention); text/image blocks stay in a user message;
// assistant tool_use blocks become tool_calls on the assistant message.
func anthropicMsgToChatMessages(m AnthropicMessage) ([]ChatMessage, error) {
switch m.Role {
case "assistant":
return anthropicAssistantToChatMessages(m.Content)
default: // "user" and any unknown role
return anthropicUserToChatMessages(m.Content)
}
}
// anthropicUserToChatMessages handles an Anthropic user message. Content may be
// a plain string or an array of blocks. tool_result blocks are extracted into
// standalone "tool" role messages; images inside tool_results are lifted into a
// follow-up user message as image_url parts (the Responses bridge does the same
// — function_call_output only accepts strings, so images must travel separately).
func anthropicUserToChatMessages(raw json.RawMessage) ([]ChatMessage, error) {
// Plain string → single user message.
var s string
if err := json.Unmarshal(raw, &s); err == nil {
content, _ := json.Marshal(s)
return []ChatMessage{{Role: "user", Content: content}}, nil
}
var blocks []AnthropicContentBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
return nil, err
}
var out []ChatMessage
var toolResultImageParts []ChatContentPart
// tool_result → "tool" role messages, text extracted; images deferred.
for _, b := range blocks {
if b.Type != "tool_result" {
continue
}
text, imageParts := convertToolResultOutput(b)
content, _ := json.Marshal(text)
out = append(out, ChatMessage{
Role: "tool",
Content: content,
ToolCallID: b.ToolUseID,
})
for _, ip := range imageParts {
toolResultImageParts = append(toolResultImageParts, ChatContentPart{
Type: "image_url",
ImageURL: &ChatImageURL{URL: ip.ImageURL},
})
}
}
// Remaining text + image blocks → user message. The double-conversion path
// (responsesContentPartsToChatContent) folds text-only content into a single
// string joined with "\n\n" and only uses the parts-array form when an image
// is present — strict chat upstreams reject array content — so the direct
// bridge preserves that folding.
var textParts []string
var parts []ChatContentPart
hasImage := false
for _, b := range blocks {
switch b.Type {
case "text":
if b.Text != "" {
textParts = append(textParts, b.Text)
parts = append(parts, ChatContentPart{Type: "text", Text: b.Text})
}
case "image":
if uri := anthropicImageToDataURI(b.Source); uri != "" {
hasImage = true
parts = append(parts, ChatContentPart{
Type: "image_url",
ImageURL: &ChatImageURL{URL: uri},
})
}
}
}
if len(toolResultImageParts) > 0 {
hasImage = true
parts = append(parts, toolResultImageParts...)
}
if !hasImage {
if len(textParts) > 0 {
content, _ := json.Marshal(strings.Join(textParts, "\n\n"))
out = append(out, ChatMessage{Role: "user", Content: content})
}
return out, nil
}
content, err := json.Marshal(parts)
if err != nil {
return nil, err
}
out = append(out, ChatMessage{Role: "user", Content: content})
return out, nil
}
// 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. A nil result means the choice is dropped: like the
// double-conversion path (responsesToolChoiceToChatToolChoice), a named choice
// pointing at an undeclared tool or an unknown choice type is not forwarded,
// because chat upstreams reject it.
//
// {"type":"auto"} → "auto"
// {"type":"any"} → "required"
// {"type":"none"} → "none"
// {"type":"tool","name":"X"} → {"type":"function","function":{"name":"X"}} (X declared)
func convertAnthropicToolChoiceToChat(raw json.RawMessage, declared map[string]bool) (json.RawMessage, error) {
var tc struct {
Type string `json:"type"`
Name string `json:"name"`
}
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":
if tc.Name == "" || !declared[tc.Name] {
return nil, nil
}
return json.Marshal(map[string]any{
"type": "function",
"function": map[string]string{"name": tc.Name},
})
default:
return nil, 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 {
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 = AnthropicStopReasonPtr(chatFinishReasonToAnthropicStopReason(choice.FinishReason, out.Content))
// "length" → "max_tokens" is handled by chatFinishReasonToAnthropicStopReason;
// Anthropic conveys max-tokens via stop_reason only, no incomplete_details field.
}
if resp.Usage != nil {
out.Usage = chatUsageToAnthropicUsage(resp.Usage)
}
}
if len(out.Content) == 0 {
out.Content = []AnthropicContentBlock{{Type: "text", Text: ""}}
}
// Empty choices / nil response never enter the choices branch above; the
// double-conversion path still reports a completed turn ("end_turn"), and
// stop_reason null/"" is invalid for completed non-stream responses.
if AnthropicStopReasonString(out.StopReason) == "" {
out.StopReason = AnthropicStopReasonPtr(chatFinishReasonToAnthropicStopReason("", out.Content))
}
// The double-conversion path generates a response id when the upstream
// omits one (ChatCompletionsResponseToResponses); clients treat it as required.
if out.ID == "" {
out.ID = generateResponsesID()
}
return out
}
// 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
reasoning := message.reasoningText()
if reasoning != "" {
blocks = append(blocks, AnthropicContentBlock{
Type: "thinking",
Thinking: reasoning,
})
}
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(reasoning) != "" && len(message.ToolCalls) == 0 {
text = reasoning
}
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.
//
// "length" → "max_tokens"
// "tool_calls" → "tool_use"
// other → "end_turn" (or "tool_use" if tool_use blocks present)
//
// "stop", "content_filter", and unknown reasons all map to a completed response
// in the double-conversion path, which then derives stop_reason from the blocks.
func chatFinishReasonToAnthropicStopReason(reason string, blocks []AnthropicContentBlock) string {
switch reason {
case "length":
return "max_tokens"
case "tool_calls":
return "tool_use"
default:
if containsAnthropicToolUseBlock(blocks) {
return "tool_use"
}
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
// cache_write_tokens and cache_creation_tokens are alternate spellings of
// the same quantity, not additive; the double-conversion path
// (ChatUsageToResponsesUsage) prefers write and falls back to creation.
if usage.PromptTokensDetails.CacheWriteTokens > 0 {
cacheCreationTokens = usage.PromptTokensDetails.CacheWriteTokens
} else {
cacheCreationTokens = usage.PromptTokensDetails.CacheCreationTokens
}
}
inputTokens := usage.PromptTokens - cachedTokens - cacheCreationTokens
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
CurrentToolHadDelta bool
HasToolCall bool
// Tool calls keyed by the upstream tool_call index. The Anthropic block
// index is assigned when the tool block is announced (content_block_start),
// which is deferred until the tool's name has arrived. Argument fragments
// and the call ID seen before the name are buffered and flushed with the
// announcement; tools whose name never arrives are announced with an empty
// name at finalize so their arguments are not lost.
toolBlockIndex map[int]int
toolAnnounced map[int]bool
toolName map[int]string
pendingToolCallID map[int]string
pendingToolArgs map[int]string
// 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),
pendingToolArgs: 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.
reasoning := choice.Delta.reasoningText()
if reasoning != nil && *reasoning != "" {
events = append(events, ensureCCAnthropicThinkingBlock(state)...)
events = append(events, ccAnthropicDelta(state, &AnthropicDelta{
Type: "thinking_delta",
Thinking: *reasoning,
})...)
}
// 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)...)
}
// Announce tools whose name never arrived so their buffered arguments are
// not silently dropped. The double-conversion path announced these
// immediately with an empty name; the deferred announcement keeps that data
// preservation while still delivering correct names when they do arrive.
if len(state.pendingToolCallID) > 0 {
idxs := make([]int, 0, len(state.pendingToolCallID))
for idx := range state.pendingToolCallID {
idxs = append(idxs, idx)
}
sort.Ints(idxs)
for _, idx := range idxs {
callID := state.pendingToolCallID[idx]
events = append(events, closeCCAnthropicBlock(state)...)
events = append(events, announceCCAnthropicToolBlock(state, idx, callID, "")...)
}
}
events = append(events, closeCCAnthropicBlock(state)...)
stopReason := ccFinishReasonToAnthropicStopReason(state.FinishReason, state.HasToolCall)
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,
StopReason: nil, // JSON null; never ""
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. The
// content_block_start for a tool is deferred until its name has arrived (some
// upstreams stream id/arguments before the name); argument fragments seen
// before the announcement are buffered and flushed with it, later fragments
// stream as input_json_delta on the tool's block.
func handleCCAnthropicToolCall(state *ChatCompletionsToAnthropicStreamState, toolCall *ChatToolCall) []AnthropicStreamEvent {
idx := 0
if toolCall.Index != nil {
idx = *toolCall.Index
}
var events []AnthropicStreamEvent
if _, seen := state.toolAnnounced[idx]; !seen {
// New tool call: it ends whatever block is currently streaming.
events = append(events, closeCCAnthropicBlock(state)...)
state.HasToolCall = true
callID := toolCall.ID
if callID == "" {
callID = generateItemID()
}
if name := toolCall.Function.Name; name != "" {
events = append(events, announceCCAnthropicToolBlock(state, idx, callID, name)...)
} else {
state.toolAnnounced[idx] = false
state.pendingToolCallID[idx] = callID
}
} else if !state.toolAnnounced[idx] && toolCall.Function.Name != "" {
// Deferred announcement: the name has arrived.
callID := state.pendingToolCallID[idx]
if toolCall.ID != "" {
callID = toolCall.ID
}
events = append(events, closeCCAnthropicBlock(state)...)
events = append(events, announceCCAnthropicToolBlock(state, idx, callID, toolCall.Function.Name)...)
}
// Argument fragment → input_json_delta on the tool's block once announced,
// buffered until the deferred announcement otherwise.
if toolCall.Function.Arguments != "" {
if state.toolAnnounced[idx] {
blockIdx := state.toolBlockIndex[idx]
if state.ContentBlockOpen && blockIdx == state.ContentBlockIndex {
state.CurrentToolHadDelta = true
}
events = append(events, AnthropicStreamEvent{
Type: "content_block_delta",
Index: &blockIdx,
Delta: &AnthropicDelta{
Type: "input_json_delta",
PartialJSON: toolCall.Function.Arguments,
},
})
} else {
state.pendingToolArgs[idx] += toolCall.Function.Arguments
}
}
return events
}
// announceCCAnthropicToolBlock assigns the next Anthropic block index to the
// tool, emits its content_block_start, and flushes any argument fragments
// buffered while the announcement was deferred.
func announceCCAnthropicToolBlock(state *ChatCompletionsToAnthropicStreamState, idx int, callID, name string) []AnthropicStreamEvent {
blockIdx := state.ContentBlockIndex
state.toolBlockIndex[idx] = blockIdx
state.toolAnnounced[idx] = true
state.toolName[idx] = name
state.CurrentToolName = name
state.CurrentToolHadDelta = false
state.ContentBlockOpen = true
state.CurrentBlockType = "tool_use"
delete(state.pendingToolCallID, idx)
events := []AnthropicStreamEvent{{
Type: "content_block_start",
Index: &blockIdx,
ContentBlock: &AnthropicContentBlock{
Type: "tool_use",
ID: fromResponsesCallID(callID),
Name: name,
Input: json.RawMessage("{}"),
},
}}
if pending := state.pendingToolArgs[idx]; pending != "" {
delete(state.pendingToolArgs, idx)
state.CurrentToolHadDelta = true
events = append(events, AnthropicStreamEvent{
Type: "content_block_delta",
Index: &blockIdx,
Delta: &AnthropicDelta{
Type: "input_json_delta",
PartialJSON: pending,
},
})
}
return events
}
// ccAnthropicDelta emits a content_block_delta on the current block.
func ccAnthropicDelta(state *ChatCompletionsToAnthropicStreamState, delta *AnthropicDelta) []AnthropicStreamEvent {
if !state.ContentBlockOpen {
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. A tool_use
// block that streamed no argument delta gets a final input_json_delta "{}"
// first — the double-conversion path normalizes empty tool arguments to "{}",
// and some clients assemble tool input exclusively from deltas.
func closeCCAnthropicBlock(state *ChatCompletionsToAnthropicStreamState) []AnthropicStreamEvent {
if !state.ContentBlockOpen {
return nil
}
idx := state.ContentBlockIndex
var events []AnthropicStreamEvent
if state.CurrentBlockType == "tool_use" && !state.CurrentToolHadDelta {
events = append(events, AnthropicStreamEvent{
Type: "content_block_delta",
Index: &idx,
Delta: &AnthropicDelta{
Type: "input_json_delta",
PartialJSON: "{}",
},
})
}
state.ContentBlockOpen = false
state.ContentBlockIndex++
state.CurrentBlockType = ""
state.CurrentToolName = ""
state.CurrentToolHadDelta = false
return append(events, 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"
}
}