Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
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

This commit is contained in:
李建琦
2026-08-21 18:30:13 +08:00
commit 6d655c9903
3584 changed files with 1270640 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,507 @@
package apicompat
import (
"encoding/json"
"fmt"
"strings"
)
// AnthropicToResponses converts an Anthropic Messages request directly into
// a Responses API request. This preserves fields that would be lost in a
// Chat Completions intermediary round-trip (e.g. thinking, cache_control,
// structured system prompts).
func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
input, err := convertAnthropicToResponsesInput(req.System, req.Messages)
if err != nil {
return nil, err
}
inputJSON, err := json.Marshal(input)
if err != nil {
return nil, err
}
out := &ResponsesRequest{
Model: req.Model,
Input: inputJSON,
Stream: req.Stream,
Include: []string{"reasoning.encrypted_content"},
}
// Reasoning models (gpt-5.x) served via the Responses API do not accept
// sampling parameters. Sending temperature or top_p causes a 400
// "Unsupported parameter" error, so we only forward them for non-reasoning
// models.
if !isReasoningModel(req.Model) {
out.Temperature = req.Temperature
out.TopP = req.TopP
}
storeFalse := false
out.Store = &storeFalse
parallelToolCalls := true
out.ParallelToolCalls = &parallelToolCalls
out.Text = &ResponsesText{Verbosity: "medium"}
if req.MaxTokens > 0 {
v := req.MaxTokens
if v < minMaxOutputTokens {
v = minMaxOutputTokens
}
out.MaxOutputTokens = &v
}
if len(req.Tools) > 0 {
out.Tools = convertAnthropicToolsToResponses(req.Tools)
}
// Determine reasoning effort: only output_config.effort controls the
// level; thinking.type is ignored. Default follows Codex CLI / airgate's
// Anthropic bridge shape, which uses medium when unset.
// Anthropic levels map 1:1 to OpenAI: low→low, medium→medium, high→high, max→xhigh.
effort := "medium"
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
effort = req.OutputConfig.Effort
}
out.Reasoning = &ResponsesReasoning{
Effort: mapAnthropicEffortToResponses(effort),
Summary: "auto",
}
// Convert tool_choice
if len(req.ToolChoice) > 0 {
tc, err := convertAnthropicToolChoiceToResponses(req.ToolChoice)
if err != nil {
return nil, fmt.Errorf("convert tool_choice: %w", err)
}
out.ToolChoice = tc
}
return out, nil
}
// convertAnthropicToolChoiceToResponses maps Anthropic tool_choice to Responses format.
//
// {"type":"auto"} → "auto"
// {"type":"any"} → "required"
// {"type":"none"} → "none"
// {"type":"tool","name":"X"} → {"type":"function","name":"X"}
func convertAnthropicToolChoiceToResponses(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",
"name": tc.Name,
})
default:
// Pass through unknown types as-is
return raw, nil
}
}
// convertAnthropicToResponsesInput builds the Responses API input items array
// from the Anthropic system field and message list.
func convertAnthropicToResponsesInput(system json.RawMessage, msgs []AnthropicMessage) ([]ResponsesInputItem, error) {
var out []ResponsesInputItem
// System prompt → developer role input item. ChatGPT Codex SSE behaves like
// Codex CLI here: keeping Anthropic system text in input preserves the
// conversation/cache shape better than moving it into instructions.
if len(system) > 0 {
sysParts, err := parseAnthropicSystemContentParts(system)
if err != nil {
return nil, err
}
if len(sysParts) > 0 {
content, _ := json.Marshal(sysParts)
out = append(out, ResponsesInputItem{
Type: "message",
Role: "developer",
Content: content,
})
}
}
for _, m := range msgs {
items, err := anthropicMsgToResponsesItems(m)
if err != nil {
return nil, err
}
out = append(out, items...)
}
return out, nil
}
// parseAnthropicSystemContentParts handles the Anthropic system field which can
// be a plain string or an array of text blocks. Claude Code may include an
// x-anthropic-billing-header block; airgate drops it before sending to Codex.
func parseAnthropicSystemContentParts(raw json.RawMessage) ([]ResponsesContentPart, error) {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
if isAnthropicBillingHeaderText(s) || s == "" {
return nil, nil
}
return []ResponsesContentPart{{Type: "input_text", Text: s}}, nil
}
var blocks []AnthropicContentBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
return nil, err
}
var parts []ResponsesContentPart
for _, b := range blocks {
if b.Type == "text" && b.Text != "" && !isAnthropicBillingHeaderText(b.Text) {
parts = append(parts, ResponsesContentPart{Type: "input_text", Text: b.Text})
}
}
return parts, nil
}
func isAnthropicBillingHeaderText(text string) bool {
return strings.HasPrefix(text, "x-anthropic-billing-header: ")
}
// anthropicMsgToResponsesItems converts a single Anthropic message into one
// or more Responses API input items.
func anthropicMsgToResponsesItems(m AnthropicMessage) ([]ResponsesInputItem, error) {
switch m.Role {
case "user":
return anthropicUserToResponses(m.Content)
case "assistant":
return anthropicAssistantToResponses(m.Content)
default:
return anthropicUserToResponses(m.Content)
}
}
// anthropicUserToResponses handles an Anthropic user message. Content can be a
// plain string or an array of blocks. tool_result blocks are extracted into
// function_call_output items. Image blocks are converted to input_image parts.
func anthropicUserToResponses(raw json.RawMessage) ([]ResponsesInputItem, error) {
// Try plain string.
var s string
if err := json.Unmarshal(raw, &s); err == nil {
parts := []ResponsesContentPart{{Type: "input_text", Text: s}}
partsJSON, err := json.Marshal(parts)
if err != nil {
return nil, err
}
return []ResponsesInputItem{{Type: "message", Role: "user", Content: partsJSON}}, nil
}
var blocks []AnthropicContentBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
return nil, err
}
var out []ResponsesInputItem
var toolResultImageParts []ResponsesContentPart
// Extract tool_result blocks → function_call_output items.
// Images inside tool_results are extracted separately because the
// Responses API function_call_output.output only accepts strings.
for _, b := range blocks {
if b.Type != "tool_result" {
continue
}
outputText, imageParts := convertToolResultOutput(b)
out = append(out, ResponsesInputItem{
Type: "function_call_output",
CallID: toResponsesCallID(b.ToolUseID),
Output: outputText,
})
toolResultImageParts = append(toolResultImageParts, imageParts...)
}
// Remaining text + image blocks → user message with content parts.
// Also include images extracted from tool_results so the model can see them.
var parts []ResponsesContentPart
for _, b := range blocks {
switch b.Type {
case "text":
if b.Text != "" {
parts = append(parts, ResponsesContentPart{Type: "input_text", Text: b.Text})
}
case "image":
if uri := anthropicImageToDataURI(b.Source); uri != "" {
parts = append(parts, ResponsesContentPart{Type: "input_image", ImageURL: uri})
}
}
}
parts = append(parts, toolResultImageParts...)
if len(parts) > 0 {
content, err := json.Marshal(parts)
if err != nil {
return nil, err
}
out = append(out, ResponsesInputItem{Type: "message", Role: "user", Content: content})
}
return out, nil
}
// anthropicAssistantToResponses handles an Anthropic assistant message.
// Text content → assistant message with output_text parts.
// tool_use blocks → function_call items.
// thinking blocks with signature → reasoning items (encrypted_content) so
// multi-turn Grok/Codex prompt cache can reuse prior reasoning prefixes.
// thinking without signature remains ignored (not accepted as plain text input).
func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, error) {
// Try plain string.
var s string
if err := json.Unmarshal(raw, &s); err == nil {
parts := []ResponsesContentPart{{Type: "output_text", Text: s}}
partsJSON, err := json.Marshal(parts)
if err != nil {
return nil, err
}
return []ResponsesInputItem{{Type: "message", Role: "assistant", Content: partsJSON}}, nil
}
var blocks []AnthropicContentBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
return nil, err
}
var items []ResponsesInputItem
// Preserve turn order: reasoning → assistant text → tool calls. xAI/Codex
// multi-turn cache and tool continuations expect reasoning before the
// assistant message that followed it.
for _, b := range blocks {
if b.Type != "thinking" {
continue
}
sig := strings.TrimSpace(b.Signature)
// Only replay provider ciphertext. Skip GPT/Codex-style gAAAA blobs and
// empty placeholders — xAI returns 400 on decrypt for foreign signatures.
if sig == "" || strings.HasPrefix(sig, "gAAAA") {
continue
}
items = append(items, ResponsesInputItem{
Type: "reasoning",
EncryptedContent: sig,
})
}
// Text content → assistant message with output_text content parts.
text := extractAnthropicTextFromBlocks(blocks)
if text != "" {
parts := []ResponsesContentPart{{Type: "output_text", Text: text}}
partsJSON, err := json.Marshal(parts)
if err != nil {
return nil, err
}
items = append(items, ResponsesInputItem{Type: "message", Role: "assistant", Content: partsJSON})
}
// tool_use → function_call items.
for _, b := range blocks {
if b.Type != "tool_use" {
continue
}
args := "{}"
if len(b.Input) > 0 {
args = string(b.Input)
}
fcID := toResponsesCallID(b.ID)
items = append(items, ResponsesInputItem{
Type: "function_call",
CallID: fcID,
Name: b.Name,
Arguments: args,
})
}
return items, nil
}
// toResponsesCallID preserves Anthropic tool IDs as Responses call_id values.
// Claude Code sends tool_result.tool_use_id back verbatim, and ChatGPT Codex
// continuation expects that call_id to match the original tool_use id.
func toResponsesCallID(id string) string {
return id
}
// fromResponsesCallID reverses old prefixed IDs while preserving current IDs.
func fromResponsesCallID(id string) string {
if after, ok := strings.CutPrefix(id, "fc_"); ok {
// Only strip if the remainder doesn't look like it was already "fc_" prefixed.
// E.g. "fc_toolu_xxx" → "toolu_xxx", "fc_call_xxx" → "call_xxx"
if strings.HasPrefix(after, "toolu_") || strings.HasPrefix(after, "call_") {
return after
}
}
return id
}
// anthropicImageToDataURI converts an AnthropicImageSource to a data URI string.
// Returns "" if the source is nil or has no data.
func anthropicImageToDataURI(src *AnthropicImageSource) string {
if src == nil || src.Data == "" {
return ""
}
mediaType := src.MediaType
if mediaType == "" {
mediaType = "image/png"
}
return "data:" + mediaType + ";base64," + src.Data
}
// convertToolResultOutput extracts text and image content from a tool_result
// block. Returns the text as a string for the function_call_output Output
// field, plus any image parts that must be sent in a separate user message
// (the Responses API output field only accepts strings).
func convertToolResultOutput(b AnthropicContentBlock) (string, []ResponsesContentPart) {
if len(b.Content) == 0 {
return "(empty)", nil
}
// Try plain string content.
var s string
if err := json.Unmarshal(b.Content, &s); err == nil {
if s == "" {
s = "(empty)"
}
return s, nil
}
// Array of content blocks — may contain text and/or images.
var inner []AnthropicContentBlock
if err := json.Unmarshal(b.Content, &inner); err != nil {
return "(empty)", nil
}
// Separate text (for function_call_output) from images (for user message).
var textParts []string
var imageParts []ResponsesContentPart
for _, ib := range inner {
switch ib.Type {
case "text":
if ib.Text != "" {
textParts = append(textParts, ib.Text)
}
case "image":
if uri := anthropicImageToDataURI(ib.Source); uri != "" {
imageParts = append(imageParts, ResponsesContentPart{Type: "input_image", ImageURL: uri})
}
}
}
text := strings.Join(textParts, "\n\n")
if text == "" {
text = "(empty)"
}
return text, imageParts
}
// extractAnthropicTextFromBlocks joins all text blocks, ignoring thinking/
// tool_use/tool_result blocks.
func extractAnthropicTextFromBlocks(blocks []AnthropicContentBlock) string {
var parts []string
for _, b := range blocks {
if b.Type == "text" && b.Text != "" {
parts = append(parts, b.Text)
}
}
return strings.Join(parts, "\n\n")
}
// mapAnthropicEffortToResponses converts Anthropic reasoning effort levels to
// OpenAI Responses API effort levels.
//
// Both APIs default to "high". The mapping is 1:1 for shared levels;
// only Anthropic's "max" (Opus 4.6 exclusive) maps to OpenAI's "xhigh"
// (GPT-5.2+ exclusive) as both represent the highest reasoning tier.
//
// low → low
// medium → medium
// high → high
// max → xhigh
func mapAnthropicEffortToResponses(effort string) string {
if effort == "max" {
return "xhigh"
}
return effort // low→low, medium→medium, high→high, unknown→passthrough
}
// convertAnthropicToolsToResponses maps Anthropic tool definitions to
// Responses API tools. Server-side tools like web_search are mapped to their
// OpenAI equivalents; regular tools become function tools.
func convertAnthropicToolsToResponses(tools []AnthropicTool) []ResponsesTool {
var out []ResponsesTool
for _, t := range tools {
// Anthropic server tools like "web_search_20250305" → OpenAI {"type":"web_search"}
if strings.HasPrefix(t.Type, "web_search") {
out = append(out, ResponsesTool{Type: "web_search"})
continue
}
out = append(out, ResponsesTool{
Type: "function",
Name: t.Name,
Description: t.Description,
Parameters: normalizeToolParameters(t.InputSchema),
Strict: boolPtr(false),
})
}
return out
}
func boolPtr(v bool) *bool {
return &v
}
// isReasoningModel reports whether model is a reasoning model that does not
// support sampling parameters (temperature, top_p) via the Responses API.
// All gpt-5.x models are reasoning-only; the Responses API returns
// "Unsupported parameter: temperature" if these fields are present.
func isReasoningModel(model string) bool {
return strings.HasPrefix(model, "gpt-5")
}
// normalizeToolParameters ensures the tool parameter schema is valid for
// OpenAI's Responses API, which requires "properties" on object schemas.
//
// - nil/empty → {"type":"object","properties":{}}
// - type=object without properties → adds "properties": {}
// - otherwise → returned unchanged
func normalizeToolParameters(schema json.RawMessage) json.RawMessage {
if len(schema) == 0 || string(schema) == "null" {
return json.RawMessage(`{"type":"object","properties":{}}`)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(schema, &m); err != nil {
return schema
}
typ := m["type"]
if string(typ) != `"object"` {
return schema
}
if _, ok := m["properties"]; ok {
return schema
}
m["properties"] = json.RawMessage(`{}`)
out, err := json.Marshal(m)
if err != nil {
return schema
}
return out
}
@@ -0,0 +1,634 @@
package apicompat
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
// ---------------------------------------------------------------------------
// Non-streaming: AnthropicResponse → ResponsesResponse
// ---------------------------------------------------------------------------
// AnthropicToResponsesResponse converts an Anthropic Messages response into a
// Responses API response. This is the reverse of ResponsesToAnthropic and
// enables Anthropic upstream responses to be returned in OpenAI Responses format.
func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse {
id := resp.ID
if id == "" {
id = generateResponsesID()
}
out := &ResponsesResponse{
ID: id,
Object: "response",
Model: resp.Model,
}
var outputs []ResponsesOutput
var msgParts []ResponsesContentPart
for _, block := range resp.Content {
switch block.Type {
case "thinking":
if block.Thinking != "" {
outputs = append(outputs, ResponsesOutput{
Type: "reasoning",
ID: generateItemID(),
Summary: []ResponsesSummary{{
Type: "summary_text",
Text: block.Thinking,
}},
})
}
case "text":
if block.Text != "" {
msgParts = append(msgParts, ResponsesContentPart{
Type: "output_text",
Text: block.Text,
})
}
case "tool_use":
args := "{}"
if len(block.Input) > 0 {
args = string(block.Input)
}
outputs = append(outputs, ResponsesOutput{
Type: "function_call",
ID: generateItemID(),
CallID: toResponsesCallID(block.ID),
Name: block.Name,
Arguments: args,
Status: "completed",
})
}
}
// Assemble message output item from text parts
if len(msgParts) > 0 {
outputs = append(outputs, ResponsesOutput{
Type: "message",
ID: generateItemID(),
Role: "assistant",
Content: msgParts,
Status: "completed",
})
}
if len(outputs) == 0 {
outputs = append(outputs, ResponsesOutput{
Type: "message",
ID: generateItemID(),
Role: "assistant",
Content: []ResponsesContentPart{{Type: "output_text", Text: ""}},
Status: "completed",
})
}
out.Output = outputs
// Map stop_reason → status
out.Status = anthropicStopReasonToResponsesStatus(AnthropicStopReasonString(resp.StopReason), resp.Content)
if out.Status == "incomplete" {
out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"}
}
// Usage
// Anthropic's input_tokens excludes cache_read/cache_creation, while OpenAI
// Responses' input_tokens is the total including cached tokens. Add them back
// when converting so downstream consumers see OpenAI semantics.
totalInputTokens := resp.Usage.InputTokens +
resp.Usage.CacheReadInputTokens +
resp.Usage.CacheCreationInputTokens
out.Usage = &ResponsesUsage{
InputTokens: totalInputTokens,
OutputTokens: resp.Usage.OutputTokens,
TotalTokens: totalInputTokens + resp.Usage.OutputTokens,
CacheCreationInputTokens: resp.Usage.CacheCreationInputTokens,
}
if resp.Usage.CacheReadInputTokens > 0 {
out.Usage.InputTokensDetails = &ResponsesInputTokensDetails{
CachedTokens: resp.Usage.CacheReadInputTokens,
}
}
return out
}
// anthropicStopReasonToResponsesStatus maps Anthropic stop_reason to Responses status.
func anthropicStopReasonToResponsesStatus(stopReason string, blocks []AnthropicContentBlock) string {
switch stopReason {
case "max_tokens":
return "incomplete"
case "end_turn", "tool_use", "stop_sequence":
return "completed"
default:
return "completed"
}
}
// ---------------------------------------------------------------------------
// Streaming: AnthropicStreamEvent → []ResponsesStreamEvent (stateful converter)
// ---------------------------------------------------------------------------
// AnthropicEventToResponsesState tracks state for converting a sequence of
// Anthropic SSE events into Responses SSE events.
type AnthropicEventToResponsesState struct {
ResponseID string
Model string
Created int64
SequenceNumber int
// CreatedSent tracks whether response.created has been emitted.
CreatedSent bool
// CompletedSent tracks whether the terminal event has been emitted.
CompletedSent bool
// Current output tracking
OutputIndex int
CurrentItemID string
CurrentItemType string // "message" | "function_call" | "reasoning"
// For message output: accumulate text parts
ContentIndex int
// TextAccum accumulates the current text part so that output_text.done and
// content_part.done can carry the full text (deltas carry increments only).
TextAccum string
// For function_call: track per-output info
CurrentCallID string
CurrentName string
// Content of the currently open item, folded into Outputs when it closes.
CurrentContent []ResponsesContentPart // message
CurrentArgs string // function_call
CurrentSummary string // reasoning
// Outputs accumulates every closed output item so that response.completed
// can carry the full output list. The OpenAI SDK's get_final_response()
// parses the terminal event's response directly; without this, clients see
// an empty output_text.
Outputs []ResponsesOutput
// Usage from message_start / message_delta. InputTokens here follows
// Anthropic semantics (excludes cached tokens); they are added back when
// emitting the OpenAI Responses usage.
InputTokens int
OutputTokens int
CacheReadInputTokens int
CacheCreationInputTokens int
StopReason string
}
// NewAnthropicEventToResponsesState returns an initialised stream state.
func NewAnthropicEventToResponsesState() *AnthropicEventToResponsesState {
return &AnthropicEventToResponsesState{
Created: time.Now().Unix(),
}
}
// AnthropicEventToResponsesEvents converts a single Anthropic SSE event into
// zero or more Responses SSE events, updating state as it goes.
func AnthropicEventToResponsesEvents(
evt *AnthropicStreamEvent,
state *AnthropicEventToResponsesState,
) []ResponsesStreamEvent {
switch evt.Type {
case "message_start":
return anthToResHandleMessageStart(evt, state)
case "content_block_start":
return anthToResHandleContentBlockStart(evt, state)
case "content_block_delta":
return anthToResHandleContentBlockDelta(evt, state)
case "content_block_stop":
return anthToResHandleContentBlockStop(evt, state)
case "message_delta":
return anthToResHandleMessageDelta(evt, state)
case "message_stop":
return anthToResHandleMessageStop(state)
default:
return nil
}
}
// FinalizeAnthropicResponsesStream emits synthetic termination events if the
// stream ended without a proper message_stop.
func FinalizeAnthropicResponsesStream(state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
if !state.CreatedSent || state.CompletedSent {
return nil
}
var events []ResponsesStreamEvent
// Close any open item
events = append(events, closeCurrentResponsesItem(state)...)
status, incompleteDetails := anthropicResponsesStreamTerminalState(state.StopReason)
events = append(events, makeResponsesCompletedEvent(state, status, incompleteDetails))
state.CompletedSent = true
return events
}
// ResponsesEventToSSE formats a ResponsesStreamEvent as an SSE data line.
func ResponsesEventToSSE(evt ResponsesStreamEvent) (string, error) {
data, err := json.Marshal(evt)
if err != nil {
return "", err
}
return fmt.Sprintf("event: %s\ndata: %s\n\n", evt.Type, data), nil
}
// --- internal handlers ---
func anthToResHandleMessageStart(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
if evt.Message != nil {
state.ResponseID = evt.Message.ID
if state.Model == "" {
state.Model = evt.Message.Model
}
if evt.Message.Usage.InputTokens > 0 {
state.InputTokens = evt.Message.Usage.InputTokens
}
if evt.Message.Usage.CacheReadInputTokens > 0 {
state.CacheReadInputTokens = evt.Message.Usage.CacheReadInputTokens
}
if evt.Message.Usage.CacheCreationInputTokens > 0 {
state.CacheCreationInputTokens = evt.Message.Usage.CacheCreationInputTokens
}
}
if state.CreatedSent {
return nil
}
state.CreatedSent = true
// Emit response.created
return []ResponsesStreamEvent{makeResponsesCreatedEvent(state)}
}
func anthToResHandleContentBlockStart(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
if evt.ContentBlock == nil {
return nil
}
var events []ResponsesStreamEvent
switch evt.ContentBlock.Type {
case "thinking":
state.CurrentItemID = generateItemID()
state.CurrentItemType = "reasoning"
state.ContentIndex = 0
events = append(events, makeResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
Item: &ResponsesOutput{
Type: "reasoning",
ID: state.CurrentItemID,
},
}))
case "text":
// If we don't have an open message item, open one
if state.CurrentItemType != "message" {
state.CurrentItemID = generateItemID()
state.CurrentItemType = "message"
state.ContentIndex = 0
events = append(events, makeResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
Item: &ResponsesOutput{
Type: "message",
ID: state.CurrentItemID,
Role: "assistant",
Status: "in_progress",
},
}))
}
// response.content_part.added must precede the output_text.delta events
// for that part. The message item is added with content: [], and the
// OpenAI SDK's accumulating stream helper (client.responses.stream) only
// appends a content part when it sees content_part.added. Without it the
// following output_text.delta indexes output.content[content_index] and
// raises IndexError. Raw event iteration
// (responses.create(stream=True)) does not accumulate, which is why this
// went unnoticed.
events = append(events, makeResponsesEvent(state, "response.content_part.added", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
ContentIndex: state.ContentIndex,
ItemID: state.CurrentItemID,
Part: &ResponsesContentPart{Type: "output_text", Text: ""},
}))
state.TextAccum = ""
case "tool_use":
// Close previous item if any
events = append(events, closeCurrentResponsesItem(state)...)
state.CurrentItemID = generateItemID()
state.CurrentItemType = "function_call"
state.CurrentCallID = toResponsesCallID(evt.ContentBlock.ID)
state.CurrentName = evt.ContentBlock.Name
events = append(events, makeResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
Item: &ResponsesOutput{
Type: "function_call",
ID: state.CurrentItemID,
CallID: state.CurrentCallID,
Name: state.CurrentName,
Status: "in_progress",
},
}))
}
return events
}
func anthToResHandleContentBlockDelta(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
if evt.Delta == nil {
return nil
}
switch evt.Delta.Type {
case "text_delta":
if evt.Delta.Text == "" {
return nil
}
state.TextAccum += evt.Delta.Text
return []ResponsesStreamEvent{makeResponsesEvent(state, "response.output_text.delta", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
ContentIndex: state.ContentIndex,
Delta: evt.Delta.Text,
ItemID: state.CurrentItemID,
})}
case "thinking_delta":
if evt.Delta.Thinking == "" {
return nil
}
state.CurrentSummary += evt.Delta.Thinking
return []ResponsesStreamEvent{makeResponsesEvent(state, "response.reasoning_summary_text.delta", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
SummaryIndex: 0,
Delta: evt.Delta.Thinking,
ItemID: state.CurrentItemID,
})}
case "input_json_delta":
if evt.Delta.PartialJSON == "" {
return nil
}
state.CurrentArgs += evt.Delta.PartialJSON
return []ResponsesStreamEvent{makeResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
Delta: evt.Delta.PartialJSON,
ItemID: state.CurrentItemID,
CallID: state.CurrentCallID,
Name: state.CurrentName,
})}
case "signature_delta":
// Anthropic signature deltas have no Responses equivalent; skip
return nil
}
return nil
}
func anthToResHandleContentBlockStop(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
switch state.CurrentItemType {
case "reasoning":
// Emit reasoning summary done + output item done
events := []ResponsesStreamEvent{
makeResponsesEvent(state, "response.reasoning_summary_text.done", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
SummaryIndex: 0,
ItemID: state.CurrentItemID,
}),
}
events = append(events, closeCurrentResponsesItem(state)...)
return events
case "function_call":
// Emit function_call_arguments.done + output item done
events := []ResponsesStreamEvent{
makeResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
ItemID: state.CurrentItemID,
CallID: state.CurrentCallID,
Name: state.CurrentName,
}),
}
events = append(events, closeCurrentResponsesItem(state)...)
return events
case "message":
// Text block is done: emit output_text.done then content_part.done (the
// order OpenAI uses), both carrying the part's full text. The message
// item itself stays open since more blocks may follow.
text := state.TextAccum
state.TextAccum = ""
state.CurrentContent = append(state.CurrentContent, ResponsesContentPart{Type: "output_text", Text: text})
return []ResponsesStreamEvent{
makeResponsesEvent(state, "response.output_text.done", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
ContentIndex: state.ContentIndex,
ItemID: state.CurrentItemID,
Text: text,
}),
makeResponsesEvent(state, "response.content_part.done", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
ContentIndex: state.ContentIndex,
ItemID: state.CurrentItemID,
Part: &ResponsesContentPart{Type: "output_text", Text: text},
}),
}
}
return nil
}
func anthToResHandleMessageDelta(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
if evt.Usage != nil {
state.OutputTokens = evt.Usage.OutputTokens
if evt.Usage.InputTokens > 0 {
state.InputTokens = evt.Usage.InputTokens
}
if evt.Usage.CacheReadInputTokens > 0 {
state.CacheReadInputTokens = evt.Usage.CacheReadInputTokens
}
if evt.Usage.CacheCreationInputTokens > 0 {
state.CacheCreationInputTokens = evt.Usage.CacheCreationInputTokens
}
}
if evt.Delta != nil && evt.Delta.StopReason != "" {
state.StopReason = evt.Delta.StopReason
}
return nil
}
func anthToResHandleMessageStop(state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
if state.CompletedSent {
return nil
}
var events []ResponsesStreamEvent
events = append(events, closeCurrentResponsesItem(state)...)
status, incompleteDetails := anthropicResponsesStreamTerminalState(state.StopReason)
events = append(events, makeResponsesCompletedEvent(state, status, incompleteDetails))
state.CompletedSent = true
return events
}
// --- helper functions ---
func anthropicResponsesStreamTerminalState(stopReason string) (string, *ResponsesIncompleteDetails) {
if stopReason == "max_tokens" {
return "incomplete", &ResponsesIncompleteDetails{Reason: "max_output_tokens"}
}
return "completed", nil
}
func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
if state.CurrentItemType == "" {
return nil
}
// Assemble the full item: both output_item.done and response.completed must
// carry its content. Emitting only {type,id,status} makes SDK-side
// accumulation produce an empty output.
item := ResponsesOutput{
Type: state.CurrentItemType,
ID: state.CurrentItemID,
Status: "completed",
}
switch state.CurrentItemType {
case "message":
item.Role = "assistant"
item.Content = state.CurrentContent
case "function_call":
item.CallID = state.CurrentCallID
item.Name = state.CurrentName
args := state.CurrentArgs
if args == "" {
args = "{}"
}
item.Arguments = args
case "reasoning":
if state.CurrentSummary != "" {
item.Summary = []ResponsesSummary{{Type: "summary_text", Text: state.CurrentSummary}}
}
}
state.Outputs = append(state.Outputs, item)
// Reset
state.CurrentItemType = ""
state.CurrentItemID = ""
state.CurrentCallID = ""
state.CurrentName = ""
state.CurrentContent = nil
state.CurrentArgs = ""
state.CurrentSummary = ""
state.TextAccum = ""
state.OutputIndex++
state.ContentIndex = 0
return []ResponsesStreamEvent{makeResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex - 1, // Use the index before increment
Item: &item,
})}
}
func makeResponsesCreatedEvent(state *AnthropicEventToResponsesState) ResponsesStreamEvent {
seq := state.SequenceNumber
state.SequenceNumber++
return ResponsesStreamEvent{
Type: "response.created",
SequenceNumber: seq,
Response: &ResponsesResponse{
ID: state.ResponseID,
Object: "response",
Model: state.Model,
Status: "in_progress",
Output: []ResponsesOutput{},
},
}
}
func makeResponsesCompletedEvent(
state *AnthropicEventToResponsesState,
status string,
incompleteDetails *ResponsesIncompleteDetails,
) ResponsesStreamEvent {
seq := state.SequenceNumber
state.SequenceNumber++
// Anthropic's input_tokens excludes cache_read/cache_creation; add them
// back to match OpenAI Responses semantics where input_tokens is the total.
totalInputTokens := state.InputTokens + state.CacheReadInputTokens + state.CacheCreationInputTokens
usage := &ResponsesUsage{
InputTokens: totalInputTokens,
OutputTokens: state.OutputTokens,
TotalTokens: totalInputTokens + state.OutputTokens,
CacheCreationInputTokens: state.CacheCreationInputTokens,
}
if state.CacheReadInputTokens > 0 {
usage.InputTokensDetails = &ResponsesInputTokensDetails{
CachedTokens: state.CacheReadInputTokens,
}
}
eventType := "response.completed"
if status == "incomplete" {
eventType = "response.incomplete"
}
// Carry the output items accumulated over the stream. The SDK's
// get_final_response() reads them straight from the terminal event, so an
// empty list leaves clients with an empty result.
outputs := state.Outputs
if outputs == nil {
outputs = []ResponsesOutput{}
}
return ResponsesStreamEvent{
Type: eventType,
SequenceNumber: seq,
Response: &ResponsesResponse{
ID: state.ResponseID,
Object: "response",
Model: state.Model,
Status: status,
Output: outputs,
Usage: usage,
IncompleteDetails: incompleteDetails,
},
}
}
func makeResponsesEvent(state *AnthropicEventToResponsesState, eventType string, template *ResponsesStreamEvent) ResponsesStreamEvent {
seq := state.SequenceNumber
state.SequenceNumber++
evt := *template
evt.Type = eventType
evt.SequenceNumber = seq
return evt
}
func generateResponsesID() string {
b := make([]byte, 12)
_, _ = rand.Read(b)
return "resp_" + hex.EncodeToString(b)
}
func generateItemID() string {
b := make([]byte, 12)
_, _ = rand.Read(b)
return "item_" + hex.EncodeToString(b)
}
@@ -0,0 +1,187 @@
package apicompat
import "testing"
// TestAnthropicEventToResponses_TextEmitsContentPart pins that a message text
// stream emits response.content_part.added, and that it precedes the first
// output_text.delta for that part.
//
// Why: the OpenAI SDK's accumulating stream helper (client.responses.stream)
// only appends a content part to the message item when it sees
// content_part.added. The item is added with content: [], so a missing event
// makes the following output_text.delta index output.content[content_index] and
// raise IndexError. Raw event iteration does not accumulate, so a regression
// here is easy to miss.
func TestAnthropicEventToResponses_TextEmitsContentPart(t *testing.T) {
state := NewAnthropicEventToResponsesState()
state.Model = "claude-sonnet-4-5"
var types []string
feed := func(evt *AnthropicStreamEvent) {
for _, out := range AnthropicEventToResponsesEvents(evt, state) {
types = append(types, out.Type)
}
}
idx := 0
feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1", Model: "claude-sonnet-4-5"}})
feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: &AnthropicContentBlock{Type: "text"}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "Hel"}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "lo"}})
feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx})
feed(&AnthropicStreamEvent{Type: "message_stop"})
posOf := func(target string) int {
for i, ty := range types {
if ty == target {
return i
}
}
return -1
}
partAdded := posOf("response.content_part.added")
firstDelta := posOf("response.output_text.delta")
if partAdded < 0 {
t.Fatalf("response.content_part.added was not emitted; got %v", types)
}
if firstDelta < 0 {
t.Fatalf("response.output_text.delta was not emitted; got %v", types)
}
if partAdded > firstDelta {
t.Errorf("content_part.added must precede the first output_text.delta; got %v", types)
}
if posOf("response.content_part.done") < 0 {
t.Errorf("response.content_part.done was not emitted; got %v", types)
}
}
// TestAnthropicEventToResponses_DoneEventsCarryFullText pins that done events
// carry the part's full text (deltas carry increments only).
func TestAnthropicEventToResponses_DoneEventsCarryFullText(t *testing.T) {
state := NewAnthropicEventToResponsesState()
state.Model = "claude-sonnet-4-5"
var events []ResponsesStreamEvent
feed := func(evt *AnthropicStreamEvent) {
events = append(events, AnthropicEventToResponsesEvents(evt, state)...)
}
idx := 0
feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1"}})
feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: &AnthropicContentBlock{Type: "text"}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "Hello "}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "world"}})
feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx})
const want = "Hello world"
var sawTextDone, sawPartDone bool
for _, e := range events {
switch e.Type {
case "response.output_text.done":
sawTextDone = true
if e.Text != want {
t.Errorf("output_text.done text = %q, want %q", e.Text, want)
}
case "response.content_part.done":
sawPartDone = true
if e.Part == nil || e.Part.Text != want {
t.Errorf("content_part.done part = %+v, want text %q", e.Part, want)
}
}
}
if !sawTextDone || !sawPartDone {
t.Errorf("missing done events: output_text.done=%v content_part.done=%v", sawTextDone, sawPartDone)
}
}
// TestAnthropicEventToResponses_CompletedCarriesOutput pins that
// response.completed carries the full output list. The SDK's
// get_final_response() and tracing integrations parse the terminal event's
// response directly; an empty output leaves them with nothing (the text still
// renders from deltas, which is why this is invisible when only watching the
// stream).
func TestAnthropicEventToResponses_CompletedCarriesOutput(t *testing.T) {
state := NewAnthropicEventToResponsesState()
state.Model = "claude-sonnet-4-5"
var events []ResponsesStreamEvent
feed := func(evt *AnthropicStreamEvent) {
events = append(events, AnthropicEventToResponsesEvents(evt, state)...)
}
idx := 0
feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1"}})
feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: &AnthropicContentBlock{Type: "text"}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "4826"}})
feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx})
feed(&AnthropicStreamEvent{Type: "message_stop"})
var completed *ResponsesStreamEvent
for i := range events {
if events[i].Type == "response.completed" {
completed = &events[i]
}
}
if completed == nil || completed.Response == nil {
t.Fatalf("response.completed was not emitted")
}
if len(completed.Response.Output) == 0 {
t.Fatalf("response.completed carries an empty output; clients would see no result")
}
msg := completed.Response.Output[0]
if msg.Type != "message" || len(msg.Content) == 0 {
t.Fatalf("output[0] = %+v, want a message with content", msg)
}
if msg.Content[0].Text != "4826" {
t.Errorf("output[0].content[0].text = %q, want %q", msg.Content[0].Text, "4826")
}
}
// TestAnthropicEventToResponses_ToolCallCompletedCarriesArguments pins that a
// function call's accumulated arguments survive into output_item.done and
// response.completed.
func TestAnthropicEventToResponses_ToolCallCompletedCarriesArguments(t *testing.T) {
state := NewAnthropicEventToResponsesState()
state.Model = "claude-sonnet-4-5"
var events []ResponsesStreamEvent
feed := func(evt *AnthropicStreamEvent) {
events = append(events, AnthropicEventToResponsesEvents(evt, state)...)
}
idx := 0
feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1"}})
feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: &AnthropicContentBlock{
Type: "tool_use", ID: "toolu_1", Name: "get_weather",
}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{
Type: "input_json_delta", PartialJSON: `{"city":`,
}})
feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{
Type: "input_json_delta", PartialJSON: `"SH"}`,
}})
feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx})
feed(&AnthropicStreamEvent{Type: "message_stop"})
var completed *ResponsesStreamEvent
for i := range events {
if events[i].Type == "response.completed" {
completed = &events[i]
}
}
if completed == nil || completed.Response == nil || len(completed.Response.Output) == 0 {
t.Fatalf("response.completed carries no output")
}
fc := completed.Response.Output[0]
if fc.Type != "function_call" {
t.Fatalf("output[0].type = %q, want function_call", fc.Type)
}
if fc.Arguments != `{"city":"SH"}` {
t.Errorf("arguments = %q, want %q", fc.Arguments, `{"city":"SH"}`)
}
if fc.Name != "get_weather" {
t.Errorf("name = %q, want get_weather", fc.Name)
}
}
@@ -0,0 +1,930 @@
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"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
package apicompat
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func readIssue5302Fixture(t *testing.T, name string, dst any) {
t.Helper()
payload, err := os.ReadFile(filepath.Join("testdata", "issue5302", name))
require.NoError(t, err)
require.NoError(t, json.Unmarshal(payload, dst))
}
func TestChatReasoningAlias_AnthropicNonStreaming(t *testing.T) {
var response ChatCompletionsResponse
readIssue5302Fixture(t, "nonstream_reasoning.json", &response)
out := ChatCompletionsResponseToAnthropic(&response, "claude-sonnet-4-20250514")
require.Len(t, out.Content, 2)
require.Equal(t, "thinking", out.Content[0].Type)
require.Equal(t, "fallback reasoning", out.Content[0].Thinking)
require.Equal(t, "final answer", out.Content[1].Text)
}
func TestChatReasoningAlias_AnthropicStreaming(t *testing.T) {
var chunk ChatCompletionsChunk
readIssue5302Fixture(t, "stream_reasoning.json", &chunk)
events := ChatCompletionsChunkToAnthropicEvents(&chunk, NewChatCompletionsToAnthropicStreamState("reasoning-model"))
var thinking string
for _, event := range events {
if event.Delta != nil && event.Delta.Type == "thinking_delta" {
thinking += event.Delta.Thinking
}
}
require.Equal(t, "streamed fallback", thinking)
}
func TestChatReasoningAlias_ResponsesSharedPaths(t *testing.T) {
var response ChatCompletionsResponse
readIssue5302Fixture(t, "nonstream_reasoning.json", &response)
nonStream := ChatCompletionsResponseToResponses(&response, "reasoning-model", nil, false, nil)
require.Len(t, nonStream.Output, 2)
require.Equal(t, "reasoning", nonStream.Output[0].Type)
require.Equal(t, "fallback reasoning", nonStream.Output[0].Summary[0].Text)
var chunk ChatCompletionsChunk
readIssue5302Fixture(t, "stream_reasoning.json", &chunk)
events := ChatCompletionsChunkToResponsesEvents(&chunk, NewChatCompletionsToResponsesStreamState("reasoning-model"))
var deltas []string
for _, event := range events {
if event.Type == "response.reasoning_summary_text.delta" {
deltas = append(deltas, event.Delta)
}
}
require.Equal(t, []string{"streamed fallback"}, deltas)
}
func TestChatReasoningAlias_ReasoningContentTakesPrecedence(t *testing.T) {
var chunk ChatCompletionsChunk
readIssue5302Fixture(t, "reasoning_content_precedence.json", &chunk)
events := ChatCompletionsChunkToResponsesEvents(&chunk, NewChatCompletionsToResponsesStreamState("reasoning-model"))
var deltas []string
for _, event := range events {
if event.Type == "response.reasoning_summary_text.delta" {
deltas = append(deltas, event.Delta)
}
}
require.Equal(t, []string{"preferred reasoning"}, deltas)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,962 @@
package apicompat
// custom/freeform 工具(如 Codex 0.14x 的 exec)在 responses→chat 桥上的双向转换。
// 背景:Codex 的核心命令执行工具 exec 是 type=custom(输入为自由文本),此前被
// responsesToolsToChatTools 丢弃,导致模型工具列表中没有 exec、无法执行任何命令。
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponsesToChatCompletionsRequest_CustomToolBecomesFunctionTool(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"run dir"`),
Tools: []ResponsesTool{
{Type: "custom", Name: "exec", Description: "Run JavaScript code"},
{Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)},
},
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, out.Tools, 2)
assert.Equal(t, "function", out.Tools[0].Type)
assert.Equal(t, "exec", out.Tools[0].Function.Name)
assert.Equal(t, "Run JavaScript code", out.Tools[0].Function.Description)
assert.JSONEq(t, customToolInputSchema, string(out.Tools[0].Function.Parameters))
assert.Equal(t, "wait", out.Tools[1].Function.Name)
}
func TestResponsesToChatCompletionsRequest_AdditionalToolsItem(t *testing.T) {
req := &ResponsesRequest{
Model: "gpt-test",
Input: json.RawMessage(`[
{"type":"additional_tools","role":"developer","tools":[
{"type":"custom","name":"exec","description":"Run PowerShell","format":{"type":"text"}},
{"type":"function","name":"wait","parameters":{"type":"object","properties":{}}},
{"type":"namespace","name":"collaboration","tools":[
{"type":"function","name":"send_message","parameters":{"type":"object","properties":{}}}
]}
]},
{"type":"message","role":"user","content":[{"type":"input_text","text":"run Get-Location"}]}
]`),
ToolChoice: json.RawMessage(`"auto"`),
}
effective, err := EffectiveResponsesTools(req)
require.NoError(t, err)
require.Len(t, effective, 3)
assert.True(t, CustomToolNames(effective)["exec"])
assert.Equal(t, NamespacedToolName{Namespace: "collaboration", Name: "send_message"}, NamespaceToolNames(effective)["collaboration__send_message"])
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, out.Tools, 3)
assert.Equal(t, "exec", out.Tools[0].Function.Name)
assert.Equal(t, "wait", out.Tools[1].Function.Name)
assert.Equal(t, "collaboration__send_message", out.Tools[2].Function.Name)
assert.JSONEq(t, `"auto"`, string(out.ToolChoice))
require.Len(t, out.Messages, 1, "additional_tools must not become a chat message")
assert.Equal(t, "user", out.Messages[0].Role)
}
func TestEffectiveResponsesTools_SkipsStringInputItems(t *testing.T) {
req := &ResponsesRequest{
Input: json.RawMessage(`["plain input",{"type":"additional_tools","tools":[{"type":"custom","name":"exec"}]}]`),
}
tools, err := EffectiveResponsesTools(req)
require.NoError(t, err)
require.Len(t, tools, 1)
assert.Equal(t, "exec", tools[0].Name)
}
func TestEffectiveResponsesTools_IgnoresMalformedToolsOnNonAdditionalItem(t *testing.T) {
req := &ResponsesRequest{
Input: json.RawMessage(`[
{"type":"message","role":"user","tools":"not-an-array","content":[{"type":"input_text","text":"hello"}]},
{"type":"additional_tools","tools":[{"type":"custom","name":"exec"}]}
]`),
}
tools, err := EffectiveResponsesTools(req)
require.NoError(t, err)
require.Len(t, tools, 1)
assert.Equal(t, "exec", tools[0].Name)
}
func TestEffectiveResponsesTools_RejectsMalformedAdditionalTools(t *testing.T) {
req := &ResponsesRequest{
Input: json.RawMessage(`[{"type":"additional_tools","tools":"not-an-array"}]`),
}
tools, err := EffectiveResponsesTools(req)
require.Error(t, err)
assert.Contains(t, err.Error(), "parse responses additional tools item")
assert.Empty(t, tools)
}
func TestResponsesToChatCompletionsRequest_DropsToolChoiceWhenNoConvertibleTools(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{
{Type: "web_search"},
{Type: "image_generation"},
},
ToolChoice: json.RawMessage(`"auto"`),
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
assert.Empty(t, out.Tools)
assert.Empty(t, out.ToolChoice, "tools 为空时转发 tool_choice 会被上游 400 拒绝")
}
func TestResponsesToChatCompletionsRequest_CustomToolChoiceMapsToFunctionChoice(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"run dir"`),
Tools: []ResponsesTool{{Type: "custom", Name: "exec"}},
ToolChoice: json.RawMessage(`{"type":"custom","name":"exec"}`),
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
assert.JSONEq(t, `{"type":"function","function":{"name":"exec"}}`, string(out.ToolChoice))
}
func TestResponsesInputToChatMessages_CustomToolCallHistory(t *testing.T) {
input := json.RawMessage(`[
{"role":"user","content":"list files"},
{"type":"custom_tool_call","call_id":"call_1","name":"exec","input":"dir"},
{"type":"custom_tool_call_output","call_id":"call_1","output":"main.go"}
]`)
messages, err := responsesInputToChatMessages("", input)
require.NoError(t, err)
require.Len(t, messages, 3)
assert.Equal(t, []string{"user", "assistant", "tool"}, chatMessageRoles(messages))
require.Len(t, messages[1].ToolCalls, 1)
toolCall := messages[1].ToolCalls[0]
assert.Equal(t, "call_1", toolCall.ID)
assert.Equal(t, "exec", toolCall.Function.Name)
assert.JSONEq(t, `{"input":"dir"}`, toolCall.Function.Arguments)
assert.Equal(t, "call_1", messages[2].ToolCallID)
assert.JSONEq(t, `"main.go"`, string(messages[2].Content))
}
func TestChatCompletionsResponseToResponses_CustomToolCallOutputItem(t *testing.T) {
resp := &ChatCompletionsResponse{
ID: "cc-1",
Choices: []ChatChoice{{
Message: ChatMessage{
Role: "assistant",
ToolCalls: []ChatToolCall{
{ID: "call_1", Function: ChatFunctionCall{Name: "exec", Arguments: `{"input": "dir"}`}},
{ID: "call_2", Function: ChatFunctionCall{Name: "wait", Arguments: `{"cell_id": 3}`}},
},
},
}},
}
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, false, nil)
require.Len(t, out.Output, 2)
assert.Equal(t, "custom_tool_call", out.Output[0].Type)
assert.Equal(t, "call_1", out.Output[0].CallID)
assert.Equal(t, "exec", out.Output[0].Name)
assert.Equal(t, "dir", out.Output[0].Input)
assert.Empty(t, out.Output[0].Arguments)
assert.Equal(t, "function_call", out.Output[1].Type)
assert.Equal(t, "wait", out.Output[1].Name)
assert.Equal(t, `{"cell_id": 3}`, out.Output[1].Arguments)
}
func TestExtractCustomToolCallInput_FallsBackToRawArguments(t *testing.T) {
assert.Equal(t, "dir", extractCustomToolCallInput(`{"input": "dir"}`))
assert.Equal(t, "console.log(1)", extractCustomToolCallInput(`console.log(1)`))
assert.Equal(t, `{"other": "x"}`, extractCustomToolCallInput(`{"other": "x"}`))
assert.Equal(t, "", extractCustomToolCallInput(`{}`))
assert.Equal(t, "", extractCustomToolCallInput(""))
}
func TestChatCompletionsChunkToResponsesEvents_CustomToolCallStream(t *testing.T) {
state := NewChatCompletionsToResponsesStreamState("glm-5.2")
state.CustomTools = map[string]bool{"exec": true}
idx := 0
chunk := &ChatCompletionsChunk{
ID: "cc-1",
Choices: []ChatChunkChoice{{
Delta: ChatDelta{
ToolCalls: []ChatToolCall{{
Index: &idx,
ID: "call_1",
Function: ChatFunctionCall{Name: "exec", Arguments: `{"input": "dir"}`},
}},
},
}},
}
events := ChatCompletionsChunkToResponsesEvents(chunk, state)
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
var added, inputDone, itemDone *ResponsesStreamEvent
for i := range events {
evt := &events[i]
switch evt.Type {
case "response.output_item.added":
if evt.Item != nil && evt.Item.Type != "message" && evt.Item.Type != "reasoning" {
added = evt
}
case "response.custom_tool_call_input.done":
inputDone = evt
case "response.output_item.done":
if evt.Item != nil && evt.Item.Type == "custom_tool_call" {
itemDone = evt
}
case "response.function_call_arguments.delta", "response.function_call_arguments.done":
t.Fatalf("custom 工具调用不应产出 function_call 参数事件: %s", evt.Type)
}
}
require.NotNil(t, added, "缺少 custom_tool_call 的 output_item.added")
assert.Equal(t, "custom_tool_call", added.Item.Type)
assert.Equal(t, "exec", added.Item.Name)
require.NotNil(t, inputDone, "缺少 response.custom_tool_call_input.done")
assert.Equal(t, "dir", inputDone.Input)
assert.Equal(t, "call_1", inputDone.CallID)
require.NotNil(t, itemDone, "缺少 custom_tool_call 的 output_item.done")
assert.Equal(t, "call_1", itemDone.Item.CallID)
assert.Equal(t, "exec", itemDone.Item.Name)
assert.Equal(t, "dir", itemDone.Item.Input)
assert.Empty(t, itemDone.Item.Arguments)
// response.completed 的 output 数组同样携带 custom_tool_call 项。
final := events[len(events)-1]
require.Equal(t, "response.completed", final.Type)
require.NotNil(t, final.Response)
foundCustom := false
for _, item := range final.Response.Output {
if item.Type == "custom_tool_call" {
foundCustom = true
assert.Equal(t, "exec", item.Name)
assert.Equal(t, "dir", item.Input)
}
}
assert.True(t, foundCustom, "response.completed 缺少 custom_tool_call 输出项")
}
func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{{Type: "tool_search"}},
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, out.Tools, 1)
assert.Equal(t, "function", out.Tools[0].Type)
assert.Equal(t, "tool_search", out.Tools[0].Function.Name)
assert.Contains(t, string(out.Tools[0].Function.Parameters), `"query"`)
}
// codex 只在 ResponseItem 为 tool_search_call 变体且 execution=client 时执行
// tool search;同名 function_call 会命中 ToolSearchHandler 后因 payload 不匹配
// 触发 FunctionCallError::Fatal,直接中止整个 turn,因此回程必须还原项类型。
func TestChatCompletionsResponseToResponses_ToolSearchCallOutputItem(t *testing.T) {
resp := &ChatCompletionsResponse{
ID: "cc-1",
Choices: []ChatChoice{{
Message: ChatMessage{
Role: "assistant",
ToolCalls: []ChatToolCall{
{ID: "call_s", Function: ChatFunctionCall{Name: "tool_search", Arguments: `{"query":"gmail","limit":2}`}},
},
},
}},
}
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, true, nil)
require.Len(t, out.Output, 1)
item := out.Output[0]
assert.Equal(t, "tool_search_call", item.Type)
assert.Equal(t, "call_s", item.CallID)
// 线上形态:execution 必须为 "client"codex 的必填字段,非 client 被忽略),
// arguments 必须是 JSON 对象而非字符串(codex 按对象解析 query/limit)。
b, err := json.Marshal(item)
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(b, &m))
assert.Equal(t, "client", m["execution"])
args, ok := m["arguments"].(map[string]any)
require.True(t, ok, "arguments 必须序列化为 JSON 对象")
assert.Equal(t, "gmail", args["query"])
}
func TestChatCompletionsResponseToResponses_ToolSearchNotDeclaredKeepsFunctionCall(t *testing.T) {
resp := &ChatCompletionsResponse{
Choices: []ChatChoice{{
Message: ChatMessage{
Role: "assistant",
ToolCalls: []ChatToolCall{
{ID: "call_s", Function: ChatFunctionCall{Name: "tool_search", Arguments: `{"query":"gmail"}`}},
},
},
}},
}
// 客户端未声明 type=tool_search 时,同名普通 function 工具不受影响。
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nil)
require.Len(t, out.Output, 1)
assert.Equal(t, "function_call", out.Output[0].Type)
}
func TestChatCompletionsChunkToResponsesEvents_ToolSearchCallStream(t *testing.T) {
state := NewChatCompletionsToResponsesStreamState("glm-5.2")
state.ToolSearchDeclared = true
idx := 0
chunk := &ChatCompletionsChunk{
ID: "cc-1",
Choices: []ChatChunkChoice{{
Delta: ChatDelta{
ToolCalls: []ChatToolCall{{
Index: &idx,
ID: "call_s",
Function: ChatFunctionCall{Name: "tool_search", Arguments: `{"query":"gmail"}`},
}},
},
}},
}
events := ChatCompletionsChunkToResponsesEvents(chunk, state)
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
var added, itemDone *ResponsesStreamEvent
for i := range events {
evt := &events[i]
switch evt.Type {
case "response.output_item.added":
if evt.Item != nil && evt.Item.Type != "message" && evt.Item.Type != "reasoning" {
added = evt
}
case "response.output_item.done":
if evt.Item != nil && evt.Item.Type == "tool_search_call" {
itemDone = evt
}
case "response.function_call_arguments.delta", "response.function_call_arguments.done",
"response.custom_tool_call_input.delta", "response.custom_tool_call_input.done":
t.Fatalf("tool_search 调用不应产出 %s", evt.Type)
}
}
require.NotNil(t, added, "缺少 tool_search_call 的 output_item.added")
assert.Equal(t, "tool_search_call", added.Item.Type)
require.NotNil(t, itemDone, "缺少 tool_search_call 的 output_item.done")
assert.Equal(t, "call_s", itemDone.Item.CallID)
// SSE 线上形态经 responsesItemWire 白名单重组,必须单独断言。
sse, err := ResponsesEventToSSE(*itemDone)
require.NoError(t, err)
assert.Contains(t, sse, `"execution":"client"`)
assert.Contains(t, sse, `"arguments":{"query":"gmail"}`)
assert.Contains(t, sse, `"call_id":"call_s"`)
// response.completed 的 output 数组同样携带 tool_search_call 项。
final := events[len(events)-1]
require.Equal(t, "response.completed", final.Type)
require.NotNil(t, final.Response)
found := false
for _, item := range final.Response.Output {
if item.Type == "tool_search_call" {
found = true
assert.Equal(t, "call_s", item.CallID)
}
}
assert.True(t, found, "response.completed 缺少 tool_search_call 输出项")
}
func TestHasToolSearchTool(t *testing.T) {
assert.True(t, HasToolSearchTool([]ResponsesTool{{Type: "tool_search"}}))
assert.False(t, HasToolSearchTool([]ResponsesTool{{Type: "function", Name: "tool_search"}}))
assert.False(t, HasToolSearchTool(nil))
}
func TestResponsesToChatCompletionsRequest_NamespaceToolFlattensChildren(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{{
Type: "namespace",
Name: "gmail",
Tools: []ResponsesTool{
{Type: "function", Name: "send", Description: "Send mail", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)},
{Type: "custom", Name: "ignored_child"},
},
}},
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, out.Tools, 1, "namespace 子工具中仅 function 类型被摊平")
assert.Equal(t, "gmail__send", out.Tools[0].Function.Name)
assert.Equal(t, "Send mail", out.Tools[0].Function.Description)
}
func TestResponsesToolsParsing_StringToolBecomesCustom(t *testing.T) {
var req ResponsesRequest
require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":"hi","tools":["exec",{"type":"function","name":"wait"}]}`), &req))
require.Len(t, req.Tools, 2)
assert.Equal(t, "custom", req.Tools[0].Type)
assert.Equal(t, "exec", req.Tools[0].Name)
assert.Equal(t, "function", req.Tools[1].Type)
assert.True(t, CustomToolNames(req.Tools)["exec"])
}
func TestFlattenNamespaceToolName_CapsAt64WithHashSuffix(t *testing.T) {
assert.Equal(t, "gmail__send", flattenNamespaceToolName("gmail", "send"))
long := flattenNamespaceToolName("very_long_namespace_prefix_for_testing_purposes", "and_a_rather_long_tool_name_too")
assert.LessOrEqual(t, len(long), 64)
assert.Contains(t, long, "__")
// 同输入结果稳定
assert.Equal(t, long, flattenNamespaceToolName("very_long_namespace_prefix_for_testing_purposes", "and_a_rather_long_tool_name_too"))
}
func TestResponsesInputToChatMessages_ToolSearchCallHistory(t *testing.T) {
input := json.RawMessage(`[
{"role":"user","content":"find tools"},
{"type":"tool_search_call","call_id":"call_s","arguments":{"query":"gmail"}},
{"type":"tool_search_output","call_id":"call_s","output":{"groups":["gmail"]}}
]`)
messages, err := responsesInputToChatMessages("", input)
require.NoError(t, err)
require.Len(t, messages, 3)
require.Len(t, messages[1].ToolCalls, 1)
assert.Equal(t, "tool_search", messages[1].ToolCalls[0].Function.Name)
assert.JSONEq(t, `{"query":"gmail"}`, messages[1].ToolCalls[0].Function.Arguments)
assert.Equal(t, "tool", messages[2].Role)
assert.Equal(t, "call_s", messages[2].ToolCallID)
assert.JSONEq(t, `"{\"groups\":[\"gmail\"]}"`, string(messages[2].Content))
}
func TestResponsesInputToChatMessages_NamespacedFunctionCallHistory(t *testing.T) {
input := json.RawMessage(`[
{"type":"function_call","call_id":"call_n","name":"send","namespace":"gmail","arguments":"{\"to\":\"a\"}"},
{"type":"function_call_output","call_id":"call_n","output":"ok"}
]`)
messages, err := responsesInputToChatMessages("", input)
require.NoError(t, err)
require.Len(t, messages, 2)
require.Len(t, messages[0].ToolCalls, 1)
assert.Equal(t, "gmail__send", messages[0].ToolCalls[0].Function.Name)
}
func TestChatCompletionsChunkToResponsesEvents_CustomToolNameArrivesLate(t *testing.T) {
state := NewChatCompletionsToResponsesStreamState("glm-5.2")
state.CustomTools = map[string]bool{"exec": true}
idx := 0
chunk1 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
ToolCalls: []ChatToolCall{{Index: &idx, ID: "call_1", Function: ChatFunctionCall{Arguments: `{"inp`}}},
}}}}
chunk2 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
ToolCalls: []ChatToolCall{{Index: &idx, Function: ChatFunctionCall{Name: "exec", Arguments: `ut": "dir"}`}}},
}}}}
var events []ResponsesStreamEvent
events = append(events, ChatCompletionsChunkToResponsesEvents(chunk1, state)...)
events = append(events, ChatCompletionsChunkToResponsesEvents(chunk2, state)...)
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
addedCount := 0
for _, evt := range events {
switch evt.Type {
case "response.output_item.added":
if evt.Item != nil && evt.Item.Type != "reasoning" && evt.Item.Type != "message" {
addedCount++
assert.Equal(t, "custom_tool_call", evt.Item.Type, "迟到的名字命中 custom 工具时按 custom_tool_call 宣告")
assert.Equal(t, "exec", evt.Item.Name)
}
case "response.function_call_arguments.delta", "response.function_call_arguments.done":
t.Fatalf("custom 调用不应产出 function 参数事件: %s", evt.Type)
case "response.custom_tool_call_input.done":
assert.Equal(t, "dir", evt.Input)
}
}
assert.Equal(t, 1, addedCount, "工具调用只宣告一次")
}
func TestChatCompletionsChunkToResponsesEvents_FunctionToolNameArrivesLate(t *testing.T) {
state := NewChatCompletionsToResponsesStreamState("glm-5.2")
state.CustomTools = map[string]bool{"exec": true}
idx := 0
chunk1 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
ToolCalls: []ChatToolCall{{Index: &idx, ID: "call_9", Function: ChatFunctionCall{Arguments: `{"cell`}}},
}}}}
chunk2 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
ToolCalls: []ChatToolCall{{Index: &idx, Function: ChatFunctionCall{Name: "wait", Arguments: `_id": 3}`}}},
}}}}
var events []ResponsesStreamEvent
events = append(events, ChatCompletionsChunkToResponsesEvents(chunk1, state)...)
events = append(events, ChatCompletionsChunkToResponsesEvents(chunk2, state)...)
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
deltas := ""
argsDone := ""
for _, evt := range events {
switch evt.Type {
case "response.function_call_arguments.delta":
deltas += evt.Delta
case "response.function_call_arguments.done":
argsDone = evt.Arguments
case "response.custom_tool_call_input.done":
t.Fatal("function 调用不应产出 custom 事件")
}
}
assert.Equal(t, `{"cell_id": 3}`, deltas, "宣告前累积的参数需在宣告时补发")
assert.Equal(t, `{"cell_id": 3}`, argsDone)
}
// 序列化层(MarshalJSON → responsesItemWire)单独走白名单重组,事件结构体上的字段
// 齐全不代表落到 SSE 线上的 JSON 齐全,必须在 wire 层再断言一次。
func TestResponsesEventToSSE_CustomToolCallItemCarriesAllFields(t *testing.T) {
evt := ResponsesStreamEvent{
Type: "response.output_item.done",
OutputIndex: 1,
Item: &ResponsesOutput{
Type: "custom_tool_call",
ID: "item_1",
CallID: "call_1",
Name: "exec",
Input: "dir",
Status: "completed",
},
}
sse, err := ResponsesEventToSSE(evt)
require.NoError(t, err)
assert.Contains(t, sse, `"call_id":"call_1"`)
assert.Contains(t, sse, `"name":"exec"`)
assert.Contains(t, sse, `"input":"dir"`)
assert.Contains(t, sse, `"type":"custom_tool_call"`)
}
func TestNamespaceToolNames_MapsFlattenedNames(t *testing.T) {
tools := []ResponsesTool{
{Type: "namespace", Name: "gmail", Tools: []ResponsesTool{
{Type: "function", Name: "send"},
{Type: "custom", Name: "skip_me"},
}},
{Type: "namespace", Name: "crm", Children: []ResponsesTool{
{Type: "function", Name: "query"},
}},
{Type: "function", Name: "wait"},
}
m := NamespaceToolNames(tools)
require.Len(t, m, 2)
assert.Equal(t, NamespacedToolName{Namespace: "gmail", Name: "send"}, m["gmail__send"])
assert.Equal(t, NamespacedToolName{Namespace: "crm", Name: "query"}, m["crm__query"])
// 摊平名超长时截断加哈希,无法按字符串切分还原,必须经映射反查。
longNS := "very_long_namespace_prefix_for_testing_purposes"
longChild := "and_a_rather_long_tool_name_too"
m2 := NamespaceToolNames([]ResponsesTool{{
Type: "namespace", Name: longNS,
Tools: []ResponsesTool{{Type: "function", Name: longChild}},
}})
assert.Equal(t, NamespacedToolName{Namespace: longNS, Name: longChild},
m2[flattenNamespaceToolName(longNS, longChild)])
assert.Nil(t, NamespaceToolNames(nil))
}
// 内置 tool_search 降级后的代理 function 与客户端声明的同名工具无法区分:回程会把
// 普通工具的调用劫持成 tool_search_call,必须显式拒绝(代理不能改名,codex 的模型
// 侧按 tool_search 这个名字调用)。
func TestResponsesToChatCompletionsRequest_RejectsToolSearchNameConflict(t *testing.T) {
// 与顶层 function 工具同名。
_, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{
{Type: "tool_search"},
{Type: "function", Name: "tool_search"},
},
})
require.Error(t, err, "与内置 tool_search 代理撞名的 function 工具必须拒绝")
assert.Contains(t, err.Error(), "tool_search")
// 与顶层 custom 工具同名。
_, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{
{Type: "custom", Name: "tool_search"},
{Type: "tool_search"},
},
})
require.Error(t, err, "与内置 tool_search 代理撞名的 custom 工具必须拒绝")
// 重复声明 type=tool_search 去重后只产出一个代理,不拒绝。
out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{{Type: "tool_search"}, {Type: "tool_search"}},
})
require.NoError(t, err)
require.Len(t, out.Tools, 1)
assert.Equal(t, "tool_search", out.Tools[0].Function.Name)
}
// tool_choice 指向被转换丢弃的工具(如 web_search)或不存在的名字时不能原样转发,
// chat 上游会因选择项指向未声明工具而 400;字符串形式与指向幸存工具的选择保持转发。
func TestResponsesToChatCompletionsRequest_DropsToolChoiceForDroppedTool(t *testing.T) {
// 强制选择被丢弃的 web_search:工具没了,选择项也必须丢。
out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{
{Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)},
{Type: "web_search"},
},
ToolChoice: json.RawMessage(`{"type":"web_search"}`),
})
require.NoError(t, err)
require.Len(t, out.Tools, 1)
assert.Empty(t, out.ToolChoice, "指向被丢弃服务端工具的 tool_choice 必须丢弃")
out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{
{Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)},
{Type: "web_search"},
{Type: "x_search"},
},
ToolChoice: json.RawMessage(`{"type":"function","name":"web_search"}`),
})
require.NoError(t, err)
require.Len(t, out.Tools, 2)
assert.Empty(t, out.ToolChoice, "surviving x_search must not keep a function tool_choice named web_search")
// 具名选择指向不存在的工具名。
out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{{Type: "function", Name: "wait"}},
ToolChoice: json.RawMessage(`{"type":"function","name":"missing"}`),
})
require.NoError(t, err)
assert.Empty(t, out.ToolChoice, "指向不存在工具名的 tool_choice 必须丢弃")
// 字符串形式与指向幸存工具的选择保持原有转发行为。
out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{{Type: "function", Name: "wait"}},
ToolChoice: json.RawMessage(`"auto"`),
})
require.NoError(t, err)
assert.JSONEq(t, `"auto"`, string(out.ToolChoice))
out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{{Type: "function", Name: "wait"}},
ToolChoice: json.RawMessage(`{"type":"function","name":"wait"}`),
})
require.NoError(t, err)
assert.JSONEq(t, `{"type":"function","function":{"name":"wait"}}`, string(out.ToolChoice))
}
// tool_search 工具没有被丢弃而是降级为同名 function 代理,强制选择它的 tool_choice
// 必须同步降级为指向代理的 function 选择,不能静默丢弃(丢弃会把强制搜索退化为
// 自动选择,模型可以不执行搜索)。
func TestResponsesToChatCompletionsRequest_ToolSearchToolChoiceMapsToProxy(t *testing.T) {
out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{{Type: "tool_search"}},
ToolChoice: json.RawMessage(`{"type":"tool_search"}`),
})
require.NoError(t, err)
assert.JSONEq(t, `{"type":"function","function":{"name":"tool_search"}}`, string(out.ToolChoice))
// 未声明 type=tool_search 时强制选择它没有可指向的代理,丢弃选择项。
out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{{Type: "function", Name: "wait"}},
ToolChoice: json.RawMessage(`{"type":"tool_search"}`),
})
require.NoError(t, err)
assert.Empty(t, out.ToolChoice)
}
// 客户端请求在原生 Responses API 上合法(namespace 子工具按 namespace+name 路由),
// 是摊平转换让名字产生歧义;歧义无法消除时必须显式拒绝整个请求(400),而不是
// 静默降级——否则重复声明发给上游、回程还原到错误工具,问题只能靠抓包定位。
func TestResponsesToChatCompletionsRequest_RejectsAmbiguousFlattenedNames(t *testing.T) {
// 摊平名与顶层 function 工具撞名。
_, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{
{Type: "function", Name: "gmail__send"},
{Type: "namespace", Name: "gmail", Tools: []ResponsesTool{{Type: "function", Name: "send"}}},
},
})
require.Error(t, err, "与顶层工具撞名的摊平必须拒绝")
assert.Contains(t, err.Error(), "gmail__send")
// 不同 namespace 组合产生相同摊平名。
_, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{
{Type: "namespace", Name: "a", Tools: []ResponsesTool{{Type: "function", Name: "b__c"}}},
{Type: "namespace", Name: "a__b", Tools: []ResponsesTool{{Type: "function", Name: "c"}}},
},
})
require.Error(t, err, "跨 namespace 撞名的摊平必须拒绝")
assert.Contains(t, err.Error(), "a__b__c")
}
// 完全相同的 (namespace, 子工具) 重复声明不构成歧义:去重后正常转换,不拒绝。
func TestResponsesToChatCompletionsRequest_DedupesIdenticalNamespaceChildren(t *testing.T) {
out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{
{Type: "namespace", Name: "gmail", Tools: []ResponsesTool{
{Type: "function", Name: "send"},
{Type: "function", Name: "send"},
}},
},
})
require.NoError(t, err)
require.Len(t, out.Tools, 1, "重复声明的同一子工具只声明一次")
assert.Equal(t, "gmail__send", out.Tools[0].Function.Name)
}
// codex 按 namespace+name 路由 namespace 子工具的调用:回程必须把摊平名还原为
// 裸子工具名并带独立 namespace 字段,平铺名的 function_call 会被 codex 判为
// unsupported call 拒绝执行。
func TestChatCompletionsResponseToResponses_NamespacedToolCallRestored(t *testing.T) {
resp := &ChatCompletionsResponse{
ID: "cc-1",
Choices: []ChatChoice{{
Message: ChatMessage{
Role: "assistant",
ToolCalls: []ChatToolCall{
{ID: "call_n", Function: ChatFunctionCall{Name: "mcp__svc__echo", Arguments: `{"text":"hi"}`}},
{ID: "call_9", Function: ChatFunctionCall{Name: "wait", Arguments: `{"cell_id": 3}`}},
},
},
}},
}
nsTools := map[string]NamespacedToolName{
"mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"},
}
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nsTools)
require.Len(t, out.Output, 2)
item := out.Output[0]
assert.Equal(t, "function_call", item.Type)
assert.Equal(t, "echo", item.Name)
assert.Equal(t, "mcp__svc", item.Namespace)
assert.Equal(t, "call_n", item.CallID)
assert.Equal(t, `{"text":"hi"}`, item.Arguments)
// 非流式响应体走 ResponsesOutput.MarshalJSONnamespace 必须落到线上 JSON。
b, err := json.Marshal(item)
require.NoError(t, err)
assert.Contains(t, string(b), `"namespace":"mcp__svc"`)
assert.Contains(t, string(b), `"name":"echo"`)
// 未命中映射的普通 function 调用不受影响,且不携带 namespace 字段。
assert.Equal(t, "wait", out.Output[1].Name)
assert.Empty(t, out.Output[1].Namespace)
b2, err := json.Marshal(out.Output[1])
require.NoError(t, err)
assert.NotContains(t, string(b2), `"namespace"`)
}
func TestChatCompletionsChunkToResponsesEvents_NamespacedToolCallStream(t *testing.T) {
state := NewChatCompletionsToResponsesStreamState("glm-5.2")
state.NamespaceTools = map[string]NamespacedToolName{
"mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"},
}
idx := 0
chunk := &ChatCompletionsChunk{
ID: "cc-1",
Choices: []ChatChunkChoice{{
Delta: ChatDelta{
ToolCalls: []ChatToolCall{{
Index: &idx,
ID: "call_n",
Function: ChatFunctionCall{Name: "mcp__svc__echo", Arguments: `{"text":"hi"}`},
}},
},
}},
}
events := ChatCompletionsChunkToResponsesEvents(chunk, state)
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
var added, itemDone *ResponsesStreamEvent
for i := range events {
evt := &events[i]
switch evt.Type {
case "response.output_item.added":
if evt.Item != nil && evt.Item.Type != "message" && evt.Item.Type != "reasoning" {
added = evt
}
case "response.output_item.done":
if evt.Item != nil && evt.Item.Type == "function_call" {
itemDone = evt
}
case "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done":
t.Fatalf("namespace 子工具调用不应产出 custom 事件: %s", evt.Type)
}
}
require.NotNil(t, added, "缺少 namespace 调用的 output_item.added")
assert.Equal(t, "function_call", added.Item.Type)
assert.Equal(t, "echo", added.Item.Name)
assert.Equal(t, "mcp__svc", added.Item.Namespace)
require.NotNil(t, itemDone, "缺少 namespace 调用的 output_item.done")
assert.Equal(t, "call_n", itemDone.Item.CallID)
assert.Equal(t, "echo", itemDone.Item.Name)
assert.Equal(t, "mcp__svc", itemDone.Item.Namespace)
assert.Equal(t, `{"text":"hi"}`, itemDone.Item.Arguments)
// SSE 线上形态经 responsesItemWire 白名单重组,必须单独断言 namespace 落线。
sse, err := ResponsesEventToSSE(*itemDone)
require.NoError(t, err)
assert.Contains(t, sse, `"namespace":"mcp__svc"`)
assert.Contains(t, sse, `"name":"echo"`)
assert.Contains(t, sse, `"call_id":"call_n"`)
// response.completed 的 output 数组同样携带还原后的 namespace 调用项。
final := events[len(events)-1]
require.Equal(t, "response.completed", final.Type)
require.NotNil(t, final.Response)
found := false
for _, item := range final.Response.Output {
if item.Type == "function_call" {
found = true
assert.Equal(t, "echo", item.Name)
assert.Equal(t, "mcp__svc", item.Namespace)
}
}
assert.True(t, found, "response.completed 缺少还原后的 namespace 调用项")
}
func TestChatCompletionsChunkToResponsesEvents_NamespacedToolNameArrivesLate(t *testing.T) {
state := NewChatCompletionsToResponsesStreamState("glm-5.2")
state.NamespaceTools = map[string]NamespacedToolName{
"mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"},
}
idx := 0
chunk1 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
ToolCalls: []ChatToolCall{{Index: &idx, ID: "call_n", Function: ChatFunctionCall{Arguments: `{"te`}}},
}}}}
chunk2 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
ToolCalls: []ChatToolCall{{Index: &idx, Function: ChatFunctionCall{Name: "mcp__svc__echo", Arguments: `xt":"hi"}`}}},
}}}}
var events []ResponsesStreamEvent
events = append(events, ChatCompletionsChunkToResponsesEvents(chunk1, state)...)
events = append(events, ChatCompletionsChunkToResponsesEvents(chunk2, state)...)
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
addedCount := 0
deltas := ""
for _, evt := range events {
switch evt.Type {
case "response.output_item.added":
if evt.Item != nil && evt.Item.Type != "reasoning" && evt.Item.Type != "message" {
addedCount++
assert.Equal(t, "echo", evt.Item.Name, "迟到的名字命中 namespace 映射时按还原名宣告")
assert.Equal(t, "mcp__svc", evt.Item.Namespace)
}
case "response.function_call_arguments.delta":
deltas += evt.Delta
}
}
assert.Equal(t, 1, addedCount, "工具调用只宣告一次")
assert.Equal(t, `{"text":"hi"}`, deltas, "宣告前累积的参数需在宣告时补发")
}
func TestChatCompletionsChunkToResponsesEvents_FunctionToolStreamUnaffected(t *testing.T) {
state := NewChatCompletionsToResponsesStreamState("glm-5.2")
state.CustomTools = map[string]bool{"exec": true}
idx := 0
chunk := &ChatCompletionsChunk{
Choices: []ChatChunkChoice{{
Delta: ChatDelta{
ToolCalls: []ChatToolCall{{
Index: &idx,
ID: "call_9",
Function: ChatFunctionCall{Name: "wait", Arguments: `{"cell_id": 3}`},
}},
},
}},
}
events := ChatCompletionsChunkToResponsesEvents(chunk, state)
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
sawArgsDelta := false
for _, evt := range events {
if evt.Type == "response.function_call_arguments.delta" {
sawArgsDelta = true
}
if evt.Type == "response.custom_tool_call_input.done" {
t.Fatal("function 工具不应产出 custom_tool_call 事件")
}
}
assert.True(t, sawArgsDelta, "function 工具应保持原有参数增量事件")
}
@@ -0,0 +1,156 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponsesInputToChatMessages_DeveloperRoleMapsToSystem(t *testing.T) {
messages, err := responsesInputToChatMessages("", json.RawMessage(`[{"role":"developer","content":"follow project instructions"}]`))
require.NoError(t, err)
require.Len(t, messages, 1)
assert.Equal(t, "system", messages[0].Role)
assert.JSONEq(t, `"follow project instructions"`, string(messages[0].Content))
}
func TestResponsesInputToChatMessages_KeepsChatCompletionRoles(t *testing.T) {
input := json.RawMessage(`[
{"role":"system","content":"system message"},
{"role":"user","content":"user message"},
{"role":"assistant","content":"assistant message"},
{"role":"tool","content":"tool message"}
]`)
messages, err := responsesInputToChatMessages("", input)
require.NoError(t, err)
require.Len(t, messages, 4)
assert.Equal(t, []string{"system", "user", "assistant", "tool"}, chatMessageRoles(messages))
}
func TestResponsesInputToChatMessages_EmptyRoleFallsBackToUser(t *testing.T) {
messages, err := responsesInputToChatMessages("", json.RawMessage(`[{"role":"","content":"hello"}]`))
require.NoError(t, err)
require.Len(t, messages, 1)
assert.Equal(t, "user", messages[0].Role)
}
func TestResponsesInputToChatMessages_DeveloperRoleTrimAndCaseInsensitive(t *testing.T) {
input := json.RawMessage(`[
{"role":" Developer ","content":"one"},
{"role":"\tDEVELOPER\n","content":"two"}
]`)
messages, err := responsesInputToChatMessages("", input)
require.NoError(t, err)
require.Len(t, messages, 2)
assert.Equal(t, []string{"system", "system"}, chatMessageRoles(messages))
}
func TestResponsesToChatCompletionsRequest_InstructionsAndInputDeveloperRole(t *testing.T) {
req := &ResponsesRequest{
Model: "gpt-4o",
Instructions: "Use concise answers.",
Input: json.RawMessage(`[
{"role":"developer","content":[{"type":"input_text","text":"Prefer JSON."}]},
{"role":"user","content":"Hello"}
]`),
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, out.Messages, 3)
assert.Equal(t, []string{"system", "system", "user"}, chatMessageRoles(out.Messages))
assert.JSONEq(t, `"Use concise answers."`, string(out.Messages[0].Content))
assert.JSONEq(t, `"Prefer JSON."`, string(out.Messages[1].Content))
assert.JSONEq(t, `"Hello"`, string(out.Messages[2].Content))
}
func TestResponsesToChatCompletionsRequest_TextFormatJsonObject(t *testing.T) {
req := &ResponsesRequest{
Model: "gpt-4o",
Input: json.RawMessage(`[
{"role":"user","content":"Return JSON"}
]`),
Text: &ResponsesText{
Format: json.RawMessage(`{"type":"json_object"}`),
},
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
assert.JSONEq(t, `{"type":"json_object"}`, string(out.ResponseFormat))
}
func TestResponsesToChatCompletionsRequest_TextFormatJsonSchema(t *testing.T) {
req := &ResponsesRequest{
Model: "gpt-4o",
Input: json.RawMessage(`[
{"role":"user","content":"Return structured JSON"}
]`),
Text: &ResponsesText{
Format: json.RawMessage(`{
"type":"json_schema",
"name":"answer",
"schema":{
"type":"object",
"properties":{"ok":{"type":"boolean"}},
"required":["ok"],
"additionalProperties":false
},
"strict":true
}`),
},
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
assert.JSONEq(t, `{
"type":"json_schema",
"json_schema":{
"name":"answer",
"schema":{
"type":"object",
"properties":{"ok":{"type":"boolean"}},
"required":["ok"],
"additionalProperties":false
},
"strict":true
}
}`, string(out.ResponseFormat))
}
func TestResponsesToChatCompletionsRequest_ParallelToolCalls(t *testing.T) {
parallel := false
req := &ResponsesRequest{
Model: "gpt-4o",
Input: json.RawMessage(`[
{"role":"user","content":"Use tools"}
]`),
ParallelToolCalls: &parallel,
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.NotNil(t, out.ParallelToolCalls)
assert.False(t, *out.ParallelToolCalls)
payload, err := json.Marshal(out)
require.NoError(t, err)
assert.Contains(t, string(payload), `"parallel_tool_calls":false`)
}
func chatMessageRoles(messages []ChatMessage) []string {
roles := make([]string, 0, len(messages))
for _, message := range messages {
roles = append(roles, message.Role)
}
return roles
}
@@ -0,0 +1,161 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// Encrypted-only reasoning items (empty summary + opaque encrypted_content,
// e.g. after codex remote compaction) carry no plaintext the bridge can map to
// reasoning_content. The gateway-side cache keyed by reasoning item id restores
// it; without the restore, DeepSeek thinking mode rejects the history with 400
// "The `reasoning_content` in the thinking mode must be passed back to the API".
func TestResponsesToChat_ReasoningCacheLookup_RestoresEncryptedOnlyItem(t *testing.T) {
req := &ResponsesRequest{
Model: "deepseek-reasoner",
Input: json.RawMessage(`[
{"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"},
{"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
{"type":"function_call_output","call_id":"call_1","output":"ok"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
]`),
}
out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{
ReasoningContentByID: func(itemID string) string {
if itemID == "item_enc1" {
return "cached thinking"
}
return ""
},
})
require.NoError(t, err)
require.Len(t, out.Messages, 3)
require.Equal(t, "assistant", out.Messages[0].Role)
require.Equal(t, "cached thinking", out.Messages[0].ReasoningContent)
require.Len(t, out.Messages[0].ToolCalls, 1)
require.Equal(t, "call_1", out.Messages[0].ToolCalls[0].ID)
require.Equal(t, "tool", out.Messages[1].Role)
require.Equal(t, "user", out.Messages[2].Role)
}
// A cache miss keeps the original behavior: no reasoning_content, no error.
func TestResponsesToChat_ReasoningCacheLookup_MissKeepsOriginalBehavior(t *testing.T) {
req := &ResponsesRequest{
Model: "deepseek-reasoner",
Input: json.RawMessage(`[
{"type":"reasoning","id":"item_unknown","summary":[],"encrypted_content":"opaque"},
{"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
{"type":"function_call_output","call_id":"call_1","output":"ok"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
]`),
}
out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{
ReasoningContentByID: func(string) string { return "" },
})
require.NoError(t, err)
require.Len(t, out.Messages, 3)
require.Empty(t, out.Messages[0].ReasoningContent)
// Nil options (legacy path) behaves identically.
legacy, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Equal(t, out.Messages, legacy.Messages)
}
// Plaintext summary wins and the cache lookup is not consulted.
func TestResponsesToChat_ReasoningCacheLookup_PlaintextPreferred(t *testing.T) {
req := &ResponsesRequest{
Model: "deepseek-reasoner",
Input: json.RawMessage(`[
{"type":"reasoning","id":"item_plain","summary":[{"type":"summary_text","text":"plain thinking"}]},
{"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
{"type":"function_call_output","call_id":"call_1","output":"ok"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
]`),
}
lookupCalled := false
out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{
ReasoningContentByID: func(string) string {
lookupCalled = true
return "cached thinking"
},
})
require.NoError(t, err)
require.Len(t, out.Messages, 3)
require.Equal(t, "plain thinking", out.Messages[0].ReasoningContent)
require.False(t, lookupCalled, "plaintext summary present → cache lookup must not run")
}
// DeepSeek emits reasoning only once per turn; chained tool calls
// (reasoning → call A → output A → call B) have no reasoning item before call
// B. The turn's reasoning must be replayed on B's assistant message, otherwise
// DeepSeek thinking mode 400s the history ("reasoning_content ... must be
// passed back"). Reproduced from a real codex 0.147.0 resume history.
func TestResponsesToChat_ChainedToolCallsReplayTurnReasoning(t *testing.T) {
req := &ResponsesRequest{
Model: "deepseek-reasoner",
Input: json.RawMessage(`[
{"type":"reasoning","id":"item_r1","summary":[{"type":"summary_text","text":"turn thinking"}]},
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"\n\n"}]},
{"type":"function_call","call_id":"call_a","name":"exec_command","arguments":"{}"},
{"type":"function_call_output","call_id":"call_a","output":"ok"},
{"type":"function_call","call_id":"call_b","name":"exec_command","arguments":"{}"},
{"type":"function_call_output","call_id":"call_b","output":"ok"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]},
{"type":"reasoning","id":"item_r2","summary":[{"type":"summary_text","text":"second turn"}]},
{"type":"function_call","call_id":"call_c","name":"exec_command","arguments":"{}"},
{"type":"function_call_output","call_id":"call_c","output":"ok"}
]`),
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
byCallID := map[string]ChatMessage{}
for _, m := range out.Messages {
for _, tc := range m.ToolCalls {
byCallID[tc.ID] = m
}
}
require.Len(t, byCallID, 3)
require.Equal(t, "turn thinking", byCallID["call_a"].ReasoningContent)
require.Equal(t, "turn thinking", byCallID["call_b"].ReasoningContent,
"链式第二个工具调用必须回放本轮 reasoning")
require.Equal(t, "second turn", byCallID["call_c"].ReasoningContent,
"user 消息后开启新轮次,不得沿用上一轮 reasoning")
// 每一条 assistant 消息都必须带 reasoning_contentDeepSeek 契约)。
for i, m := range out.Messages {
if m.Role == "assistant" {
require.NotEmpty(t, m.ReasoningContent, "messages[%d] 缺 reasoning_content", i)
}
}
}
func TestExtractResponsesReasoningItem(t *testing.T) {
id, text, ok := ExtractResponsesReasoningItem(json.RawMessage(
`{"type":"reasoning","id":"item_a","summary":[{"type":"summary_text","text":"think"}]}`))
require.True(t, ok)
require.Equal(t, "item_a", id)
require.Equal(t, "think", text)
// Encrypted-only item: ok with id but empty text.
id, text, ok = ExtractResponsesReasoningItem(json.RawMessage(
`{"type":"reasoning","id":"item_b","summary":[],"encrypted_content":"opaque"}`))
require.True(t, ok)
require.Equal(t, "item_b", id)
require.Empty(t, text)
// Non-reasoning items are skipped.
_, _, ok = ExtractResponsesReasoningItem(json.RawMessage(
`{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}`))
require.False(t, ok)
_, _, ok = ExtractResponsesReasoningItem(json.RawMessage(`"bare string"`))
require.False(t, ok)
}
@@ -0,0 +1,187 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// assertChatInvariants enforces the DeepSeek / OpenAI Chat Completions message
// invariants that, when violated, surface as upstream 400s. Used to validate the
// request-direction converter against golden codex request shapes.
func assertChatInvariants(t *testing.T, messages []ChatMessage) {
t.Helper()
for i, m := range messages {
// Every assistant tool_calls message must be immediately followed by one
// tool message per tool_call_id, in order.
if len(m.ToolCalls) > 0 {
for j, tc := range m.ToolCalls {
k := i + 1 + j
require.Lessf(t, k, len(messages), "tool_call %s has no following tool message", tc.ID)
require.Equalf(t, "tool", messages[k].Role, "tool_call %s not followed by a tool message", tc.ID)
require.Equalf(t, tc.ID, messages[k].ToolCallID, "tool reply order mismatch for %s", tc.ID)
}
}
// No two consecutive assistant messages.
if i > 0 && m.Role == "assistant" && messages[i-1].Role == "assistant" {
t.Fatalf("consecutive assistant messages at %d", i)
}
// No orphan tool replies.
if m.Role == "tool" {
require.NotEmptyf(t, m.ToolCallID, "tool message without tool_call_id at %d", i)
}
}
}
func convertGolden(t *testing.T, input string) []ChatMessage {
t.Helper()
msgs, err := responsesInputToChatMessages("You are a helpful assistant.", json.RawMessage(input))
require.NoError(t, err)
return msgs
}
// Golden sample: a single tool-call turn (codex runs one shell/curl command),
// the shape that produced the original "no response" / 400.
func TestGolden_SingleToolCall(t *testing.T) {
msgs := convertGolden(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"latest sha?"}]},
{"type":"reasoning","summary":[{"type":"summary_text","text":"need to run curl"}]},
{"type":"function_call","call_id":"call_a","name":"exec_command","arguments":"{\"cmd\":\"curl x\"}"},
{"type":"function_call_output","call_id":"call_a","output":"deadbeef"}
]`)
assertChatInvariants(t, msgs)
// reasoning_content must ride on the assistant tool-call message.
var asst *ChatMessage
for i := range msgs {
if len(msgs[i].ToolCalls) > 0 {
asst = &msgs[i]
}
}
require.NotNil(t, asst)
require.Equal(t, "need to run curl", asst.ReasoningContent)
}
// Golden sample: parallel tool calls (codex runs git log + git tag at once).
func TestGolden_ParallelToolCalls(t *testing.T) {
msgs := convertGolden(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"features?"}]},
{"type":"reasoning","summary":[{"type":"summary_text","text":"inspect repo"}]},
{"type":"function_call","call_id":"c0","name":"exec_command","arguments":"{\"cmd\":\"git log\"}"},
{"type":"function_call","call_id":"c1","name":"exec_command","arguments":"{\"cmd\":\"git tag\"}"},
{"type":"function_call_output","call_id":"c0","output":"log"},
{"type":"function_call_output","call_id":"c1","output":"tags"}
]`)
assertChatInvariants(t, msgs)
// Both parallel calls share ONE assistant message.
var toolMsgs int
for _, m := range msgs {
if len(m.ToolCalls) == 2 {
require.Equal(t, "c0", m.ToolCalls[0].ID)
require.Equal(t, "c1", m.ToolCalls[1].ID)
}
if m.Role == "tool" {
toolMsgs++
}
}
require.Equal(t, 2, toolMsgs)
}
// Golden sample: an unknown item type (web_search_call from a 联网查询) sitting
// between a function_call and its output must not break tool↔reply adjacency.
func TestGolden_UnknownItemBetweenToolCallAndOutput(t *testing.T) {
msgs := convertGolden(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"search"}]},
{"type":"reasoning","summary":[{"type":"summary_text","text":"let me search"}]},
{"type":"function_call","call_id":"c0","name":"exec_command","arguments":"{}"},
{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"x"}},
{"type":"function_call_output","call_id":"c0","output":"result"}
]`)
assertChatInvariants(t, msgs)
}
// Sequential tool calls (a tool reply between two calls) must stay in distinct
// assistant messages.
func TestRequest_SequentialToolCallsStaySeparate(t *testing.T) {
msgs := convertGolden(t, `[
{"type":"function_call","call_id":"c1","name":"exec","arguments":"{}"},
{"type":"function_call_output","call_id":"c1","output":"r1"},
{"type":"function_call","call_id":"c2","name":"exec","arguments":"{}"},
{"type":"function_call_output","call_id":"c2","output":"r2"}
]`)
assertChatInvariants(t, msgs)
assistants := 0
for _, m := range msgs {
if len(m.ToolCalls) == 1 {
assistants++
}
}
require.Equal(t, 2, assistants)
}
// Golden sample: codex injects a message (e.g. an "Approved command prefix
// saved" notice) between a function_call and its output. The intervening message
// must be moved after the tool reply so the assistant tool_calls is immediately
// followed by its reply.
func TestGolden_MessageBetweenToolCallAndOutput(t *testing.T) {
msgs := convertGolden(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"do it"}]},
{"type":"reasoning","summary":[{"type":"summary_text","text":"run cmd"}]},
{"type":"function_call","call_id":"A","name":"exec","arguments":"{}"},
{"type":"message","role":"developer","content":[{"type":"input_text","text":"Approved command prefix saved"}]},
{"type":"function_call_output","call_id":"A","output":"ok"}
]`)
assertChatInvariants(t, msgs)
// The assistant tool_calls message is immediately followed by its tool reply.
for i, m := range msgs {
if len(m.ToolCalls) > 0 {
require.Equal(t, "tool", msgs[i+1].Role)
require.Equal(t, "A", msgs[i+1].ToolCallID)
}
}
}
// Golden sample: a parallel tool call where one sibling's output is missing
// (codex interrupted/reconnected mid-execution). The unanswered tool_call must
// be dropped so the remaining assistant tool_calls are all answered.
func TestGolden_PartialParallelDropsUnansweredCall(t *testing.T) {
msgs := convertGolden(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
{"type":"reasoning","summary":[{"type":"summary_text","text":"r"}]},
{"type":"function_call","call_id":"A","name":"exec","arguments":"{}"},
{"type":"function_call","call_id":"B","name":"exec","arguments":"{}"},
{"type":"function_call_output","call_id":"A","output":"oa"}
]`)
assertChatInvariants(t, msgs)
for _, m := range msgs {
for _, tc := range m.ToolCalls {
require.NotEqual(t, "B", tc.ID, "unanswered tool_call B should have been dropped")
}
}
}
// Golden sample: a dangling tool_call at the end of the history (no output yet).
// The assistant message holding only that call must be dropped entirely.
func TestGolden_DanglingToolCallDropped(t *testing.T) {
msgs := convertGolden(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
{"type":"reasoning","summary":[{"type":"summary_text","text":"r"}]},
{"type":"function_call","call_id":"A","name":"exec","arguments":"{}"}
]`)
assertChatInvariants(t, msgs)
for _, m := range msgs {
require.Empty(t, m.ToolCalls, "dangling unanswered tool_call should have been dropped")
}
}
// normalizeChatMessages drops an orphan tool reply whose tool_call was never
// announced.
func TestNormalize_DropsOrphanToolReply(t *testing.T) {
msgs := convertGolden(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
{"type":"function_call_output","call_id":"ghost","output":"orphan"}
]`)
for _, m := range msgs {
require.NotEqualf(t, "tool", m.Role, "orphan tool reply should have been dropped")
}
}
@@ -0,0 +1,238 @@
package apicompat
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func collectStreamEvents(t *testing.T, chunks []string) []ResponsesStreamEvent {
t.Helper()
state := NewChatCompletionsToResponsesStreamState("deepseek-v4-pro")
var events []ResponsesStreamEvent
for _, payload := range chunks {
var chunk ChatCompletionsChunk
require.NoError(t, json.Unmarshal([]byte(payload), &chunk))
events = append(events, ChatCompletionsChunkToResponsesEvents(&chunk, state)...)
}
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
return events
}
// TestStream_ReasoningOpensItemBeforeDelta guards the bug where a strict client
// (Codex) drops reasoning deltas that reference an item not yet opened.
func TestStream_ReasoningOpensItemBeforeDelta(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}`,
`{"choices":[{"index":0,"delta":{"reasoning_content":"think"}}]}`,
`{"choices":[{"index":0,"delta":{"content":"hello"}}]}`,
`{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
})
open := map[int]string{} // output_index -> item type
for _, e := range events {
switch e.Type {
case "response.output_item.added":
require.NotNil(t, e.Item)
open[e.OutputIndex] = e.Item.Type
case "response.reasoning_summary_text.delta":
require.Equalf(t, "reasoning", open[e.OutputIndex], "reasoning delta before its item was opened")
case "response.output_text.delta":
require.Equalf(t, "message", open[e.OutputIndex], "text delta before its item was opened")
}
}
}
func TestStream_ReasoningOnlySynthesizesVisibleText(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}`,
`{"choices":[{"index":0,"delta":{"reasoning_content":"thinking before final"}}]}`,
`{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"length"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
})
open := map[int]string{}
var sawTextDelta, sawTextDone, sawMessageDone bool
for _, e := range events {
switch e.Type {
case "response.output_item.added":
require.NotNil(t, e.Item)
open[e.OutputIndex] = e.Item.Type
case "response.output_text.delta":
sawTextDelta = true
require.Equalf(t, "message", open[e.OutputIndex], "fallback text delta before its item was opened")
require.Equal(t, "thinking before final", e.Delta)
case "response.output_text.done":
sawTextDone = true
require.Equal(t, "thinking before final", e.Text)
case "response.output_item.done":
if e.Item != nil && e.Item.Type == "message" {
sawMessageDone = true
require.Equal(t, "thinking before final", e.Item.Content[0].Text)
}
case "response.completed":
require.NotNil(t, e.Response)
require.Equal(t, "incomplete", e.Response.Status)
require.NotNil(t, e.Response.IncompleteDetails)
require.Equal(t, "max_output_tokens", e.Response.IncompleteDetails.Reason)
require.Len(t, e.Response.Output, 2)
require.Equal(t, "reasoning", e.Response.Output[0].Type)
require.Equal(t, "message", e.Response.Output[1].Type)
require.Equal(t, "thinking before final", e.Response.Output[1].Content[0].Text)
}
}
require.True(t, sawTextDelta, "reasoning-only stream must produce visible text delta")
require.True(t, sawTextDone, "reasoning-only stream must close visible text part")
require.True(t, sawMessageDone, "reasoning-only stream must close synthesized message item")
}
func TestStream_ReasoningOnlyBlankDoesNotSynthesizeVisibleText(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"reasoning_content":" "}}]}`,
`{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
})
for _, e := range events {
require.NotEqual(t, "response.output_text.delta", e.Type)
if e.Type == "response.completed" {
require.NotNil(t, e.Response)
require.Len(t, e.Response.Output, 2)
require.Equal(t, "reasoning", e.Response.Output[0].Type)
require.Equal(t, "message", e.Response.Output[1].Type)
require.Equal(t, "", e.Response.Output[1].Content[0].Text)
}
}
}
func TestStream_ReasoningThenContentDoesNotDuplicateFallbackText(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"reasoning_content":"private plan"}}]}`,
`{"choices":[{"index":0,"delta":{"content":"final answer"}}]}`,
`{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
})
var textDeltas []string
for _, e := range events {
switch e.Type {
case "response.output_text.delta":
textDeltas = append(textDeltas, e.Delta)
case "response.completed":
require.NotNil(t, e.Response)
require.Len(t, e.Response.Output, 2)
require.Equal(t, "private plan", e.Response.Output[0].Summary[0].Text)
require.Equal(t, "final answer", e.Response.Output[1].Content[0].Text)
}
}
require.Equal(t, []string{"final answer"}, textDeltas)
}
func TestStream_ReasoningThenToolCallDoesNotSynthesizeVisibleText(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"reasoning_content":"call a tool"}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{}"}}]}}]}`,
`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
})
for _, e := range events {
require.NotEqual(t, "response.output_text.delta", e.Type)
if e.Type == "response.completed" {
require.NotNil(t, e.Response)
require.Len(t, e.Response.Output, 2)
require.Equal(t, "reasoning", e.Response.Output[0].Type)
require.Equal(t, "function_call", e.Response.Output[1].Type)
}
}
}
// TestStream_ToolCallLifecycleComplete guards that a tool call is fully closed
// (function_call_arguments.done + output_item.done with full arguments), which
// codex needs to execute the call.
func TestStream_ToolCallLifecycleComplete(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"plan"}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":""}}]}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"cmd\":\"ls\"}"}}]}}]}`,
`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
})
var sawAdded, sawArgsDone, sawItemDone bool
for _, e := range events {
switch e.Type {
case "response.output_item.added":
if e.Item != nil && e.Item.Type == "function_call" {
sawAdded = true
}
case "response.function_call_arguments.done":
sawArgsDone = true
require.Equal(t, `{"cmd":"ls"}`, e.Arguments)
case "response.output_item.done":
if e.Item != nil && e.Item.Type == "function_call" {
sawItemDone = true
require.Equal(t, `{"cmd":"ls"}`, e.Item.Arguments)
require.Equal(t, "call_a", e.Item.CallID)
}
}
}
require.True(t, sawAdded, "function_call output_item.added missing")
require.True(t, sawArgsDone, "function_call_arguments.done missing")
require.True(t, sawItemDone, "function_call output_item.done missing")
}
// TestStream_ToolCallArgumentsInFirstChunkNotDoubled guards the GLM/Zhipu shape
// where a single tool_call delta chunk carries id+name+arguments together.
// Earlier code copied the whole tool_call (including arguments) into state and
// then accumulated the same chunk's arguments again, producing a doubled,
// invalid JSON like {"cmd":"ls"}{"cmd":"ls"} that breaks Codex tool parsing
// ("trailing characters").
func TestStream_ToolCallArgumentsInFirstChunkNotDoubled(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"role":"assistant"}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{\"cmd\":\"ls\"}"}}]}}]}`,
`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
})
var argsDelta strings.Builder
var sawArgsDone, sawItemDone bool
for _, e := range events {
switch e.Type {
case "response.function_call_arguments.delta":
_, _ = argsDelta.WriteString(e.Delta)
case "response.function_call_arguments.done":
sawArgsDone = true
require.Equal(t, `{"cmd":"ls"}`, e.Arguments)
case "response.output_item.done":
if e.Item != nil && e.Item.Type == "function_call" {
sawItemDone = true
require.Equal(t, `{"cmd":"ls"}`, e.Item.Arguments)
}
}
}
require.True(t, sawArgsDone, "function_call_arguments.done missing")
require.True(t, sawItemDone, "function_call output_item.done missing")
// Accumulated deltas must equal the final arguments exactly (no duplication).
require.Equal(t, `{"cmd":"ls"}`, argsDelta.String())
}
// TestStream_SSEWireComplete drives the full stream through SSE encoding and
// asserts the function_call events carry complete fields on the wire.
func TestStream_SSEWireComplete(t *testing.T) {
events := collectStreamEvents(t, []string{
`{"choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"plan"}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{}"}}]}}]}`,
`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
})
var addedLine string
for _, e := range events {
sse, err := ResponsesEventToSSE(e)
require.NoError(t, err)
if e.Type == "response.output_item.added" && e.Item != nil && e.Item.Type == "function_call" {
addedLine = sse
}
}
require.NotEmpty(t, addedLine)
// The function_call added event must carry arguments:"" on the wire.
require.True(t, strings.Contains(addedLine, `"arguments":""`), "added line missing arguments: %s", addedLine)
require.Contains(t, addedLine, `"call_id":"call_a"`)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,318 @@
package apicompat
import (
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
const (
testToolImageDataURL = "data:image/png;base64,AQID"
testToolImageRemote = "https://example.com/tool-output.png"
)
func TestResponsesToolOutputMedia_ExtractsSupportedShapes(t *testing.T) {
tests := []struct {
name string
call string
outputType string
output string
imageURL string
toolText string
}{
{
name: "image-only array",
call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
outputType: "function_call_output",
output: `[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]`,
imageURL: testToolImageDataURL,
},
{
name: "text and nested image URL",
call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
outputType: "function_call_output",
output: `[{"type":"input_text","text":"render complete"},{"type":"image_url","image_url":{"url":"https://example.com/tool-output.png"}}]`,
imageURL: testToolImageRemote,
toolText: "render complete",
},
{
name: "top-level image object",
call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
outputType: "function_call_output",
output: `{"type":"input_image","image_url":"data:image/png;base64,AQID"}`,
imageURL: testToolImageDataURL,
},
{
name: "content wrapper",
call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
outputType: "function_call_output",
output: `{"status":"ok","content":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}],"unknown":{"score":0.9,"large":9007199254740993}}`,
imageURL: testToolImageDataURL,
toolText: `"large":9007199254740993`,
},
{
name: "JSON string output",
call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
outputType: "function_call_output",
output: `"[{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,AQID\"}]"`,
imageURL: testToolImageDataURL,
},
{
name: "bare data URL",
call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
outputType: "function_call_output",
output: `"data:image/png;base64,AQID"`,
imageURL: testToolImageDataURL,
},
{
name: "custom tool output",
call: `{"type":"custom_tool_call","call_id":"call_image","name":"view_image","input":"{}"}`,
outputType: "custom_tool_call_output",
output: `[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]`,
imageURL: testToolImageDataURL,
},
{
name: "tool search output",
call: `{"type":"tool_search_call","call_id":"call_image","arguments":{"query":"image"}}`,
outputType: "tool_search_output",
output: `[{"type":"image_url","image_url":{"url":"https://example.com/tool-output.png"}}]`,
imageURL: testToolImageRemote,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := fmt.Sprintf(`[%s,{"type":%q,"call_id":"call_image","output":%s}]`, tt.call, tt.outputType, tt.output)
messages := convertToolOutputMedia(t, input)
require.Len(t, messages, 3)
require.Equal(t, []string{"assistant", "tool", "user"}, chatMessageRoles(messages))
require.Equal(t, "call_image", messages[1].ToolCallID)
toolText := chatToolContentString(t, messages[1])
require.Contains(t, toolText, "[Tool output media moved to the following user message]")
require.NotContains(t, toolText, tt.imageURL)
if tt.toolText != "" {
require.Contains(t, toolText, tt.toolText)
}
parts := chatContentParts(t, messages[2])
require.Len(t, parts, 2)
require.Equal(t, "text", parts[0].Type)
require.Equal(t, "[Tool output media for call call_image]", parts[0].Text)
require.Equal(t, "image_url", parts[1].Type)
require.NotNil(t, parts[1].ImageURL)
require.Equal(t, tt.imageURL, parts[1].ImageURL.URL)
})
}
}
func TestResponsesToolOutputMedia_ParallelBatchUsesCallOrder(t *testing.T) {
messages := convertToolOutputMedia(t, `[
{"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
{"type":"function_call","call_id":"call_B","name":"view_image","arguments":"{}"},
{"type":"function_call_output","call_id":"call_B","output":[{"type":"input_image","image_url":{"url":"https://example.com/b.png"}}]},
{"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":{"url":"https://example.com/a.png"}}]}
]`)
require.Len(t, messages, 4)
require.Equal(t, []string{"assistant", "tool", "tool", "user"}, chatMessageRoles(messages))
require.Equal(t, []string{"call_A", "call_B"}, []string{messages[1].ToolCallID, messages[2].ToolCallID})
parts := chatContentParts(t, messages[3])
require.Len(t, parts, 4)
require.Equal(t, "[Tool output media for call call_A]", parts[0].Text)
require.Equal(t, "https://example.com/a.png", parts[1].ImageURL.URL)
require.Equal(t, "[Tool output media for call call_B]", parts[2].Text)
require.Equal(t, "https://example.com/b.png", parts[3].ImageURL.URL)
}
func TestResponsesToolOutputMedia_PreservesRichSiblingWhenRewriting(t *testing.T) {
messages := convertToolOutputMedia(t, `[
{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},
{"type":"function_call_output","call_id":"call_image","output":[
{"type":"result","url":"https://example.com/result","score":0.9,"text":"complete","extra":{"count":2}},
{"type":"input_image","image_url":"data:image/png;base64,AQID"}
]}
]`)
toolText := chatToolContentString(t, messages[1])
require.JSONEq(t, `[
{"type":"result","url":"https://example.com/result","score":0.9,"text":"complete","extra":{"count":2}},
{"type":"input_text","text":"[Tool output media moved to the following user message]"}
]`, toolText)
}
func TestResponsesToolOutputMedia_InterleavedMessagesFollowMediaBatch(t *testing.T) {
messages := convertToolOutputMedia(t, `[
{"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
{"type":"message","role":"developer","content":[{"type":"input_text","text":"approval saved"}]},
{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]},
{"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
]`)
require.Equal(t, []string{"assistant", "tool", "user", "system", "user"}, chatMessageRoles(messages))
require.Equal(t, "[Tool output media for call call_A]", chatContentParts(t, messages[2])[0].Text)
require.JSONEq(t, `"approval saved"`, string(messages[3].Content))
require.JSONEq(t, `"continue"`, string(messages[4].Content))
}
func TestResponsesToolOutputMedia_DropsOrphanAndUnansweredCallMedia(t *testing.T) {
t.Run("orphan", func(t *testing.T) {
messages := convertToolOutputMedia(t, `[
{"type":"function_call_output","call_id":"call_ghost","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
]`)
require.Empty(t, messages)
})
t.Run("unanswered parallel call", func(t *testing.T) {
messages := convertToolOutputMedia(t, `[
{"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
{"type":"function_call","call_id":"call_B","name":"view_image","arguments":"{}"},
{"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
]`)
require.Len(t, messages, 3)
require.Len(t, messages[0].ToolCalls, 1)
require.Equal(t, "call_A", messages[0].ToolCalls[0].ID)
parts := chatContentParts(t, messages[2])
require.Len(t, parts, 2)
require.NotContains(t, string(messages[2].Content), "call_B")
})
}
func TestResponsesToolOutputMedia_PreservesMediaFreeOutputBytes(t *testing.T) {
tests := []struct {
name string
output string
}{
{
name: "rich unknown object",
output: `{"type":"result","url":"https://example.com/result","score":0.9,"text":"complete","extra":{"count":2}}`,
},
{
name: "no-image array",
output: `[ { "type": "input_text", "text": "ok" }, {"unknown":true} ]`,
},
{
name: "plain string",
output: `"plain output"`,
},
{
name: "JSON string without image",
output: `"{ \"ok\": true }"`,
},
{
name: "embedded data URL text",
output: `"prefix data:image/png;base64,AQID suffix"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := fmt.Sprintf(`[
{"type":"function_call","call_id":"call_text","name":"exec","arguments":"{}"},
{"type":"function_call_output","call_id":"call_text","output":%s}
]`, tt.output)
messages := convertToolOutputMedia(t, input)
require.Len(t, messages, 2)
var expected string
if err := json.Unmarshal([]byte(tt.output), &expected); err != nil {
expected = tt.output
}
expectedContent, err := json.Marshal(expected)
require.NoError(t, err)
require.Equal(t, string(expectedContent), string(messages[1].Content))
})
}
}
func TestResponsesToolOutputMedia_DuplicateCallIDIsLastWins(t *testing.T) {
t.Run("later media replaces earlier media", func(t *testing.T) {
messages := convertToolOutputMedia(t, `[
{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},
{"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":{"url":"https://example.com/first.png"}}]},
{"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":{"url":"https://example.com/last.png"}}]}
]`)
require.Len(t, messages, 3)
require.NotContains(t, string(messages[1].Content), "first.png")
require.NotContains(t, string(messages[2].Content), "first.png")
require.Contains(t, string(messages[2].Content), "last.png")
})
t.Run("later text clears earlier media", func(t *testing.T) {
messages := convertToolOutputMedia(t, `[
{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},
{"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]},
{"type":"function_call_output","call_id":"call_image","output":"latest text"}
]`)
require.Len(t, messages, 2)
require.Equal(t, "latest text", chatToolContentString(t, messages[1]))
})
}
func TestResponsesToChatCompletionsRequest_ToolContentNeverContainsExtractedMedia(t *testing.T) {
req := &ResponsesRequest{
Model: "vision-model",
Input: json.RawMessage(`[
{"type":"function_call","call_id":"call_function","name":"view_image","arguments":"{}"},
{"type":"custom_tool_call","call_id":"call_custom","name":"custom_image","input":"{}"},
{"type":"tool_search_call","call_id":"call_search","arguments":{"query":"image"}},
{"type":"function_call_output","call_id":"call_function","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]},
{"type":"custom_tool_call_output","call_id":"call_custom","output":{"content":[{"type":"image_url","image_url":{"url":"https://example.com/custom.png"}}]}},
{"type":"tool_search_output","call_id":"call_search","output":"data:image/jpeg;base64,BAUG"}
]`),
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
assertChatInvariants(t, out.Messages)
var toolCount int
for _, message := range out.Messages {
if message.Role != "tool" {
continue
}
toolCount++
content := string(message.Content)
require.NotContains(t, content, "data:image/")
require.NotContains(t, content, "https://example.com/custom.png")
}
require.Equal(t, 3, toolCount)
require.Equal(t, []string{"assistant", "tool", "tool", "tool", "user"}, chatMessageRoles(out.Messages))
}
func convertToolOutputMedia(t *testing.T, input string) []ChatMessage {
t.Helper()
messages, err := responsesInputToChatMessages("", json.RawMessage(input))
require.NoError(t, err)
assertChatInvariants(t, messages)
return messages
}
func chatToolContentString(t *testing.T, message ChatMessage) string {
t.Helper()
require.Equal(t, "tool", message.Role)
var content string
require.NoError(t, json.Unmarshal(message.Content, &content))
return content
}
func chatContentParts(t *testing.T, message ChatMessage) []ChatContentPart {
t.Helper()
var parts []ChatContentPart
require.NoError(t, json.Unmarshal(message.Content, &parts))
for _, part := range parts {
if part.Type == "image_url" {
require.NotNil(t, part.ImageURL)
require.False(t, strings.TrimSpace(part.ImageURL.URL) == "")
}
}
return parts
}
@@ -0,0 +1,494 @@
package apicompat
import (
"encoding/json"
"fmt"
"strings"
)
type chatMessageContent struct {
Text *string
Parts []ChatContentPart
}
// ChatCompletionsToResponses converts a Chat Completions request into a
// Responses API request. The upstream always streams, so Stream is forced to
// true. store is always false and reasoning.encrypted_content is always
// included so that the response translator has full context.
func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, error) {
input, err := convertChatMessagesToResponsesInput(req.Messages)
if err != nil {
return nil, err
}
inputJSON, err := json.Marshal(input)
if err != nil {
return nil, err
}
out := &ResponsesRequest{
Model: req.Model,
Instructions: req.Instructions,
Input: inputJSON,
Stream: true, // upstream always streams
Include: []string{"reasoning.encrypted_content"},
ServiceTier: req.ServiceTier,
ParallelToolCalls: req.ParallelToolCalls,
}
// Reasoning models (gpt-5.x) do not accept sampling parameters.
// See isReasoningModel in anthropic_to_responses.go.
if !isReasoningModel(req.Model) {
out.Temperature = req.Temperature
out.TopP = req.TopP
}
storeFalse := false
out.Store = &storeFalse
// max_tokens / max_completion_tokens → max_output_tokens, prefer max_completion_tokens
maxTokens := 0
if req.MaxTokens != nil {
maxTokens = *req.MaxTokens
}
if req.MaxCompletionTokens != nil {
maxTokens = *req.MaxCompletionTokens
}
if maxTokens > 0 {
v := maxTokens
if v < minMaxOutputTokens {
v = minMaxOutputTokens
}
out.MaxOutputTokens = &v
}
// reasoning_effort → reasoning.effort + reasoning.summary="auto"
if req.ReasoningEffort != "" {
out.Reasoning = &ResponsesReasoning{
Effort: req.ReasoningEffort,
Summary: "auto",
}
}
if format := chatResponseFormatToResponsesTextFormat(req.ResponseFormat); len(format) > 0 {
if out.Text == nil {
out.Text = &ResponsesText{}
}
out.Text.Format = format
}
// tools[] and legacy functions[] → ResponsesTool[]
if len(req.Tools) > 0 || len(req.Functions) > 0 {
out.Tools = convertChatToolsToResponses(req.Tools, req.Functions)
}
// tool_choice: already compatible format — pass through directly.
// Legacy function_call needs mapping.
if len(req.ToolChoice) > 0 {
out.ToolChoice = req.ToolChoice
} else if len(req.FunctionCall) > 0 {
tc, err := convertChatFunctionCallToToolChoice(req.FunctionCall)
if err != nil {
return nil, fmt.Errorf("convert function_call: %w", err)
}
out.ToolChoice = tc
}
return out, nil
}
// convertChatMessagesToResponsesInput converts the Chat Completions messages
// array into a Responses API input items array.
func convertChatMessagesToResponsesInput(msgs []ChatMessage) ([]ResponsesInputItem, error) {
var out []ResponsesInputItem
for _, m := range msgs {
items, err := chatMessageToResponsesItems(m)
if err != nil {
return nil, err
}
out = append(out, items...)
}
return out, nil
}
// chatMessageToResponsesItems converts a single ChatMessage into one or more
// ResponsesInputItem values.
func chatMessageToResponsesItems(m ChatMessage) ([]ResponsesInputItem, error) {
switch m.Role {
case "system":
return chatSystemToResponses(m)
case "user":
return chatUserToResponses(m)
case "assistant":
return chatAssistantToResponses(m)
case "tool":
return chatToolToResponses(m)
case "function":
return chatFunctionToResponses(m)
default:
return chatUserToResponses(m)
}
}
// chatSystemToResponses converts a system message.
func chatSystemToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
parsed, err := parseChatMessageContent(m.Content)
if err != nil {
return nil, err
}
content, err := marshalChatInputContent(parsed)
if err != nil {
return nil, err
}
return []ResponsesInputItem{{Role: "system", Content: content}}, nil
}
// chatUserToResponses converts a user message, handling both plain strings and
// multi-modal content arrays.
func chatUserToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
parsed, err := parseChatMessageContent(m.Content)
if err != nil {
return nil, fmt.Errorf("parse user content: %w", err)
}
content, err := marshalChatInputContent(parsed)
if err != nil {
return nil, err
}
return []ResponsesInputItem{{Role: "user", Content: content}}, nil
}
// chatAssistantToResponses converts an assistant message. If there is both
// text content and tool_calls, the text is emitted as an assistant message
// first, then each tool_call becomes a function_call item. If the content is
// empty/nil and there are tool_calls, only function_call items are emitted.
func chatAssistantToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
var items []ResponsesInputItem
content := ""
if m.ReasoningContent != "" {
content = "<thinking>" + m.ReasoningContent + "</thinking>"
}
// Emit assistant message with output_text if content is non-empty.
if len(m.Content) > 0 {
s, err := parseAssistantContent(m.Content)
if err != nil {
return nil, err
}
if s != "" {
if content != "" {
content += "\n"
}
content += s
}
}
if content != "" {
parts := []ResponsesContentPart{{Type: "output_text", Text: content}}
partsJSON, err := json.Marshal(parts)
if err != nil {
return nil, err
}
items = append(items, ResponsesInputItem{Role: "assistant", Content: partsJSON})
}
// Emit one function_call item per tool_call.
for _, tc := range m.ToolCalls {
args := tc.Function.Arguments
if args == "" {
args = "{}"
}
items = append(items, ResponsesInputItem{
Type: "function_call",
CallID: tc.ID,
Name: tc.Function.Name,
Arguments: args,
})
}
return items, nil
}
// parseAssistantContent returns assistant content as plain text.
//
// Supported formats:
// - JSON string
// - JSON array of typed parts (e.g. [{"type":"text","text":"..."}])
//
// For structured thinking/reasoning parts, it preserves semantics by wrapping
// the text in explicit tags so downstream can still distinguish it from normal text.
func parseAssistantContent(raw json.RawMessage) (string, error) {
if len(raw) == 0 {
return "", nil
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s, nil
}
var parts []map[string]any
if err := json.Unmarshal(raw, &parts); err != nil {
// Keep compatibility with prior behavior: unsupported assistant content
// formats are ignored instead of failing the whole request conversion.
return "", nil
}
var b strings.Builder
write := func(v string) error {
_, err := b.WriteString(v)
return err
}
for _, p := range parts {
typ, _ := p["type"].(string)
text, _ := p["text"].(string)
thinking, _ := p["thinking"].(string)
switch typ {
case "thinking", "reasoning":
if thinking != "" {
if err := write("<thinking>"); err != nil {
return "", err
}
if err := write(thinking); err != nil {
return "", err
}
if err := write("</thinking>"); err != nil {
return "", err
}
} else if text != "" {
if err := write("<thinking>"); err != nil {
return "", err
}
if err := write(text); err != nil {
return "", err
}
if err := write("</thinking>"); err != nil {
return "", err
}
}
default:
if text != "" {
if err := write(text); err != nil {
return "", err
}
}
}
}
return b.String(), nil
}
// chatToolToResponses converts a tool result message (role=tool) into a
// function_call_output item.
func chatToolToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
output, err := parseChatContent(m.Content)
if err != nil {
return nil, err
}
if output == "" {
output = "(empty)"
}
return []ResponsesInputItem{{
Type: "function_call_output",
CallID: m.ToolCallID,
Output: output,
}}, nil
}
// chatFunctionToResponses converts a legacy function result message
// (role=function) into a function_call_output item. The Name field is used as
// call_id since legacy function calls do not carry a separate call_id.
func chatFunctionToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
output, err := parseChatContent(m.Content)
if err != nil {
return nil, err
}
if output == "" {
output = "(empty)"
}
return []ResponsesInputItem{{
Type: "function_call_output",
CallID: m.Name,
Output: output,
}}, nil
}
// parseChatContent returns the string value of a ChatMessage Content field.
// Content can be a JSON string or an array of typed parts. Array content is
// flattened to text by concatenating text parts and ignoring non-text parts.
func parseChatContent(raw json.RawMessage) (string, error) {
parsed, err := parseChatMessageContent(raw)
if err != nil {
return "", err
}
if parsed.Text != nil {
return *parsed.Text, nil
}
return flattenChatContentParts(parsed.Parts), nil
}
func parseChatMessageContent(raw json.RawMessage) (chatMessageContent, error) {
if len(raw) == 0 {
return chatMessageContent{Text: stringPtr("")}, nil
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return chatMessageContent{Text: &s}, nil
}
var parts []ChatContentPart
if err := json.Unmarshal(raw, &parts); err == nil {
return chatMessageContent{Parts: parts}, nil
}
return chatMessageContent{}, fmt.Errorf("parse content as string or parts array")
}
func marshalChatInputContent(content chatMessageContent) (json.RawMessage, error) {
if content.Text != nil {
return json.Marshal(*content.Text)
}
parts := convertChatContentPartsToResponses(content.Parts)
if len(parts) == 0 {
// A nil slice marshals to JSON null, which the upstream Responses API
// rejects ("expected an array of objects or string, but got null").
// Fall back to an empty string when no usable parts remain.
return json.Marshal("")
}
return json.Marshal(parts)
}
func convertChatContentPartsToResponses(parts []ChatContentPart) []ResponsesContentPart {
var responseParts []ResponsesContentPart
for _, p := range parts {
switch p.Type {
case "text":
if p.Text != "" {
responseParts = append(responseParts, ResponsesContentPart{
Type: "input_text",
Text: p.Text,
})
}
case "image_url":
if p.ImageURL != nil && p.ImageURL.URL != "" && !isEmptyBase64DataURI(p.ImageURL.URL) {
responseParts = append(responseParts, ResponsesContentPart{
Type: "input_image",
ImageURL: p.ImageURL.URL,
})
}
}
}
return responseParts
}
func isEmptyBase64DataURI(raw string) bool {
if !strings.HasPrefix(raw, "data:") {
return false
}
rest := strings.TrimPrefix(raw, "data:")
semicolonIdx := strings.Index(rest, ";")
if semicolonIdx < 0 {
return false
}
rest = rest[semicolonIdx+1:]
if !strings.HasPrefix(rest, "base64,") {
return false
}
return strings.TrimSpace(strings.TrimPrefix(rest, "base64,")) == ""
}
func flattenChatContentParts(parts []ChatContentPart) string {
var textParts []string
for _, p := range parts {
if p.Type == "text" && p.Text != "" {
textParts = append(textParts, p.Text)
}
}
return strings.Join(textParts, "")
}
func stringPtr(s string) *string {
return &s
}
// convertChatToolsToResponses maps Chat Completions tool definitions and legacy
// function definitions to Responses API tool definitions.
func convertChatToolsToResponses(tools []ChatTool, functions []ChatFunction) []ResponsesTool {
var out []ResponsesTool
for _, t := range tools {
if strings.EqualFold(strings.TrimSpace(t.Type), "x_search") {
out = append(out, ResponsesTool{
Type: "x_search",
AllowedXHandles: t.AllowedXHandles,
ExcludedXHandles: t.ExcludedXHandles,
FromDate: t.FromDate,
ToDate: t.ToDate,
EnableImageUnderstanding: t.EnableImageUnderstanding,
EnableVideoUnderstanding: t.EnableVideoUnderstanding,
})
continue
}
if t.Type != "function" || t.Function == nil {
continue
}
rt := ResponsesTool{
Type: "function",
Name: t.Function.Name,
Description: t.Function.Description,
Parameters: t.Function.Parameters,
Strict: defaultStrictFalse(t.Function.Strict),
}
out = append(out, rt)
}
// Legacy functions[] are treated as function-type tools.
for _, f := range functions {
rt := ResponsesTool{
Type: "function",
Name: f.Name,
Description: f.Description,
Parameters: f.Parameters,
Strict: defaultStrictFalse(f.Strict),
}
out = append(out, rt)
}
return out
}
func defaultStrictFalse(src *bool) *bool {
if src == nil {
value := false
return &value
}
return src
}
// convertChatFunctionCallToToolChoice maps the legacy function_call field to a
// Responses API tool_choice value.
//
// "auto" → "auto"
// "none" → "none"
// {"name":"X"} → {"type":"function","name":"X"}
func convertChatFunctionCallToToolChoice(raw json.RawMessage) (json.RawMessage, error) {
// Try string first ("auto", "none", etc.) — pass through as-is.
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return json.Marshal(s)
}
// Object form: {"name":"X"}
var obj struct {
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, err
}
return json.Marshal(map[string]any{
"type": "function",
"name": obj.Name,
})
}
@@ -0,0 +1,79 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestChatCompletionsToResponsesPreservesXSearchTool(t *testing.T) {
enabled := true
req := &ChatCompletionsRequest{
Model: "grok-4.5",
Messages: []ChatMessage{
{Role: "user", Content: json.RawMessage(`"latest xAI post"`)},
},
Tools: []ChatTool{{
Type: "x_search",
AllowedXHandles: []string{"xai"},
ExcludedXHandles: []string{"spam"},
FromDate: "2026-08-01",
ToDate: "2026-08-10",
EnableImageUnderstanding: &enabled,
EnableVideoUnderstanding: &enabled,
}},
ToolChoice: json.RawMessage(`{"type":"x_search"}`),
}
resp, err := ChatCompletionsToResponses(req)
require.NoError(t, err)
require.Len(t, resp.Tools, 1)
require.Equal(t, "x_search", resp.Tools[0].Type)
require.Equal(t, []string{"xai"}, resp.Tools[0].AllowedXHandles)
require.Equal(t, []string{"spam"}, resp.Tools[0].ExcludedXHandles)
require.Equal(t, "2026-08-01", resp.Tools[0].FromDate)
require.Equal(t, "2026-08-10", resp.Tools[0].ToDate)
require.NotNil(t, resp.Tools[0].EnableImageUnderstanding)
require.True(t, *resp.Tools[0].EnableImageUnderstanding)
require.NotNil(t, resp.Tools[0].EnableVideoUnderstanding)
require.True(t, *resp.Tools[0].EnableVideoUnderstanding)
require.JSONEq(t, `{"type":"x_search"}`, string(resp.ToolChoice))
}
func TestResponsesToChatCompletionsPreservesXSearchTool(t *testing.T) {
enabled := true
req := &ResponsesRequest{
Model: "grok-4.5",
Input: json.RawMessage(`"latest xAI post"`),
Tools: []ResponsesTool{{
Type: "x_search",
AllowedXHandles: []string{"xai"},
ExcludedXHandles: []string{"spam"},
FromDate: "2026-08-01",
ToDate: "2026-08-10",
EnableImageUnderstanding: &enabled,
EnableVideoUnderstanding: &enabled,
}},
ToolChoice: json.RawMessage(`{"type":"x_search"}`),
}
chat, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, chat.Tools, 1)
require.Equal(t, "x_search", chat.Tools[0].Type)
require.Equal(t, []string{"xai"}, chat.Tools[0].AllowedXHandles)
require.Equal(t, []string{"spam"}, chat.Tools[0].ExcludedXHandles)
require.JSONEq(t, `{"type":"x_search"}`, string(chat.ToolChoice))
}
func TestResponsesToChatCompletionsXSearchToolChoiceString(t *testing.T) {
chat, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
Model: "grok-4.5",
Input: json.RawMessage(`"latest xAI post"`),
Tools: []ResponsesTool{{Type: "x_search"}},
ToolChoice: json.RawMessage(`"x_search"`),
})
require.NoError(t, err)
require.JSONEq(t, `"x_search"`, string(chat.ToolChoice))
}
@@ -0,0 +1,92 @@
package apicompat
import "encoding/json"
func chatResponseFormatToResponsesTextFormat(raw json.RawMessage) json.RawMessage {
raw = normalizedRawJSON(raw)
if len(raw) == 0 {
return nil
}
obj, ok := rawJSONObject(raw)
if !ok || rawString(obj["type"]) != "json_schema" {
return raw
}
schemaRaw := normalizedRawJSON(obj["json_schema"])
if len(schemaRaw) == 0 {
return raw
}
var schema map[string]json.RawMessage
if err := json.Unmarshal(schemaRaw, &schema); err != nil {
return raw
}
schema["type"] = rawJSONString("json_schema")
out, err := json.Marshal(schema)
if err != nil {
return raw
}
return out
}
func responsesTextFormatToChatResponseFormat(raw json.RawMessage) json.RawMessage {
raw = normalizedRawJSON(raw)
if len(raw) == 0 {
return nil
}
obj, ok := rawJSONObject(raw)
if !ok || rawString(obj["type"]) != "json_schema" {
return raw
}
if _, alreadyChatShape := obj["json_schema"]; alreadyChatShape {
return raw
}
schema := make(map[string]json.RawMessage, len(obj))
for key, value := range obj {
if key == "type" {
continue
}
schema[key] = value
}
if len(schema) == 0 {
return raw
}
schemaRaw, err := json.Marshal(schema)
if err != nil {
return raw
}
out, err := json.Marshal(map[string]json.RawMessage{
"type": rawJSONString("json_schema"),
"json_schema": schemaRaw,
})
if err != nil {
return raw
}
return out
}
func normalizedRawJSON(raw json.RawMessage) json.RawMessage {
raw = bytesTrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
return nil
}
return append(json.RawMessage(nil), raw...)
}
func rawJSONObject(raw json.RawMessage) (map[string]json.RawMessage, bool) {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, false
}
return obj, true
}
func rawJSONString(value string) json.RawMessage {
data, _ := json.Marshal(value)
return data
}
@@ -0,0 +1,99 @@
package apicompat
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAnthropicUsageFromResponsesUsage_CacheCreation(t *testing.T) {
usage := &ResponsesUsage{
InputTokens: 20,
OutputTokens: 5,
CacheCreationInputTokens: 6,
InputTokensDetails: &ResponsesInputTokensDetails{
CachedTokens: 4,
},
}
got := anthropicUsageFromResponsesUsage(usage)
assert.Equal(t, 10, got.InputTokens, "input = total(20) - cache_read(4) - cache_creation(6)")
assert.Equal(t, 5, got.OutputTokens)
assert.Equal(t, 4, got.CacheReadInputTokens)
assert.Equal(t, 6, got.CacheCreationInputTokens, "cache creation must be preserved")
}
func TestAnthropicUsageFromResponsesUsage_NoCacheCreation(t *testing.T) {
usage := &ResponsesUsage{
InputTokens: 10,
OutputTokens: 5,
InputTokensDetails: &ResponsesInputTokensDetails{
CachedTokens: 3,
},
}
got := anthropicUsageFromResponsesUsage(usage)
assert.Equal(t, 7, got.InputTokens)
assert.Equal(t, 3, got.CacheReadInputTokens)
assert.Equal(t, 0, got.CacheCreationInputTokens)
}
func TestResponsesEventToAnthropicEvents_StreamingCacheCreation(t *testing.T) {
state := NewResponsesEventToAnthropicState()
state.MessageStartSent = true
completedEvt := &ResponsesStreamEvent{
Type: "response.completed",
Response: &ResponsesResponse{
Status: "completed",
Usage: &ResponsesUsage{
InputTokens: 20,
OutputTokens: 5,
CacheCreationInputTokens: 6,
InputTokensDetails: &ResponsesInputTokensDetails{
CachedTokens: 4,
},
},
},
}
events := ResponsesEventToAnthropicEvents(completedEvt, state)
var deltaEvt *AnthropicStreamEvent
for i := range events {
if events[i].Type == "message_delta" {
deltaEvt = &events[i]
break
}
}
require.NotNil(t, deltaEvt, "should have message_delta event")
require.NotNil(t, deltaEvt.Usage)
assert.Equal(t, 6, deltaEvt.Usage.CacheCreationInputTokens, "streaming cache_creation must be preserved")
assert.Equal(t, 10, deltaEvt.Usage.InputTokens, "input = 20 - 4(read) - 6(creation)")
assert.Equal(t, 4, deltaEvt.Usage.CacheReadInputTokens)
}
func TestAnthropicToResponsesResponse_CacheCreation(t *testing.T) {
resp := AnthropicResponse{
ID: "msg_test",
Type: "message",
Role: "assistant",
Model: "claude-opus-4-6",
Usage: AnthropicUsage{
InputTokens: 10,
OutputTokens: 5,
CacheReadInputTokens: 4,
CacheCreationInputTokens: 6,
},
StopReason: AnthropicStopReasonPtr("end_turn"),
}
out := AnthropicToResponsesResponse(&resp)
require.NotNil(t, out.Usage)
assert.Equal(t, 20, out.Usage.InputTokens, "total = input(10) + cache_read(4) + cache_creation(6)")
assert.Equal(t, 6, out.Usage.CacheCreationInputTokens, "cache creation must round-trip")
}
@@ -0,0 +1,750 @@
package apicompat
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
// ResponsesClientToolMapping records the reversible lowering applied before a
// native Responses request is sent to an upstream that only understands
// function tools.
type ResponsesClientToolMapping struct {
CustomTools map[string]bool
ToolSearch bool
NamespaceTools map[string]ResponsesNamespaceName
}
// AdaptResponsesClientTools lowers Codex client-only tools in req to
// ordinary function tools. It mutates req and returns the mapping required to
// restore the upstream response.
func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, bool, error) {
if req == nil {
return ResponsesClientToolMapping{}, false, nil
}
tools, ok := req["tools"].([]any)
if !ok || len(tools) == 0 {
return ResponsesClientToolMapping{}, false, nil
}
discovered, err := promoteResponsesToolSearchDiscoveries(req)
if err != nil {
return ResponsesClientToolMapping{}, false, err
}
if discovered {
tools, _ = req["tools"].([]any)
}
adapter := ResponsesClientToolMapping{CustomTools: make(map[string]bool)}
functionNames := make(map[string]bool)
customNames := make(map[string]bool)
for _, raw := range tools {
tool, ok := raw.(map[string]any)
if !ok {
continue
}
name := strings.TrimSpace(stringValue(tool["name"]))
switch strings.TrimSpace(stringValue(tool["type"])) {
case "function":
if name != "" {
functionNames[name] = true
}
case "custom":
if name != "" {
customNames[name] = true
}
case "tool_search":
adapter.ToolSearch = true
}
}
for name := range customNames {
if functionNames[name] {
return ResponsesClientToolMapping{}, false, fmt.Errorf("custom tool %q conflicts with a function tool of the same name; this upstream cannot disambiguate them, rename one of the tools", name)
}
}
if adapter.ToolSearch && (functionNames[toolSearchProxyName] || customNames[toolSearchProxyName]) {
return ResponsesClientToolMapping{}, false, fmt.Errorf("built-in tool_search conflicts with a declared tool named %q; this upstream cannot disambiguate them, rename the tool", toolSearchProxyName)
}
// Namespace flattening also rewrites namespace-qualified history and choice.
names, flattened, err := FlattenResponsesNamespaces(req)
if err != nil {
return ResponsesClientToolMapping{}, false, err
}
adapter.NamespaceTools = names
if adapter.ToolSearch {
if _, exists := names[toolSearchProxyName]; exists {
return ResponsesClientToolMapping{}, false, fmt.Errorf("built-in tool_search conflicts with namespace tool flattened as %q; this upstream cannot disambiguate them, rename the tool", toolSearchProxyName)
}
}
tools, _ = req["tools"].([]any)
lowered := make([]any, 0, len(tools))
changed := discovered || flattened
seenSearch := false
for _, raw := range tools {
tool, ok := raw.(map[string]any)
if !ok {
lowered = append(lowered, raw)
continue
}
typ := strings.TrimSpace(stringValue(tool["type"]))
name := strings.TrimSpace(stringValue(tool["name"]))
switch typ {
case "custom":
if name == "" {
lowered = append(lowered, raw)
continue
}
copy := copyClientTool(tool)
copy["type"] = "function"
copy["parameters"] = json.RawMessage(customToolInputSchema)
delete(copy, "format")
adapter.CustomTools[name] = true
lowered = append(lowered, copy)
changed = true
case "tool_search":
if seenSearch {
changed = true
continue
}
seenSearch = true
lowered = append(lowered, map[string]any{
"type": "function", "name": toolSearchProxyName,
"description": "Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task.",
"parameters": json.RawMessage(toolSearchProxySchema),
})
changed = true
default:
lowered = append(lowered, raw)
}
}
if changed {
req["tools"] = lowered
}
historyChanged, err := rewriteClientToolHistory(req["input"], &adapter)
if err != nil {
return ResponsesClientToolMapping{}, false, err
}
if historyChanged {
changed = true
}
if rewriteClientToolChoice(req, &adapter) {
changed = true
}
if len(adapter.CustomTools) == 0 {
adapter.CustomTools = nil
}
if len(adapter.NamespaceTools) == 0 {
adapter.NamespaceTools = nil
}
return adapter, changed, nil
}
// AdaptResponsesClientToolsWithInheritedMapping lowers client-tool history on
// a follow-up request that omits the session-level tools declaration. An
// explicitly present tools field, including an empty or malformed value,
// always replaces the inherited mapping and is handled by the ordinary
// declaration-driven adapter.
func AdaptResponsesClientToolsWithInheritedMapping(
req map[string]any,
inherited ResponsesClientToolMapping,
inheritedLoweredTools ...[]any,
) (ResponsesClientToolMapping, bool, error) {
if req == nil {
return ResponsesClientToolMapping{}, false, nil
}
if _, toolsPresent := req["tools"]; toolsPresent {
return AdaptResponsesClientTools(req)
}
if len(inherited.CustomTools) == 0 && !inherited.ToolSearch && len(inherited.NamespaceTools) == 0 {
return ResponsesClientToolMapping{}, false, nil
}
if len(inheritedLoweredTools) > 0 && len(inheritedLoweredTools[0]) > 0 {
req["tools"] = restoreInheritedResponsesClientToolDeclarations(inheritedLoweredTools[0], inherited)
return AdaptResponsesClientTools(req)
}
changed, err := rewriteClientToolHistory(req["input"], &inherited)
if err != nil {
return ResponsesClientToolMapping{}, false, err
}
if len(inherited.NamespaceTools) > 0 {
before := changed
rewriteNamespaceQualifiedCalls(req["input"], inherited.NamespaceTools)
// Namespace rewriting does not currently report whether it changed a
// value. A retained namespace mapping is only used for follow-up
// history, so conservatively rebuild the request when input exists.
if _, inputPresent := req["input"]; inputPresent && !before {
changed = true
}
}
if rewriteClientToolChoice(req, &inherited) {
changed = true
}
return inherited, changed, nil
}
func copyClientTool(tool map[string]any) map[string]any {
copy := make(map[string]any, len(tool))
for key, value := range tool {
copy[key] = value
}
return copy
}
func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) (bool, error) {
changed := false
var visit func(any) error
visit = func(value any) error {
switch typed := value.(type) {
case []any:
for _, item := range typed {
if err := visit(item); err != nil {
return err
}
}
case map[string]any:
typ := strings.TrimSpace(stringValue(typed["type"]))
switch typ {
case "custom_tool_call":
if adapter.CustomTools[strings.TrimSpace(stringValue(typed["name"]))] {
typed["type"] = "function_call"
typed["arguments"] = customToolCallArguments(stringValue(typed["input"]))
delete(typed, "input")
dropInvalidLoweredFunctionItemID(typed)
changed = true
}
case "custom_tool_call_output":
typed["type"] = "function_call_output"
dropInvalidLoweredFunctionItemID(typed)
normalizeClientToolOutput(typed)
changed = true
case "tool_search_call":
if adapter.ToolSearch {
typed["type"] = "function_call"
typed["name"] = toolSearchProxyName
typed["arguments"] = rawObjectString(typed["arguments"])
delete(typed, "execution")
dropInvalidLoweredFunctionItemID(typed)
changed = true
}
case "tool_search_output":
if adapter.ToolSearch {
callID := strings.TrimSpace(stringValue(typed["call_id"]))
if callID == "" {
return fmt.Errorf("tool_search_output requires a non-empty string call_id before it can be lowered to function_call_output")
}
typed["type"] = "function_call_output"
dropInvalidLoweredFunctionItemID(typed)
if err := normalizeToolSearchOutput(typed); err != nil {
return err
}
changed = true
}
}
for _, child := range typed {
if err := visit(child); err != nil {
return err
}
}
}
return nil
}
if err := visit(value); err != nil {
return false, err
}
return changed, nil
}
// dropInvalidLoweredFunctionItemID removes Codex client-only item IDs such as
// ctc_*, ctco_*, tsc_*, and tso_* after their item type is lowered to the
// function protocol. Function upstreams validate these IDs with the fc prefix;
// call_id, which is preserved separately, is the tool call/output pairing key.
func dropInvalidLoweredFunctionItemID(item map[string]any) {
id := strings.TrimSpace(stringValue(item["id"]))
if id != "" && !strings.HasPrefix(id, "fc") {
delete(item, "id")
}
}
func normalizeClientToolOutput(item map[string]any) {
output, exists := item["output"]
if !exists {
return
}
if _, ok := output.(string); ok {
return
}
if output == nil {
item["output"] = ""
return
}
encoded, err := json.Marshal(output)
if err != nil {
item["output"] = ""
return
}
item["output"] = string(encoded)
}
// normalizeToolSearchOutput converts both tool_search output wire shapes into
// the string output required by function_call_output. Older clients send an
// output field directly; newer Codex clients return discovered definitions in
// a top-level tools field. Codex treats that field's value as the tool output,
// so serialize the value directly rather than wrapping it in another object.
func normalizeToolSearchOutput(item map[string]any) error {
if output, hasOutput := item["output"]; hasOutput {
switch typed := output.(type) {
case string:
item["output"] = typed
case nil:
item["output"] = ""
default:
encoded, err := json.Marshal(typed)
if err != nil {
return fmt.Errorf("tool_search_output output cannot be encoded as function_call_output output: %w", err)
}
item["output"] = string(encoded)
}
dropToolSearchOutputPrivateFields(item)
return nil
}
tools, hasTools := item["tools"]
if !hasTools {
return fmt.Errorf("tool_search_output requires output or tools before it can be lowered to function_call_output")
}
encoded, err := json.Marshal(tools)
if err != nil {
return fmt.Errorf("tool_search_output tools cannot be encoded as function_call_output output: %w", err)
}
item["output"] = string(encoded)
dropToolSearchOutputPrivateFields(item)
return nil
}
func dropToolSearchOutputPrivateFields(item map[string]any) {
delete(item, "tools")
delete(item, "status")
delete(item, "execution")
}
func rewriteClientToolChoice(req map[string]any, adapter *ResponsesClientToolMapping) bool {
choice, ok := req["tool_choice"].(map[string]any)
if !ok {
return false
}
typ := strings.TrimSpace(stringValue(choice["type"]))
name := strings.TrimSpace(stringValue(choice["name"]))
if typ == "custom" && adapter.CustomTools[name] {
choice["type"] = "function"
return true
}
if typ == "tool_search" && adapter.ToolSearch {
req["tool_choice"] = map[string]any{"type": "function", "name": toolSearchProxyName}
return true
}
return false
}
func customToolCallArguments(input string) string {
encoded, _ := json.Marshal(map[string]string{"input": input})
return string(encoded)
}
func rawObjectString(value any) string {
if text, ok := value.(string); ok {
return text
}
encoded, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(encoded)
}
// RestoreResponsesClientToolPayload restores client tool calls in a non-stream
// native Responses JSON payload.
func RestoreResponsesClientToolPayload(payload []byte, mapping ResponsesClientToolMapping) ([]byte, bool, error) {
if len(payload) == 0 {
return payload, false, nil
}
var value any
if err := json.Unmarshal(payload, &value); err != nil {
return payload, false, err
}
changed := restoreClientToolValue(value, &mapping)
if !changed {
if len(mapping.NamespaceTools) == 0 {
return payload, false, nil
}
return RestoreResponsesNamespaceCalls(payload, mapping.NamespaceTools)
}
var rebuilt bytes.Buffer
encoder := json.NewEncoder(&rebuilt)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return payload, false, err
}
rebuiltPayload := bytes.TrimSuffix(rebuilt.Bytes(), []byte("\n"))
if len(mapping.NamespaceTools) == 0 {
return rebuiltPayload, true, nil
}
restored, _, err := RestoreResponsesNamespaceCalls(rebuiltPayload, mapping.NamespaceTools)
if err != nil {
return payload, false, err
}
return restored, true, nil
}
func restoreClientToolValue(value any, adapter *ResponsesClientToolMapping) bool {
changed := false
switch typed := value.(type) {
case []any:
for _, item := range typed {
changed = restoreClientToolValue(item, adapter) || changed
}
case map[string]any:
if strings.TrimSpace(stringValue(typed["type"])) == "function_call" {
name := strings.TrimSpace(stringValue(typed["name"]))
if adapter.CustomTools[name] {
typed["type"] = "custom_tool_call"
typed["input"] = extractCustomToolCallInput(rawObjectString(typed["arguments"]))
delete(typed, "arguments")
delete(typed, "namespace")
changed = true
} else if adapter.ToolSearch && name == toolSearchProxyName {
typed["type"] = "tool_search_call"
typed["execution"] = "client"
typed["arguments"] = json.RawMessage(toolSearchCallArgumentsJSON(rawObjectString(typed["arguments"])))
delete(typed, "name")
delete(typed, "namespace")
changed = true
}
}
for _, child := range typed {
changed = restoreClientToolValue(child, adapter) || changed
}
}
return changed
}
// ResponsesClientToolStreamRestorer restores client tool stream lifecycles.
// It is intentionally stateful because custom tools need their function
// arguments buffered until the upstream signals the call is complete.
type ResponsesClientToolStreamRestorer struct {
adapter ResponsesClientToolMapping
nextSeq int
seenSeq bool
calls map[string]*responsesClientToolStreamCall
byOutput map[int]*responsesClientToolStreamCall
}
type responsesClientToolStreamCall struct {
kind string
name string
callID string
itemID string
outputIdx int
arguments strings.Builder
}
func NewResponsesClientToolStreamRestorer(mapping ResponsesClientToolMapping) *ResponsesClientToolStreamRestorer {
return &ResponsesClientToolStreamRestorer{adapter: mapping, calls: make(map[string]*responsesClientToolStreamCall), byOutput: make(map[int]*responsesClientToolStreamCall)}
}
// Restore transforms one upstream SSE event into zero or more client events.
// Returned sequence numbers are continuous even when function argument events
// are suppressed or a custom completion expands into two events.
func (r *ResponsesClientToolStreamRestorer) Restore(event ResponsesStreamEvent) []ResponsesStreamEvent {
if r == nil {
return []ResponsesStreamEvent{event}
}
if !r.seenSeq {
r.nextSeq = event.SequenceNumber
r.seenSeq = true
}
var out []ResponsesStreamEvent
emit := func(event ResponsesStreamEvent) {
event.SequenceNumber = r.nextSeq
r.nextSeq++
out = append(out, event)
}
switch event.Type {
case "response.output_item.added":
if call := r.recordItem(event); call != nil {
if call.kind == "custom" {
event.Item.Type = "custom_tool_call"
event.Item.Input = ""
event.Item.Arguments = ""
event.Item.Namespace = ""
} else {
event.Item.Type = "tool_search_call"
event.Item.Name = ""
event.Item.Arguments = "{}"
event.Item.Namespace = ""
}
}
emit(r.restoreNamespaceEvent(event))
case "response.function_call_arguments.delta":
if call := r.callFor(event); call != nil {
_, _ = call.arguments.WriteString(event.Delta)
return nil
}
emit(r.restoreNamespaceEvent(event))
case "response.function_call_arguments.done":
if call := r.callFor(event); call != nil {
if event.Arguments != "" {
call.arguments.Reset()
_, _ = call.arguments.WriteString(event.Arguments)
}
if call.kind == "custom" {
input := extractCustomToolCallInput(call.arguments.String())
if input != "" {
emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.delta", OutputIndex: call.outputIdx, ItemID: call.itemID, Delta: input})
}
emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.done", OutputIndex: call.outputIdx, ItemID: call.itemID, CallID: call.callID, Name: call.name, Input: input})
}
return out
}
emit(r.restoreNamespaceEvent(event))
case "response.output_item.done":
if call := r.recordItem(event); call != nil {
if call.kind == "custom" {
event.Item.Type = "custom_tool_call"
event.Item.Input = extractCustomToolCallInput(call.arguments.String())
event.Item.Arguments = ""
event.Item.Namespace = ""
} else {
event.Item.Type = "tool_search_call"
event.Item.Name = ""
event.Item.Arguments = call.arguments.String()
if strings.TrimSpace(event.Item.Arguments) == "" {
event.Item.Arguments = "{}"
}
event.Item.Namespace = ""
}
delete(r.calls, call.itemID)
delete(r.calls, call.callID)
delete(r.byOutput, call.outputIdx)
}
emit(r.restoreNamespaceEvent(event))
default:
// response.completed carries the non-stream representation.
if event.Response != nil {
restoreResponsesOutputClientTools(event.Response.Output, &r.adapter)
}
emit(r.restoreNamespaceEvent(event))
}
return out
}
// RestoreEvent restores one Responses SSE JSON data payload. Custom tool
// completions can expand to multiple payloads and proxy argument deltas can be
// intentionally dropped, hence the slice return value.
func (r *ResponsesClientToolStreamRestorer) RestoreEvent(payload []byte) ([][]byte, bool, error) {
if len(payload) == 0 {
return nil, false, nil
}
var wire struct {
Type string `json:"type"`
Sequence int `json:"sequence_number"`
}
if err := json.Unmarshal(payload, &wire); err != nil {
return nil, false, err
}
if isResponsesClientToolTerminalEvent(wire.Type) {
restored, changed, err := RestoreResponsesClientToolPayload(payload, r.adapter)
if err != nil {
return nil, false, err
}
return r.resequenceRaw(restored, wire.Sequence, changed)
}
if !clientToolLifecycleEvent(wire.Type) {
return r.resequenceRaw(payload, wire.Sequence, false)
}
if !r.clientToolEventPayload(payload) {
return r.resequenceRaw(payload, wire.Sequence, false)
}
var event ResponsesStreamEvent
if err := json.Unmarshal(payload, &event); err != nil {
return nil, false, err
}
events := r.Restore(event)
if len(events) == 1 {
unchanged, err := json.Marshal(events[0])
if err == nil && bytes.Equal(bytes.TrimSpace(unchanged), bytes.TrimSpace(payload)) {
return [][]byte{payload}, false, nil
}
}
result := make([][]byte, 0, len(events))
for _, restored := range events {
encoded, err := json.Marshal(restored)
if err != nil {
return nil, false, err
}
result = append(result, encoded)
}
return result, true, nil
}
func isResponsesClientToolTerminalEvent(typ string) bool {
switch strings.TrimSpace(typ) {
case "response.completed", "response.done", "response.incomplete", "response.failed", "response.cancelled", "response.canceled":
return true
default:
return false
}
}
func (r *ResponsesClientToolStreamRestorer) clientToolEventPayload(payload []byte) bool {
var raw struct {
ItemID string `json:"item_id"`
CallID string `json:"call_id"`
Name string `json:"name"`
OutputIndex int `json:"output_index"`
Item *struct {
Type string `json:"type"`
ID string `json:"id"`
CallID string `json:"call_id"`
Name string `json:"name"`
} `json:"item"`
}
if err := json.Unmarshal(payload, &raw); err != nil {
return false
}
if raw.Item != nil {
if raw.Item.Type != "function_call" {
return false
}
_, namespaceTool := r.adapter.NamespaceTools[raw.Item.Name]
return r.adapter.CustomTools[raw.Item.Name] || (r.adapter.ToolSearch && raw.Item.Name == toolSearchProxyName) || namespaceTool || r.calls[raw.Item.ID] != nil || r.calls[raw.Item.CallID] != nil
}
if _, namespaceTool := r.adapter.NamespaceTools[raw.Name]; namespaceTool {
return true
}
if r.calls[raw.ItemID] != nil || r.calls[raw.CallID] != nil || r.byOutput[raw.OutputIndex] != nil {
return true
}
return false
}
func clientToolLifecycleEvent(typ string) bool {
switch typ {
case "response.output_item.added", "response.output_item.done", "response.function_call_arguments.delta", "response.function_call_arguments.done":
return true
default:
return false
}
}
// resequenceRaw deliberately keeps opaque upstream event fields untouched.
func (r *ResponsesClientToolStreamRestorer) resequenceRaw(payload []byte, sequence int, changed bool) ([][]byte, bool, error) {
if !r.seenSeq {
r.nextSeq, r.seenSeq = sequence, true
}
if r.nextSeq == sequence && !changed {
r.nextSeq++
return [][]byte{payload}, false, nil
}
var raw map[string]any
if err := json.Unmarshal(payload, &raw); err != nil {
return nil, false, err
}
raw["sequence_number"] = r.nextSeq
r.nextSeq++
encoded, err := json.Marshal(raw)
if err != nil {
return nil, false, err
}
return [][]byte{encoded}, true, nil
}
func (r *ResponsesClientToolStreamRestorer) recordItem(event ResponsesStreamEvent) *responsesClientToolStreamCall {
if event.Item == nil || event.Item.Type != "function_call" {
return nil
}
name := event.Item.Name
kind := ""
if r.adapter.CustomTools[name] {
kind = "custom"
} else if r.adapter.ToolSearch && name == toolSearchProxyName {
kind = "tool_search"
}
if kind == "" {
return nil
}
key := event.Item.ID
if key == "" {
key = event.Item.CallID
}
call := r.calls[key]
if call == nil {
call = &responsesClientToolStreamCall{kind: kind, name: name, callID: event.Item.CallID, itemID: event.Item.ID, outputIdx: event.OutputIndex}
r.calls[key] = call
if call.callID != "" {
r.calls[call.callID] = call
}
r.byOutput[call.outputIdx] = call
}
if event.Item.Arguments != "" {
call.arguments.Reset()
_, _ = call.arguments.WriteString(event.Item.Arguments)
}
return call
}
func (r *ResponsesClientToolStreamRestorer) callFor(event ResponsesStreamEvent) *responsesClientToolStreamCall {
if call := r.calls[event.ItemID]; call != nil {
return call
}
if call := r.byOutput[event.OutputIndex]; call != nil {
return call
}
for _, call := range r.calls {
if (event.CallID != "" && call.callID == event.CallID) || (event.ItemID == "" && event.Name != "" && call.name == event.Name) {
return call
}
}
return nil
}
func (r *ResponsesClientToolStreamRestorer) restoreNamespaceEvent(event ResponsesStreamEvent) ResponsesStreamEvent {
if len(r.adapter.NamespaceTools) == 0 {
return event
}
if event.Item != nil && event.Item.Type == "function_call" {
if name, ok := r.adapter.NamespaceTools[event.Item.Name]; ok {
event.Item.Name, event.Item.Namespace = name.Name, name.Namespace
}
}
if event.Type == "response.function_call_arguments.delta" || event.Type == "response.function_call_arguments.done" {
if name, ok := r.adapter.NamespaceTools[event.Name]; ok {
event.Name = name.Name
}
}
return event
}
func restoreResponsesOutputClientTools(outputs []ResponsesOutput, adapter *ResponsesClientToolMapping) {
for index := range outputs {
output := &outputs[index]
if output.Type != "function_call" {
continue
}
if adapter.CustomTools[output.Name] {
output.Type = "custom_tool_call"
output.Input = extractCustomToolCallInput(output.Arguments)
output.Arguments = ""
output.Namespace = ""
} else if adapter.ToolSearch && output.Name == toolSearchProxyName {
output.Type = "tool_search_call"
output.Name = ""
output.Namespace = ""
}
if name, ok := adapter.NamespaceTools[output.Name]; ok && output.Type == "function_call" {
output.Name, output.Namespace = name.Name, name.Namespace
}
}
}
@@ -0,0 +1,642 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces(t *testing.T) {
req := map[string]any{
"tools": []any{
map[string]any{"type": "custom", "name": "exec", "format": map[string]any{"type": "grammar"}},
map[string]any{"type": "tool_search"},
map[string]any{"type": "namespace", "name": "team", "tools": []any{map[string]any{"type": "function", "name": "send"}}},
},
"tool_choice": map[string]any{"type": "custom", "name": "exec"},
"input": []any{
map[string]any{"type": "custom_tool_call", "id": "ctc_client", "call_id": "c1", "name": "exec", "input": "dir"},
map[string]any{"type": "custom_tool_call_output", "id": "ctco_client", "call_id": "c1", "output": "ok"},
map[string]any{"type": "tool_search_call", "id": "tsc_client", "call_id": "s1", "arguments": map[string]any{"query": "git"}},
map[string]any{"type": "tool_search_output", "id": "tso_client", "call_id": "s1", "output": map[string]any{"groups": []string{"git"}}},
map[string]any{"type": "function_call", "call_id": "n1", "namespace": "team", "name": "send", "arguments": "{}"},
},
}
mapping, changed, err := AdaptResponsesClientTools(req)
require.NoError(t, err)
require.True(t, changed)
require.True(t, mapping.CustomTools["exec"])
require.True(t, mapping.ToolSearch)
require.Equal(t, ResponsesNamespaceName{Namespace: "team", Name: "send"}, mapping.NamespaceTools["team__send"])
tools := requireResponsesClientToolValue[[]any](t, req["tools"])
require.Len(t, tools, 3)
exec := requireResponsesClientToolValue[map[string]any](t, tools[0])
require.Equal(t, "function", exec["type"])
parameters := requireResponsesClientToolValue[json.RawMessage](t, exec["parameters"])
require.JSONEq(t, customToolInputSchema, string(parameters))
search := requireResponsesClientToolValue[map[string]any](t, tools[1])
require.Equal(t, toolSearchProxyName, search["name"])
namespaceTool := requireResponsesClientToolValue[map[string]any](t, tools[2])
require.Equal(t, "team__send", namespaceTool["name"])
choice := requireResponsesClientToolValue[map[string]any](t, req["tool_choice"])
require.Equal(t, "function", choice["type"])
input := requireResponsesClientToolValue[[]any](t, req["input"])
customCall := requireResponsesClientToolValue[map[string]any](t, input[0])
require.Equal(t, "function_call", customCall["type"])
require.NotContains(t, customCall, "id")
require.JSONEq(t, `{"input":"dir"}`, requireResponsesClientToolValue[string](t, customCall["arguments"]))
customOutput := requireResponsesClientToolValue[map[string]any](t, input[1])
require.Equal(t, "function_call_output", customOutput["type"])
require.NotContains(t, customOutput, "id")
searchCall := requireResponsesClientToolValue[map[string]any](t, input[2])
require.Equal(t, "function_call", searchCall["type"])
require.NotContains(t, searchCall, "id")
require.Equal(t, toolSearchProxyName, searchCall["name"])
require.JSONEq(t, `{"query":"git"}`, requireResponsesClientToolValue[string](t, searchCall["arguments"]))
searchOutput := requireResponsesClientToolValue[map[string]any](t, input[3])
require.Equal(t, "function_call_output", searchOutput["type"])
require.NotContains(t, searchOutput, "id")
require.JSONEq(t, `{"groups":["git"]}`, requireResponsesClientToolValue[string](t, searchOutput["output"]))
namespaceCall := requireResponsesClientToolValue[map[string]any](t, input[4])
require.Equal(t, "team__send", namespaceCall["name"])
}
func TestAdaptResponsesClientTools_LowersDiscoveredToolSearchOutput(t *testing.T) {
requestJSON := `{
"tools":[{"type":"tool_search"}],
"input":[
{"type":"tool_search_call","id":"tsc_client","call_id":"call_search","arguments":{"query":"codex app"},"execution":"client","status":"completed"},
{"type":"tool_search_output","id":"tso_client","call_id":"call_search","execution":"client","status":"completed","tools":[
{"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","description":"Load workspace dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]},
{"type":"namespace","name":"multi_agent_v1","tools":[
{"type":"function","name":"spawn_agent","description":"Spawn an agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}},
{"type":"function","name":"wait_agent","description":"Wait for agents","parameters":{"type":"object","properties":{},"additionalProperties":false}}
]}
]}
]
}`
type adaptedRequest struct {
req map[string]any
mapping ResponsesClientToolMapping
}
adapt := func() adaptedRequest {
var req map[string]any
require.NoError(t, json.Unmarshal([]byte(requestJSON), &req))
mapping, changed, err := AdaptResponsesClientTools(req)
require.NoError(t, err)
require.True(t, changed)
return adaptedRequest{req: req, mapping: mapping}
}
first := adapt()
second := adapt()
firstInput := requireResponsesClientToolValue[[]any](t, first.req["input"])
secondInput := requireResponsesClientToolValue[[]any](t, second.req["input"])
tools := requireResponsesClientToolValue[[]any](t, first.req["tools"])
require.Len(t, tools, 4)
require.Equal(t, []string{
"tool_search",
"codex_app__load_workspace_dependencies",
"multi_agent_v1__spawn_agent",
"multi_agent_v1__wait_agent",
}, responsesClientToolNames(t, tools))
require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, first.mapping.NamespaceTools["multi_agent_v1__spawn_agent"])
require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "wait_agent"}, first.mapping.NamespaceTools["multi_agent_v1__wait_agent"])
call := requireResponsesClientToolValue[map[string]any](t, firstInput[0])
require.Equal(t, "function_call", call["type"])
require.Equal(t, toolSearchProxyName, call["name"])
require.JSONEq(t, `{"query":"codex app"}`, requireResponsesClientToolValue[string](t, call["arguments"]))
require.NotContains(t, call, "execution")
output := requireResponsesClientToolValue[map[string]any](t, firstInput[1])
require.Equal(t, map[string]any{
"type": "function_call_output",
"call_id": "call_search",
"output": output["output"],
}, output)
outputText := requireResponsesClientToolValue[string](t, output["output"])
require.JSONEq(t, `[
{"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","description":"Load workspace dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]},
{"type":"namespace","name":"multi_agent_v1","tools":[
{"type":"function","name":"spawn_agent","description":"Spawn an agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}},
{"type":"function","name":"wait_agent","description":"Wait for agents","parameters":{"type":"object","properties":{},"additionalProperties":false}}
]}
]`, outputText)
secondOutput := requireResponsesClientToolValue[map[string]any](t, secondInput[1])
require.Equal(t, outputText, secondOutput["output"], "tool discovery output encoding must be deterministic")
restored, changed, err := RestoreResponsesClientToolPayload(
[]byte(`{"output":[{"type":"function_call","name":"multi_agent_v1__spawn_agent","call_id":"call_spawn","arguments":"{\"message\":\"work\"}"}]}`),
first.mapping,
)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"output":[{"type":"function_call","name":"spawn_agent","namespace":"multi_agent_v1","call_id":"call_spawn","arguments":"{\"message\":\"work\"}"}]}`, string(restored))
}
func TestAdaptResponsesClientTools_PromotesDirectDiscoveryAndDeduplicatesIdenticalDeclarations(t *testing.T) {
direct := map[string]any{
"type": "function", "name": "inspect_result", "description": "Inspect a result",
"parameters": map[string]any{"type": "object", "properties": map[string]any{}},
}
custom := map[string]any{
"type": "custom", "name": "run_script", "description": "Run a script",
"format": map[string]any{"type": "grammar"},
}
namespace := map[string]any{
"type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{
"type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"},
}},
}
req := map[string]any{
"tools": []any{
map[string]any{"type": "function", "name": "static_first", "parameters": map[string]any{"type": "object"}},
map[string]any{"type": "tool_search"},
},
"input": []any{
map[string]any{"type": "tool_search_output", "status": "completed", "call_id": "search_1", "tools": []any{direct, custom, namespace}},
map[string]any{"type": "tool_search_output", "status": "completed", "call_id": "search_2", "tools": []any{copyClientTool(direct), copyClientTool(custom), copyClientTool(namespace)}},
},
}
mapping, changed, err := AdaptResponsesClientTools(req)
require.NoError(t, err)
require.True(t, changed)
require.True(t, mapping.CustomTools["run_script"])
require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"])
tools := requireResponsesClientToolValue[[]any](t, req["tools"])
require.Equal(t, []string{"static_first", "tool_search", "inspect_result", "run_script", "multi_agent_v1__spawn_agent"}, responsesClientToolNames(t, tools))
customTool := requireResponsesClientToolValue[map[string]any](t, tools[3])
require.Equal(t, "function", customTool["type"])
require.NotContains(t, customTool, "format")
for _, raw := range requireResponsesClientToolValue[[]any](t, req["input"]) {
item := requireResponsesClientToolValue[map[string]any](t, raw)
require.Equal(t, "function_call_output", item["type"])
require.NotContains(t, item, "tools")
require.NotContains(t, item, "status")
}
}
func TestAdaptResponsesClientTools_RejectsDiscoveredSchemaAndNamespaceCollisions(t *testing.T) {
tests := []struct {
name string
staticTools []any
discovered []any
}{
{
name: "direct schema collision",
staticTools: []any{map[string]any{
"type": "function", "name": "inspect", "parameters": map[string]any{"type": "object"},
}},
discovered: []any{map[string]any{
"type": "function", "name": "inspect", "parameters": map[string]any{"type": "string"},
}},
},
{
name: "namespace schema collision",
staticTools: []any{map[string]any{
"type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{
"type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"},
}},
}},
discovered: []any{map[string]any{
"type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{
"type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "string"},
}},
}},
},
{
name: "flattened namespace collision",
staticTools: []any{map[string]any{"type": "function", "name": "multi_agent_v1__spawn_agent"}},
discovered: []any{map[string]any{
"type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{"type": "function", "name": "spawn_agent"}},
}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := map[string]any{
"tools": append(tt.staticTools, map[string]any{"type": "tool_search"}),
"input": []any{map[string]any{
"type": "tool_search_output", "status": "completed", "tools": tt.discovered,
}},
}
_, _, err := AdaptResponsesClientTools(req)
require.ErrorContains(t, err, "conflicts")
})
}
}
func TestAdaptResponsesClientTools_DoesNotPromoteUnusableDiscoveries(t *testing.T) {
req := map[string]any{
"tools": []any{map[string]any{"type": "tool_search"}},
"input": []any{
map[string]any{"type": "tool_search_output", "call_id": "search_in_progress", "status": "in_progress", "tools": []any{map[string]any{"type": "function", "name": "not_ready"}}},
map[string]any{"type": "tool_search_output", "call_id": "search_malformed", "status": "completed", "tools": []any{map[string]any{"type": "function"}}},
},
}
_, changed, err := AdaptResponsesClientTools(req)
require.NoError(t, err)
require.True(t, changed, "the static tool_search declaration is still lowered")
tools := requireResponsesClientToolValue[[]any](t, req["tools"])
require.Equal(t, []string{"tool_search"}, responsesClientToolNames(t, tools))
}
func responsesClientToolNames(t *testing.T, tools []any) []string {
t.Helper()
names := make([]string, 0, len(tools))
for _, raw := range tools {
tool := requireResponsesClientToolValue[map[string]any](t, raw)
names = append(names, requireResponsesClientToolValue[string](t, tool["name"]))
}
return names
}
func TestAdaptResponsesClientTools_ToolSearchOutputEdgeCases(t *testing.T) {
unencodableOutput := make(chan struct{})
tests := []struct {
name string
item map[string]any
wantOutput any
wantOutputExists bool
wantPrivateKeys []string
wantExactOutput bool
wantErr bool
}{
{
name: "absent tools and output is rejected",
item: map[string]any{"type": "tool_search_output", "call_id": "call_empty", "status": "completed"},
wantOutputExists: false,
wantErr: true,
},
{
name: "preexisting string output wins",
item: map[string]any{
"type": "tool_search_output", "call_id": "call_legacy", "output": "legacy",
"tools": []any{map[string]any{"type": "function", "name": "ignored"}}, "execution": "client",
},
wantOutput: "legacy",
wantOutputExists: true,
wantExactOutput: true,
},
{
name: "preexisting object output remains legacy representation",
item: map[string]any{
"type": "tool_search_output", "call_id": "call_object", "output": map[string]any{"groups": []any{"github"}},
"tools": []any{map[string]any{"type": "function", "name": "ignored"}},
},
wantOutput: `{"groups":["github"]}`,
wantOutputExists: true,
wantExactOutput: true,
},
{
name: "unencodable preexisting output is rejected",
item: map[string]any{
"type": "tool_search_output", "call_id": "call_bad_output", "output": unencodableOutput,
"tools": []any{map[string]any{"type": "function", "name": "retained"}}, "status": "completed", "execution": "client",
},
wantOutput: unencodableOutput,
wantErr: true,
},
{
name: "empty tools array is a valid empty output",
item: map[string]any{
"type": "tool_search_output", "call_id": "call_empty_tools",
"tools": []any{}, "status": "completed", "execution": "client",
},
wantOutput: `[]`,
wantOutputExists: true,
wantExactOutput: true,
},
{
name: "non-array tools value is serialized directly",
item: map[string]any{
"type": "tool_search_output", "call_id": "call_malformed",
"tools": map[string]any{"unexpected": true}, "status": "completed", "execution": "client",
},
wantOutput: `{"unexpected":true}`,
wantOutputExists: true,
wantExactOutput: true,
},
{
name: "unencodable tools is rejected",
item: map[string]any{
"type": "tool_search_output", "call_id": "call_unencodable", "tools": make(chan struct{}), "status": "completed",
},
wantErr: true,
},
{
name: "missing call id is rejected",
item: map[string]any{
"type": "tool_search_output", "tools": []any{}, "status": "completed",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := map[string]any{
"tools": []any{map[string]any{"type": "tool_search"}},
"input": []any{tt.item},
}
_, changed, err := AdaptResponsesClientTools(req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.True(t, changed)
input := requireResponsesClientToolValue[[]any](t, req["input"])
output := requireResponsesClientToolValue[map[string]any](t, input[0])
require.Equal(t, "function_call_output", output["type"])
actualOutput, outputExists := output["output"]
require.Equal(t, tt.wantOutputExists, outputExists)
if tt.wantOutputExists {
require.Equal(t, tt.wantOutput, actualOutput)
}
if tt.wantExactOutput {
require.Equal(t, map[string]any{
"type": "function_call_output",
"call_id": output["call_id"],
"output": tt.wantOutput,
}, output)
}
if len(tt.wantPrivateKeys) > 0 {
for _, key := range tt.wantPrivateKeys {
require.Contains(t, output, key)
}
} else {
require.NotContains(t, output, "tools")
require.NotContains(t, output, "status")
require.NotContains(t, output, "execution")
}
})
}
}
func requireResponsesClientToolValue[T any](t *testing.T, value any) T {
t.Helper()
typed, ok := value.(T)
require.True(t, ok, "unexpected value type %T", value)
return typed
}
func TestAdaptResponsesClientTools_RejectsAmbiguousNames(t *testing.T) {
cases := []map[string]any{
{"tools": []any{map[string]any{"type": "custom", "name": "same"}, map[string]any{"type": "function", "name": "same"}}},
{"tools": []any{map[string]any{"type": "tool_search"}, map[string]any{"type": "function", "name": "tool_search"}}},
{"tools": []any{map[string]any{"type": "function", "name": "team__send"}, map[string]any{"type": "namespace", "name": "team", "tools": []any{map[string]any{"type": "function", "name": "send"}}}}},
}
for _, req := range cases {
_, _, err := AdaptResponsesClientTools(req)
require.Error(t, err)
}
}
func TestAdaptResponsesClientToolsWithInheritedMapping_LowersFollowupHistoryWithoutTools(t *testing.T) {
req := map[string]any{
"input": []any{
map[string]any{
"type": "custom_tool_call", "name": "exec",
"call_id": "call_1", "input": "pwd",
},
map[string]any{
"type": "custom_tool_call_output", "call_id": "call_1",
"id": "ctco_client_output_1",
"output": []any{map[string]any{"type": "input_text", "text": "ok"}},
},
},
}
inherited := ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}}
mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(req, inherited)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, inherited, mapping)
items := requireResponsesClientToolValue[[]any](t, req["input"])
call := requireResponsesClientToolValue[map[string]any](t, items[0])
require.Equal(t, "function_call", call["type"])
require.JSONEq(t, `{"input":"pwd"}`, requireResponsesClientToolValue[string](t, call["arguments"]))
require.NotContains(t, call, "input")
output := requireResponsesClientToolValue[map[string]any](t, items[1])
require.Equal(t, "function_call_output", output["type"])
require.NotContains(t, output, "id")
require.JSONEq(t, `[{"text":"ok","type":"input_text"}]`, requireResponsesClientToolValue[string](t, output["output"]))
}
func TestAdaptResponsesClientToolsWithInheritedMapping_PromotesOmittedToolsDiscoveryIntoEffectiveDeclarations(t *testing.T) {
req := map[string]any{
"input": []any{map[string]any{
"type": "tool_search_output", "call_id": "call_search", "status": "completed", "execution": "client",
"tools": []any{map[string]any{
"type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{
"type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"},
}},
}},
}},
}
inherited := ResponsesClientToolMapping{
ToolSearch: true,
NamespaceTools: map[string]ResponsesNamespaceName{
"codex_app__read_resource": {Namespace: "codex_app", Name: "read_resource"},
},
}
lowered := []any{
map[string]any{"type": "function", "name": "static_first", "parameters": map[string]any{"type": "object"}},
map[string]any{"type": "function", "name": "tool_search", "parameters": json.RawMessage(toolSearchProxySchema)},
map[string]any{"type": "function", "name": "codex_app__read_resource", "parameters": map[string]any{"type": "object"}},
}
mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(req, inherited, lowered)
require.NoError(t, err)
require.True(t, changed)
require.True(t, mapping.ToolSearch)
require.Equal(t, ResponsesNamespaceName{Namespace: "codex_app", Name: "read_resource"}, mapping.NamespaceTools["codex_app__read_resource"])
require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"])
tools := requireResponsesClientToolValue[[]any](t, req["tools"])
require.Equal(t, []string{
"static_first", "tool_search", "codex_app__read_resource", "multi_agent_v1__spawn_agent",
}, responsesClientToolNames(t, tools))
output := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0])
require.Equal(t, "function_call_output", output["type"])
require.IsType(t, "", output["output"])
require.NotContains(t, output, "tools")
require.NotContains(t, output, "status")
require.NotContains(t, output, "execution")
}
func TestAdaptResponsesClientToolsWithInheritedMapping_ExplicitToolsReplaceInheritedMapping(t *testing.T) {
req := map[string]any{
"tools": []any{},
"input": []any{map[string]any{
"type": "custom_tool_call", "name": "exec", "input": "pwd",
}},
}
mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(
req,
ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}},
)
require.NoError(t, err)
require.False(t, changed)
require.Empty(t, mapping)
items := requireResponsesClientToolValue[[]any](t, req["input"])
call := requireResponsesClientToolValue[map[string]any](t, items[0])
require.Equal(t, "custom_tool_call", call["type"])
}
func TestAdaptResponsesClientToolsWithInheritedMapping_ExplicitToolResetDoesNotPromoteDiscovery(t *testing.T) {
for _, reset := range []any{nil, []any{}} {
req := map[string]any{
"tools": reset,
"input": []any{map[string]any{
"type": "tool_search_output", "call_id": "call_reset", "status": "completed",
"tools": []any{map[string]any{"type": "function", "name": "must_not_promote"}},
}},
}
mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(
req,
ResponsesClientToolMapping{ToolSearch: true},
[]any{map[string]any{"type": "function", "name": "tool_search"}},
)
require.NoError(t, err)
require.False(t, changed)
require.Empty(t, mapping)
item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0])
require.Equal(t, "tool_search_output", item["type"])
}
}
func TestRestoreResponsesClientToolPayload_RestoresClientAndNamespaceCalls(t *testing.T) {
mapping := ResponsesClientToolMapping{
CustomTools: map[string]bool{"exec": true}, ToolSearch: true,
NamespaceTools: map[string]ResponsesNamespaceName{"team__send": {Namespace: "team", Name: "send"}},
}
payload := []byte(`{"id":"resp","output":[{"type":"function_call","id":"i1","call_id":"c1","name":"exec","arguments":"{\"input\":\"dir\"}","namespace":"ignore"},{"type":"function_call","id":"i2","call_id":"s1","name":"tool_search","arguments":"{\"query\":\"git\"}"},{"type":"function_call","id":"i3","call_id":"n1","name":"team__send","arguments":"{}"}]}`)
restored, changed, err := RestoreResponsesClientToolPayload(payload, mapping)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"id":"resp","output":[{"type":"custom_tool_call","id":"i1","call_id":"c1","name":"exec","input":"dir"},{"type":"tool_search_call","id":"i2","call_id":"s1","execution":"client","arguments":{"query":"git"}},{"type":"function_call","id":"i3","call_id":"n1","name":"send","namespace":"team","arguments":"{}"}]}`, string(restored))
}
func TestResponsesClientToolStreamRestorer_CustomToolBuffersWrapperAndSequences(t *testing.T) {
restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}})
added := restorer.Restore(ResponsesStreamEvent{Type: "response.output_item.added", SequenceNumber: 7, OutputIndex: 0, Item: &ResponsesOutput{Type: "function_call", ID: "i1", CallID: "c1", Name: "exec", Status: "in_progress"}})
require.Len(t, added, 1)
require.Equal(t, 7, added[0].SequenceNumber)
require.Equal(t, "custom_tool_call", added[0].Item.Type)
require.Empty(t, restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.delta", SequenceNumber: 8, ItemID: "i1", Delta: `{"input":"di`}))
done := restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.done", SequenceNumber: 9, ItemID: "i1", CallID: "c1", Name: "exec", Arguments: `{"input":"dir"}`})
require.Len(t, done, 2)
require.Equal(t, 8, done[0].SequenceNumber)
require.Equal(t, "response.custom_tool_call_input.delta", done[0].Type)
require.Equal(t, "dir", done[0].Delta)
require.Equal(t, 9, done[1].SequenceNumber)
require.Equal(t, "response.custom_tool_call_input.done", done[1].Type)
require.Equal(t, "dir", done[1].Input)
closed := restorer.Restore(ResponsesStreamEvent{Type: "response.output_item.done", SequenceNumber: 10, OutputIndex: 0, Item: &ResponsesOutput{Type: "function_call", ID: "i1", CallID: "c1", Name: "exec", Arguments: `{"input":"dir"}`, Status: "completed"}})
require.Equal(t, 10, closed[0].SequenceNumber)
require.Equal(t, "custom_tool_call", closed[0].Item.Type)
require.Equal(t, "dir", closed[0].Item.Input)
}
func TestResponsesClientToolStreamRestorer_ToolSearchAndFunction(t *testing.T) {
restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{ToolSearch: true})
search := restorer.Restore(ResponsesStreamEvent{Type: "response.output_item.added", SequenceNumber: 0, OutputIndex: 0, Item: &ResponsesOutput{Type: "function_call", ID: "s1", CallID: "c1", Name: "tool_search", Status: "in_progress"}})
require.Equal(t, "tool_search_call", search[0].Item.Type)
require.Empty(t, restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.delta", SequenceNumber: 1, ItemID: "s1", Delta: `{"query":"git"}`}))
require.Empty(t, restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.done", SequenceNumber: 2, ItemID: "s1", Arguments: `{"query":"git"}`}))
closed := restorer.Restore(ResponsesStreamEvent{Type: "response.output_item.done", SequenceNumber: 3, OutputIndex: 0, Item: &ResponsesOutput{Type: "function_call", ID: "s1", CallID: "c1", Name: "tool_search", Status: "completed"}})
require.Equal(t, 1, closed[0].SequenceNumber)
require.Equal(t, "tool_search_call", closed[0].Item.Type)
require.JSONEq(t, `{"query":"git"}`, string(toolSearchCallArgumentsJSON(closed[0].Item.Arguments)))
function := restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.done", SequenceNumber: 4, ItemID: "plain", Name: "plain", Arguments: "{}"})
require.Len(t, function, 1)
require.Equal(t, "response.function_call_arguments.done", function[0].Type)
require.Equal(t, 2, function[0].SequenceNumber)
}
func TestResponsesClientToolStreamRestorer_RestoresNamespaceLifecycle(t *testing.T) {
restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{
NamespaceTools: map[string]ResponsesNamespaceName{
"browser__open": {Namespace: "browser", Name: "open"},
},
})
added, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.output_item.added","sequence_number":4,"output_index":0,"item":{"type":"function_call","id":"i1","call_id":"c1","name":"browser__open","arguments":"","status":"in_progress"}}`))
require.NoError(t, err)
require.True(t, changed)
require.Len(t, added, 1)
require.Equal(t, "open", gjson.GetBytes(added[0], "item.name").String())
require.Equal(t, "browser", gjson.GetBytes(added[0], "item.namespace").String())
delta, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.function_call_arguments.delta","sequence_number":5,"output_index":0,"item_id":"i1","name":"browser__open","delta":"{\"url\":"}`))
require.NoError(t, err)
require.True(t, changed)
require.Len(t, delta, 1)
require.Equal(t, "open", gjson.GetBytes(delta[0], "name").String())
done, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.function_call_arguments.done","sequence_number":6,"output_index":0,"item_id":"i1","name":"browser__open","arguments":"{}"}`))
require.NoError(t, err)
require.True(t, changed)
require.Len(t, done, 1)
require.Equal(t, "open", gjson.GetBytes(done[0], "name").String())
}
func TestResponsesClientToolStreamRestorer_RawEventsPreserveUnknownFieldsAndOutputFallback(t *testing.T) {
restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}})
passthrough, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.created","sequence_number":4,"response":{"id":"r"},"upstream_extension":{"keep":true}}`))
require.NoError(t, err)
require.False(t, changed)
require.Len(t, passthrough, 1)
require.Contains(t, string(passthrough[0]), `"upstream_extension":{"keep":true}`)
restorer.Restore(ResponsesStreamEvent{Type: "response.output_item.added", SequenceNumber: 5, OutputIndex: 9, Item: &ResponsesOutput{Type: "function_call", ID: "item", CallID: "call", Name: "exec"}})
// Some upstreams omit every tool identity field on later argument chunks.
require.Empty(t, restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.delta", SequenceNumber: 6, OutputIndex: 9, Delta: `{"input":"pwd"}`}))
done := restorer.Restore(ResponsesStreamEvent{Type: "response.function_call_arguments.done", SequenceNumber: 7, OutputIndex: 9})
require.Len(t, done, 2)
require.Equal(t, "pwd", done[1].Input)
}
func TestResponsesClientToolStreamRestorer_RestoresAllTerminalEvents(t *testing.T) {
for _, eventType := range []string{
"response.completed",
"response.done",
"response.incomplete",
"response.failed",
"response.cancelled",
"response.canceled",
} {
t.Run(eventType, func(t *testing.T) {
restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}})
payload := []byte(`{"type":"` + eventType + `","sequence_number":7,"response":{"id":"resp_tools","output":[{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}"}]}}`)
restored, changed, err := restorer.RestoreEvent(payload)
require.NoError(t, err)
require.True(t, changed)
require.Len(t, restored, 1)
require.Equal(t, eventType, gjson.GetBytes(restored[0], "type").String())
require.Equal(t, int64(7), gjson.GetBytes(restored[0], "sequence_number").Int())
require.Equal(t, "custom_tool_call", gjson.GetBytes(restored[0], "response.output.0.type").String())
require.Equal(t, "pwd", gjson.GetBytes(restored[0], "response.output.0.input").String())
require.False(t, gjson.GetBytes(restored[0], "response.output.0.arguments").Exists())
})
}
}
@@ -0,0 +1,212 @@
package apicompat
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
// ResponsesNamespaceName identifies a function child in a Responses namespace.
// It aliases the chat bridge mapping so both native and bridged paths share one
// namespace identity contract.
type ResponsesNamespaceName = NamespacedToolName
// FlattenResponsesNamespaces converts Codex private namespace declarations into
// public Responses function tools and rewrites namespace-qualified request calls.
func FlattenResponsesNamespaces(req map[string]any) (map[string]ResponsesNamespaceName, bool, error) {
return FlattenResponsesNamespacesExcept(req, nil)
}
// FlattenResponsesNamespacesExcept is FlattenResponsesNamespaces with a set of
// service-owned namespace names that must remain native in the request.
func FlattenResponsesNamespacesExcept(req map[string]any, preserved map[string]bool) (map[string]ResponsesNamespaceName, bool, error) {
if req == nil {
return nil, false, nil
}
tools, ok := req["tools"].([]any)
if !ok || len(tools) == 0 {
return nil, false, nil
}
topLevel := make(map[string]bool)
for _, raw := range tools {
tool, ok := raw.(map[string]any)
if !ok {
continue
}
typ := strings.TrimSpace(stringValue(tool["type"]))
name := strings.TrimSpace(stringValue(tool["name"]))
if (typ == "function" || typ == "custom") && name != "" {
topLevel[name] = true
}
}
names := make(map[string]ResponsesNamespaceName)
for _, raw := range tools {
tool, ok := raw.(map[string]any)
if !ok || strings.TrimSpace(stringValue(tool["type"])) != "namespace" {
continue
}
namespace := strings.TrimSpace(stringValue(tool["name"]))
if namespace == "" || preserved[namespace] {
continue
}
for _, rawChild := range namespaceChildren(tool) {
child, ok := rawChild.(map[string]any)
if !ok || strings.TrimSpace(stringValue(child["type"])) != "function" {
continue
}
name := strings.TrimSpace(stringValue(child["name"]))
if name == "" {
continue
}
flat := flattenNamespaceToolName(namespace, name)
entry := ResponsesNamespaceName{Namespace: namespace, Name: name}
if topLevel[flat] {
return nil, false, fmt.Errorf("namespace tool %q/%q flattens to %q which conflicts with a top-level tool of the same name; this upstream cannot disambiguate them, rename one of the tools", namespace, name, flat)
}
if prev, exists := names[flat]; exists && prev != entry {
return nil, false, fmt.Errorf("namespace tools %q/%q and %q/%q both flatten to %q; this upstream cannot disambiguate them, rename one of the tools", prev.Namespace, prev.Name, namespace, name, flat)
}
names[flat] = entry
}
}
if len(names) == 0 {
return nil, false, nil
}
flattened := make([]any, 0, len(tools)+len(names))
seen := make(map[string]bool)
for _, raw := range tools {
tool, ok := raw.(map[string]any)
if !ok || strings.TrimSpace(stringValue(tool["type"])) != "namespace" {
flattened = append(flattened, raw)
continue
}
namespace := strings.TrimSpace(stringValue(tool["name"]))
if preserved[namespace] {
flattened = append(flattened, raw)
continue
}
for _, rawChild := range namespaceChildren(tool) {
child, ok := rawChild.(map[string]any)
if !ok || strings.TrimSpace(stringValue(child["type"])) != "function" {
continue
}
name := strings.TrimSpace(stringValue(child["name"]))
flat := flattenNamespaceToolName(namespace, name)
if name == "" || seen[flat] {
continue
}
seen[flat] = true
flatChild := make(map[string]any, len(child))
for key, value := range child {
flatChild[key] = value
}
flatChild["name"] = flat
flattened = append(flattened, flatChild)
}
}
req["tools"] = flattened
rewriteNamespaceQualifiedCalls(req["input"], names)
if choice, ok := req["tool_choice"].(map[string]any); ok {
choiceNamespace := strings.TrimSpace(stringValue(choice["name"]))
if strings.TrimSpace(stringValue(choice["type"])) == "namespace" && !preserved[choiceNamespace] {
req["tool_choice"] = "auto"
} else {
rewriteNamespaceQualifiedCall(choice, names)
}
}
return names, true, nil
}
// RestoreResponsesNamespaceCalls restores flattened function calls in a JSON
// Responses payload to the namespace/name identity expected by Codex.
func RestoreResponsesNamespaceCalls(payload []byte, names map[string]ResponsesNamespaceName) ([]byte, bool, error) {
if len(payload) == 0 || len(names) == 0 {
return payload, false, nil
}
var value any
if err := json.Unmarshal(payload, &value); err != nil {
return payload, false, err
}
changed := restoreResponsesNamespaceValue(value, names)
if !changed {
return payload, false, nil
}
var rebuilt bytes.Buffer
encoder := json.NewEncoder(&rebuilt)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return payload, false, err
}
return bytes.TrimSuffix(rebuilt.Bytes(), []byte("\n")), true, nil
}
func namespaceChildren(tool map[string]any) []any {
if children, ok := tool["tools"].([]any); ok && len(children) > 0 {
return children
}
children, _ := tool["children"].([]any)
return children
}
func rewriteNamespaceQualifiedCalls(value any, names map[string]ResponsesNamespaceName) {
switch typed := value.(type) {
case []any:
for _, item := range typed {
rewriteNamespaceQualifiedCalls(item, names)
}
case map[string]any:
if strings.TrimSpace(stringValue(typed["type"])) == "function_call" {
rewriteNamespaceQualifiedCall(typed, names)
}
for _, child := range typed {
rewriteNamespaceQualifiedCalls(child, names)
}
}
}
func rewriteNamespaceQualifiedCall(item map[string]any, names map[string]ResponsesNamespaceName) bool {
namespace := strings.TrimSpace(stringValue(item["namespace"]))
name := strings.TrimSpace(stringValue(item["name"]))
if namespace == "" || name == "" {
return false
}
flat := flattenNamespaceToolName(namespace, name)
entry, ok := names[flat]
if !ok || entry.Namespace != namespace || entry.Name != name {
return false
}
item["name"] = flat
delete(item, "namespace")
return true
}
func restoreResponsesNamespaceValue(value any, names map[string]ResponsesNamespaceName) bool {
changed := false
switch typed := value.(type) {
case []any:
for _, item := range typed {
changed = restoreResponsesNamespaceValue(item, names) || changed
}
case map[string]any:
if strings.TrimSpace(stringValue(typed["type"])) == "function_call" {
if entry, ok := names[strings.TrimSpace(stringValue(typed["name"]))]; ok {
typed["name"] = entry.Name
typed["namespace"] = entry.Namespace
changed = true
}
}
for _, child := range typed {
changed = restoreResponsesNamespaceValue(child, names) || changed
}
}
return changed
}
func stringValue(value any) string {
text, _ := value.(string)
return text
}
@@ -0,0 +1,164 @@
package apicompat
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestFlattenResponsesNamespaces_RewritesDeclarationHistoryAndChoice(t *testing.T) {
req := map[string]any{
"model": "gpt-5.5",
"tools": []any{
map[string]any{"type": "function", "name": "plain", "description": "keep"},
map[string]any{
"type": "namespace",
"name": "collaboration",
"tools": []any{
map[string]any{"type": "function", "name": "spawn_agent", "description": "spawn", "parameters": map[string]any{"type": "object"}},
},
},
},
"tool_choice": map[string]any{"type": "function", "name": "spawn_agent", "namespace": "collaboration"},
"input": []any{
map[string]any{"type": "function_call", "call_id": "call_1", "name": "spawn_agent", "namespace": "collaboration", "arguments": "{}"},
map[string]any{"type": "message", "role": "user", "content": "hi", "name": "spawn_agent", "namespace": "collaboration"},
},
}
names, changed, err := FlattenResponsesNamespaces(req)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, ResponsesNamespaceName{Namespace: "collaboration", Name: "spawn_agent"}, names["collaboration__spawn_agent"])
tools, ok := req["tools"].([]any)
require.True(t, ok)
require.Len(t, tools, 2)
plainTool, ok := tools[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "plain", plainTool["name"])
flatTool, ok := tools[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "collaboration__spawn_agent", flatTool["name"])
require.Equal(t, "spawn", flatTool["description"])
choice, ok := req["tool_choice"].(map[string]any)
require.True(t, ok)
require.Equal(t, "collaboration__spawn_agent", choice["name"])
require.NotContains(t, choice, "namespace")
input, ok := req["input"].([]any)
require.True(t, ok)
require.Len(t, input, 2)
call, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "collaboration__spawn_agent", call["name"])
require.NotContains(t, call, "namespace")
message, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "spawn_agent", message["name"])
require.Equal(t, "collaboration", message["namespace"])
require.Equal(t, "gpt-5.5", req["model"])
}
func TestFlattenResponsesNamespaces_RejectsFlatNameCollision(t *testing.T) {
req := map[string]any{"tools": []any{
map[string]any{"type": "function", "name": "collaboration__spawn_agent"},
map[string]any{"type": "namespace", "name": "collaboration", "tools": []any{
map[string]any{"type": "function", "name": "spawn_agent"},
}},
}}
_, _, err := FlattenResponsesNamespaces(req)
require.ErrorContains(t, err, "conflicts with a top-level tool")
}
func TestFlattenResponsesNamespaces_NamespaceGroupChoiceFallsBackToAuto(t *testing.T) {
req := map[string]any{
"tools": []any{map[string]any{
"type": "namespace", "name": "collaboration", "tools": []any{
map[string]any{"type": "function", "name": "spawn_agent"},
map[string]any{"type": "function", "name": "send_message"},
},
}},
"tool_choice": map[string]any{"type": "namespace", "name": "collaboration"},
}
_, changed, err := FlattenResponsesNamespaces(req)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "auto", req["tool_choice"])
}
func TestFlattenResponsesNamespacesExcept_PreservesBuiltInNamespaceAndChoice(t *testing.T) {
req := map[string]any{
"tools": []any{
map[string]any{"type": "namespace", "name": "image_gen", "tools": []any{
map[string]any{"type": "function", "name": "imagegen"},
}},
map[string]any{"type": "namespace", "name": "collaboration", "tools": []any{
map[string]any{"type": "function", "name": "spawn_agent"},
}},
},
"tool_choice": map[string]any{"type": "namespace", "name": "image_gen"},
}
names, changed, err := FlattenResponsesNamespacesExcept(req, map[string]bool{"image_gen": true})
require.NoError(t, err)
require.True(t, changed)
require.Contains(t, names, "collaboration__spawn_agent")
tools, ok := req["tools"].([]any)
require.True(t, ok)
require.Len(t, tools, 2)
preservedTool, ok := tools[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "namespace", preservedTool["type"])
require.Equal(t, "image_gen", preservedTool["name"])
flatTool, ok := tools[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "function", flatTool["type"])
require.Equal(t, "collaboration__spawn_agent", flatTool["name"])
require.Equal(t, map[string]any{"type": "namespace", "name": "image_gen"}, req["tool_choice"])
}
func TestFlattenResponsesNamespaces_RejectsNamespaceCollision(t *testing.T) {
req := map[string]any{"tools": []any{
map[string]any{"type": "namespace", "name": "a", "tools": []any{
map[string]any{"type": "function", "name": "b__c"},
}},
map[string]any{"type": "namespace", "name": "a__b", "tools": []any{
map[string]any{"type": "function", "name": "c"},
}},
}}
_, _, err := FlattenResponsesNamespaces(req)
require.ErrorContains(t, err, "both flatten")
}
func TestRestoreResponsesNamespaceCalls_RewritesOnlyFunctionCalls(t *testing.T) {
payload := []byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","name":"collaboration__spawn_agent","call_id":"call_1","arguments":"{}","extra":"keep"},{"type":"function_call","name":"plain","arguments":"{}"},{"type":"message","name":"collaboration__spawn_agent","content":"<tag>&value</tag>"}]}}`)
names := map[string]ResponsesNamespaceName{
"collaboration__spawn_agent": {Namespace: "collaboration", Name: "spawn_agent"},
}
got, changed, err := RestoreResponsesNamespaceCalls(payload, names)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"type":"response.completed","response":{"output":[{"type":"function_call","name":"spawn_agent","namespace":"collaboration","call_id":"call_1","arguments":"{}","extra":"keep"},{"type":"function_call","name":"plain","arguments":"{}"},{"type":"message","name":"collaboration__spawn_agent","content":"<tag>&value</tag>"}]}}`, string(got))
require.Contains(t, string(got), "<tag>&value</tag>")
require.NotContains(t, string(got), `\u003c`)
}
func TestRestoreResponsesNamespaceCalls_RewritesLifecycleItems(t *testing.T) {
for _, eventType := range []string{"response.output_item.added", "response.output_item.done"} {
t.Run(eventType, func(t *testing.T) {
payload := []byte(`{"type":"` + eventType + `","item":{"type":"function_call","name":"collaboration__spawn_agent","arguments":"{}"}}`)
got, changed, err := RestoreResponsesNamespaceCalls(payload, map[string]ResponsesNamespaceName{
"collaboration__spawn_agent": {Namespace: "collaboration", Name: "spawn_agent"},
})
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"type":"`+eventType+`","item":{"type":"function_call","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}}`, string(got))
})
}
}
@@ -0,0 +1,233 @@
package apicompat
import "encoding/json"
// MarshalJSON renders a ResponsesStreamEvent into its wire form.
//
// The OpenAI Responses streaming protocol requires several fields to be present
// even when they hold a zero value: output_index/content_index/summary_index are
// meaningful at 0, a function_call item must always carry call_id/name/arguments
// (arguments may be ""), a message item must carry content:[] and an output_text
// part must carry text/annotations/logprobs. Go's `omitempty` drops exactly those
// zero values, and strict clients (Codex CLI) reject items/deltas whose required
// fields are missing.
//
// Rather than marshalling with omitempty and patching the JSON afterwards, every
// streamed event type is constructed explicitly here — the Go analogue of the
// reference gateways' (cc-switch, CCX) per-event object construction. This is the
// single source of truth for Responses SSE field presence and applies uniformly
// to every emitter (Chat→Responses bridge and Anthropic→Responses converter).
//
// Event types not listed fall back to the default struct marshalling, which
// bounds the blast radius of this method to the streamed item/part/text/tool
// events.
func (e ResponsesStreamEvent) MarshalJSON() ([]byte, error) {
switch e.Type {
case "response.output_text.delta", "response.output_text.done":
m := e.wireBase()
e.putItemID(m)
m["output_index"] = e.OutputIndex
m["content_index"] = e.ContentIndex
if e.Type == "response.output_text.done" {
m["text"] = e.Text
} else {
m["delta"] = e.Delta
}
return json.Marshal(m)
case "response.content_part.added", "response.content_part.done":
m := e.wireBase()
e.putItemID(m)
m["output_index"] = e.OutputIndex
m["content_index"] = e.ContentIndex
m["part"] = outputTextPartWire(e.Part)
return json.Marshal(m)
case "response.reasoning_summary_text.delta", "response.reasoning_summary_text.done":
m := e.wireBase()
e.putItemID(m)
m["output_index"] = e.OutputIndex
m["summary_index"] = e.SummaryIndex
if e.Type == "response.reasoning_summary_text.done" {
m["text"] = e.Text
} else {
m["delta"] = e.Delta
}
return json.Marshal(m)
case "response.reasoning_summary_part.added", "response.reasoning_summary_part.done":
m := e.wireBase()
e.putItemID(m)
m["output_index"] = e.OutputIndex
m["summary_index"] = e.SummaryIndex
m["part"] = summaryTextPartWire(e.Part)
return json.Marshal(m)
case "response.output_item.added", "response.output_item.done":
m := e.wireBase()
m["output_index"] = e.OutputIndex
m["item"] = responsesItemWire(e.Item)
return json.Marshal(m)
case "response.function_call_arguments.delta", "response.function_call_arguments.done":
m := e.wireBase()
e.putItemID(m)
m["output_index"] = e.OutputIndex
if e.CallID != "" {
m["call_id"] = e.CallID
}
if e.Name != "" {
m["name"] = e.Name
}
if e.Type == "response.function_call_arguments.done" {
m["arguments"] = e.Arguments
} else {
m["delta"] = e.Delta
}
return json.Marshal(m)
case "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done":
m := e.wireBase()
e.putItemID(m)
m["output_index"] = e.OutputIndex
if e.CallID != "" {
m["call_id"] = e.CallID
}
if e.Name != "" {
m["name"] = e.Name
}
if e.Type == "response.custom_tool_call_input.done" {
m["input"] = e.Input
} else {
m["delta"] = e.Delta
}
return json.Marshal(m)
default:
// response.created / completed / done / failed / incomplete and any
// event type not shaped above keep the default struct marshalling.
type alias ResponsesStreamEvent
return json.Marshal(alias(e))
}
}
func (e ResponsesStreamEvent) wireBase() map[string]any {
m := map[string]any{
"type": e.Type,
"sequence_number": e.SequenceNumber,
}
return m
}
func (e ResponsesStreamEvent) putItemID(m map[string]any) {
if e.ItemID != "" {
m["item_id"] = e.ItemID
}
}
// outputTextPartWire renders a content part for a message's output_text, always
// carrying text/annotations/logprobs (matching cc-switch's push_text_delta).
func outputTextPartWire(part *ResponsesContentPart) map[string]any {
text := ""
if part != nil {
text = part.Text
}
return map[string]any{
"type": "output_text",
"text": text,
"annotations": []any{},
"logprobs": []any{},
}
}
// summaryTextPartWire renders a reasoning summary part.
func summaryTextPartWire(part *ResponsesContentPart) map[string]any {
text := ""
if part != nil {
text = part.Text
}
return map[string]any{
"type": "summary_text",
"text": text,
}
}
// responsesItemWire renders an output_item with every field the item's type
// requires to be present, including the empty arrays/strings that omitempty
// would otherwise drop. Mirrors cc-switch's response_function_call_item and the
// message/reasoning item shapes codex expects.
func responsesItemWire(item *ResponsesOutput) map[string]any {
if item == nil {
return map[string]any{}
}
m := map[string]any{
"type": item.Type,
"id": item.ID,
}
if item.Status != "" {
m["status"] = item.Status
}
switch item.Type {
case "message":
role := item.Role
if role == "" {
role = "assistant"
}
m["role"] = role
m["content"] = messageContentWire(item.Content)
case "reasoning":
m["summary"] = reasoningSummaryWire(item.Summary)
if item.EncryptedContent != "" {
m["encrypted_content"] = item.EncryptedContent
}
case "function_call":
m["call_id"] = item.CallID
m["name"] = item.Name
m["arguments"] = item.Arguments
// namespace 子工具的还原调用:codex 按 namespace+name 路由,缺少该字段
// 会被判为 unsupported call。
if item.Namespace != "" {
m["namespace"] = item.Namespace
}
case "custom_tool_call":
// custom/freeform 工具调用(如 codex 的 exec):input 为自由文本。缺少
// call_id/name 时 codex 无法路由该调用(表现为 unsupported call)。
m["call_id"] = item.CallID
m["name"] = item.Name
m["input"] = item.Input
case "tool_search_call":
// tool_search 调用还原项:execution 必须为 "client"(否则 codex 忽略该
// 调用),arguments 在线上是 JSON 对象而非字符串。
m["call_id"] = item.CallID
m["execution"] = "client"
m["arguments"] = toolSearchCallArgumentsJSON(item.Arguments)
}
return m
}
// messageContentWire renders a message item's content array; always an array
// (never null), with each output_text part carrying its text.
func messageContentWire(parts []ResponsesContentPart) []map[string]any {
out := make([]map[string]any, 0, len(parts))
for _, p := range parts {
typ := p.Type
if typ == "" {
typ = "output_text"
}
out = append(out, map[string]any{"type": typ, "text": p.Text})
}
return out
}
// reasoningSummaryWire renders a reasoning item's summary array; always an array.
func reasoningSummaryWire(summary []ResponsesSummary) []map[string]any {
out := make([]map[string]any, 0, len(summary))
for _, s := range summary {
typ := s.Type
if typ == "" {
typ = "summary_text"
}
out = append(out, map[string]any{"type": typ, "text": s.Text})
}
return out
}
@@ -0,0 +1,186 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// marshalEvent marshals through the custom MarshalJSON and returns the decoded
// object plus the set of top-level keys.
func marshalEvent(t *testing.T, e ResponsesStreamEvent) map[string]any {
t.Helper()
b, err := json.Marshal(e)
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(b, &m))
return m
}
// TestWire_IndexFieldsPresentAtZero guards the omitempty trap: output_index/
// content_index/summary_index must serialize even when 0.
func TestWire_IndexFieldsPresentAtZero(t *testing.T) {
m := marshalEvent(t, ResponsesStreamEvent{
Type: "response.output_text.delta", OutputIndex: 0, ContentIndex: 0, ItemID: "msg_1", Delta: "hi",
})
require.Contains(t, m, "output_index")
require.Contains(t, m, "content_index")
require.EqualValues(t, 0, m["output_index"])
r := marshalEvent(t, ResponsesStreamEvent{
Type: "response.reasoning_summary_text.delta", OutputIndex: 0, SummaryIndex: 0, ItemID: "rs_1", Delta: "think",
})
require.Contains(t, r, "output_index")
require.Contains(t, r, "summary_index")
}
// TestWire_FunctionCallItemAlwaysComplete guards that a function_call item
// always carries call_id/name/arguments, including arguments:"" on .added.
func TestWire_FunctionCallItemAlwaysComplete(t *testing.T) {
added := marshalEvent(t, ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 1,
Item: &ResponsesOutput{Type: "function_call", ID: "fc_1", CallID: "call_a", Name: "exec", Status: "in_progress"},
})
item, ok := added["item"].(map[string]any)
require.True(t, ok, "item must be an object")
for _, k := range []string{"call_id", "name", "arguments"} {
require.Containsf(t, item, k, "function_call item missing %q", k)
}
require.Equal(t, "", item["arguments"])
}
// TestWire_MessageItemContentAlwaysArray guards content:[] presence.
func TestWire_MessageItemContentAlwaysArray(t *testing.T) {
m := marshalEvent(t, ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "message", ID: "msg_1", Role: "assistant", Status: "in_progress"},
})
item, ok := m["item"].(map[string]any)
require.True(t, ok, "item must be an object")
require.Contains(t, item, "content")
_, ok = item["content"].([]any)
require.True(t, ok, "content must be an array")
}
// TestWire_ReasoningItemSummaryAlwaysArray guards summary:[] presence.
func TestWire_ReasoningItemSummaryAlwaysArray(t *testing.T) {
m := marshalEvent(t, ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "reasoning", ID: "rs_1", Status: "in_progress"},
})
item, ok := m["item"].(map[string]any)
require.True(t, ok, "item must be an object")
require.Contains(t, item, "summary")
_, ok = item["summary"].([]any)
require.True(t, ok, "summary must be an array")
}
// TestWire_ContentPartCarriesAnnotationsLogprobs guards the output_text part shape.
func TestWire_ContentPartCarriesAnnotationsLogprobs(t *testing.T) {
m := marshalEvent(t, ResponsesStreamEvent{
Type: "response.content_part.added", OutputIndex: 0, ContentIndex: 0, ItemID: "msg_1",
Part: &ResponsesContentPart{Type: "output_text", Text: ""},
})
part, ok := m["part"].(map[string]any)
require.True(t, ok, "part must be an object")
require.Equal(t, "output_text", part["type"])
require.Contains(t, part, "text")
require.Contains(t, part, "annotations")
require.Contains(t, part, "logprobs")
}
// TestWire_ArgumentsDonePresentEvenEmpty guards arguments presence on done.
func TestWire_ArgumentsDonePresentEvenEmpty(t *testing.T) {
m := marshalEvent(t, ResponsesStreamEvent{
Type: "response.function_call_arguments.done", OutputIndex: 1, ItemID: "fc_1", CallID: "call_a", Name: "exec", Arguments: "",
})
require.Contains(t, m, "arguments")
require.Equal(t, "", m["arguments"])
}
// TestWire_CustomToolCallInputIndexPresentAtZero guards the omitempty trap for
// custom_tool_call_input.delta/done: output_index must serialize even when 0
// (custom tool call as the first output item).
func TestWire_CustomToolCallInputIndexPresentAtZero(t *testing.T) {
d := marshalEvent(t, ResponsesStreamEvent{
Type: "response.custom_tool_call_input.delta", OutputIndex: 0, ItemID: "ct_1", Delta: "dir",
})
require.Contains(t, d, "output_index")
require.EqualValues(t, 0, d["output_index"])
require.Equal(t, "dir", d["delta"])
done := marshalEvent(t, ResponsesStreamEvent{
Type: "response.custom_tool_call_input.done", OutputIndex: 0, ItemID: "ct_1", CallID: "call_1", Name: "exec", Input: "dir",
})
require.Contains(t, done, "output_index")
require.EqualValues(t, 0, done["output_index"])
require.Equal(t, "dir", done["input"])
require.NotContains(t, done, "delta")
}
// TestWire_UnknownEventFallsBackToDefault ensures non-streamed event types keep
// default marshalling (the response object is preserved).
func TestWire_UnknownEventFallsBackToDefault(t *testing.T) {
m := marshalEvent(t, ResponsesStreamEvent{
Type: "response.completed",
Response: &ResponsesResponse{ID: "resp_1", Object: "response", Status: "completed"},
})
require.Contains(t, m, "response")
}
func TestResponsesOutputUnmarshal_ToolSearchObjectArguments(t *testing.T) {
var item ResponsesOutput
require.NoError(t, json.Unmarshal([]byte(`{
"type":"tool_search_call",
"id":"item_1",
"call_id":"call_1",
"execution":"client",
"arguments":{"query":"gmail","limit":2}
}`), &item))
require.Equal(t, "tool_search_call", item.Type)
require.Equal(t, `{"query":"gmail","limit":2}`, item.Arguments)
wire, err := json.Marshal(item)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(wire, &decoded))
args, ok := decoded["arguments"].(map[string]any)
require.True(t, ok, "tool_search_call arguments must remain an object")
require.Equal(t, "gmail", args["query"])
}
func TestResponsesResponseUnmarshal_ToolSearchObjectArguments(t *testing.T) {
var response ResponsesResponse
require.NoError(t, json.Unmarshal([]byte(`{
"id":"response_1",
"object":"response",
"status":"completed",
"output":[{
"type":"tool_search_call",
"id":"item_1",
"call_id":"call_1",
"arguments":{"query":"gmail"}
}]
}`), &response))
require.Len(t, response.Output, 1)
require.Equal(t, `{"query":"gmail"}`, response.Output[0].Arguments)
}
func TestResponsesStreamEventUnmarshal_ToolSearchObjectArguments(t *testing.T) {
var event ResponsesStreamEvent
require.NoError(t, json.Unmarshal([]byte(`{
"type":"response.output_item.done",
"item":{
"type":"tool_search_call",
"id":"item_1",
"call_id":"call_1",
"arguments":{"query":"gmail"}
}
}`), &event))
require.NotNil(t, event.Item)
require.Equal(t, `{"query":"gmail"}`, event.Item.Arguments)
}
@@ -0,0 +1,711 @@
package apicompat
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// ---------------------------------------------------------------------------
// Non-streaming: ResponsesResponse → AnthropicResponse
// ---------------------------------------------------------------------------
// ResponsesToAnthropic converts a Responses API response directly into an
// Anthropic Messages response. Reasoning output items are mapped to thinking
// blocks; function_call items become tool_use blocks.
func ResponsesToAnthropic(resp *ResponsesResponse, model string) *AnthropicResponse {
out := &AnthropicResponse{
ID: resp.ID,
Type: "message",
Role: "assistant",
Model: model,
}
var blocks []AnthropicContentBlock
for _, item := range resp.Output {
switch item.Type {
case "reasoning":
summaryText := ""
for _, s := range item.Summary {
if s.Type == "summary_text" && s.Text != "" {
summaryText += s.Text
}
}
// Always surface encrypted_content as thinking.signature so Claude
// Code / multi-turn clients can send it back. Signature-only
// thinking blocks are valid when the model omits a visible summary.
if summaryText != "" || strings.TrimSpace(item.EncryptedContent) != "" {
blocks = append(blocks, AnthropicContentBlock{
Type: "thinking",
Thinking: summaryText,
Signature: item.EncryptedContent,
})
}
case "message":
for _, part := range item.Content {
if part.Type == "output_text" && part.Text != "" {
blocks = append(blocks, AnthropicContentBlock{
Type: "text",
Text: part.Text,
})
}
}
case "function_call":
blocks = append(blocks, AnthropicContentBlock{
Type: "tool_use",
ID: fromResponsesCallID(item.CallID),
Name: item.Name,
Input: sanitizeAnthropicToolUseInput(item.Name, item.Arguments),
})
case "web_search_call":
toolUseID := "srvtoolu_" + item.ID
query := ""
if item.Action != nil {
query = item.Action.Query
}
inputJSON, _ := json.Marshal(map[string]string{"query": query})
blocks = append(blocks, AnthropicContentBlock{
Type: "server_tool_use",
ID: toolUseID,
Name: "web_search",
Input: inputJSON,
})
emptyResults, _ := json.Marshal([]struct{}{})
blocks = append(blocks, AnthropicContentBlock{
Type: "web_search_tool_result",
ToolUseID: toolUseID,
Content: emptyResults,
})
}
}
if len(blocks) == 0 {
blocks = append(blocks, AnthropicContentBlock{Type: "text", Text: ""})
}
out.Content = blocks
out.StopReason = AnthropicStopReasonPtr(responsesStatusToAnthropicStopReason(resp.Status, resp.IncompleteDetails, blocks))
if resp.Usage != nil {
out.Usage = anthropicUsageFromResponsesUsage(resp.Usage)
}
return out
}
func anthropicUsageFromResponsesUsage(usage *ResponsesUsage) AnthropicUsage {
if usage == nil {
return AnthropicUsage{}
}
cachedTokens := 0
if usage.InputTokensDetails != nil {
cachedTokens = usage.InputTokensDetails.CachedTokens
}
inputTokens := usage.InputTokens - cachedTokens - usage.CacheCreationInputTokens
if inputTokens < 0 {
inputTokens = 0
}
return AnthropicUsage{
InputTokens: inputTokens,
OutputTokens: usage.OutputTokens,
CacheReadInputTokens: cachedTokens,
CacheCreationInputTokens: usage.CacheCreationInputTokens,
}
}
func responsesStatusToAnthropicStopReason(status string, details *ResponsesIncompleteDetails, blocks []AnthropicContentBlock) string {
switch status {
case "incomplete":
if details != nil && details.Reason == "max_output_tokens" {
return "max_tokens"
}
return "end_turn"
case "completed":
if containsAnthropicToolUseBlock(blocks) {
return "tool_use"
}
return "end_turn"
default:
return "end_turn"
}
}
func containsAnthropicToolUseBlock(blocks []AnthropicContentBlock) bool {
for _, block := range blocks {
if block.Type == "tool_use" {
return true
}
}
return false
}
func sanitizeAnthropicToolUseInput(name string, raw string) json.RawMessage {
if name != "Read" || raw == "" {
return json.RawMessage(raw)
}
var input map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &input); err != nil {
return json.RawMessage(raw)
}
if pages, ok := input["pages"]; !ok || string(pages) != `""` {
return json.RawMessage(raw)
}
delete(input, "pages")
sanitized, err := json.Marshal(input)
if err != nil {
return json.RawMessage(raw)
}
return sanitized
}
// ---------------------------------------------------------------------------
// Streaming: ResponsesStreamEvent → []AnthropicStreamEvent (stateful converter)
// ---------------------------------------------------------------------------
// ResponsesEventToAnthropicState tracks state for converting a sequence of
// Responses SSE events directly into Anthropic SSE events.
type ResponsesEventToAnthropicState struct {
MessageStartSent bool
MessageStopSent bool
ContentBlockIndex int
ContentBlockOpen bool
CurrentBlockType string // "text" | "thinking" | "tool_use"
CurrentToolName string
CurrentToolArgs string
CurrentToolHadDelta bool
// PendingThinkingSignature is filled from reasoning.encrypted_content and
// emitted as signature_delta before the thinking block is closed.
PendingThinkingSignature string
HasToolCall bool
// OutputIndexToBlockIdx maps Responses output_index → Anthropic content block index.
OutputIndexToBlockIdx map[int]int
InputTokens int
OutputTokens int
CacheReadInputTokens int
CacheCreationInputTokens int
ResponseID string
Model string
Created int64
}
// NewResponsesEventToAnthropicState returns an initialised stream state.
func NewResponsesEventToAnthropicState() *ResponsesEventToAnthropicState {
return &ResponsesEventToAnthropicState{
OutputIndexToBlockIdx: make(map[int]int),
Created: time.Now().Unix(),
}
}
// ResponsesEventToAnthropicEvents converts a single Responses SSE event into
// zero or more Anthropic SSE events, updating state as it goes.
func ResponsesEventToAnthropicEvents(
evt *ResponsesStreamEvent,
state *ResponsesEventToAnthropicState,
) []AnthropicStreamEvent {
switch evt.Type {
case "response.created":
return resToAnthHandleCreated(evt, state)
case "response.output_item.added":
return resToAnthHandleOutputItemAdded(evt, state)
case "response.output_text.delta":
return resToAnthHandleTextDelta(evt, state)
case "response.output_text.done":
return resToAnthHandleBlockDone(state)
case "response.function_call_arguments.delta",
// custom/freeform 工具的输入增量与 function_call 参数增量同形。
"response.custom_tool_call_input.delta":
return resToAnthHandleFuncArgsDelta(evt, state)
case "response.function_call_arguments.done":
return resToAnthHandleFuncArgsDone(evt, state)
case "response.output_item.done":
return resToAnthHandleOutputItemDone(evt, state)
case "response.reasoning_summary_text.delta",
// 原始推理文本增量,与 reasoning summary 一样映射为 thinking。
"response.reasoning_text.delta":
return resToAnthHandleReasoningDelta(evt, state)
case "response.reasoning_summary_text.done":
// Keep the thinking block open until response.output_item.done.
// Grok/Codex attach encrypted_content on the finished reasoning item;
// closing early would drop signature_delta and break multi-turn cache.
return nil
// response.done 是 Realtime/WS 与项目透传路径使用的终止别名;
// 普通 Responses HTTP SSE 的公开终止事件仍以 response.completed 为主。
case "response.completed", "response.done", "response.incomplete", "response.failed":
return resToAnthHandleCompleted(evt, state)
default:
return nil
}
}
// FinalizeResponsesAnthropicStream emits synthetic termination events if the
// stream ended without a proper completion event.
func FinalizeResponsesAnthropicStream(state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if !state.MessageStartSent || state.MessageStopSent {
return nil
}
var events []AnthropicStreamEvent
events = append(events, closeCurrentBlock(state)...)
stopReason := "end_turn"
if state.HasToolCall {
stopReason = "tool_use"
}
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
}
// ResponsesAnthropicEventToSSE formats an AnthropicStreamEvent as an SSE line pair.
func ResponsesAnthropicEventToSSE(evt AnthropicStreamEvent) (string, error) {
data, err := json.Marshal(evt)
if err != nil {
return "", err
}
return fmt.Sprintf("event: %s\ndata: %s\n\n", evt.Type, data), nil
}
// --- internal handlers ---
func resToAnthHandleCreated(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if evt.Response != nil {
state.ResponseID = evt.Response.ID
// Only use upstream model if no override was set (e.g. originalModel)
if state.Model == "" {
state.Model = evt.Response.Model
}
}
if state.MessageStartSent {
return nil
}
state.MessageStartSent = true
// Official Anthropic message_start uses stop_reason: null and usage with
// input_tokens when known. We leave StopReason nil (JSON null) and usage
// zeros until response.completed; never emit stop_reason:"" which breaks
// strict clients' turn-finalization / session usage accounting.
return []AnthropicStreamEvent{{
Type: "message_start",
Message: &AnthropicResponse{
ID: state.ResponseID,
Type: "message",
Role: "assistant",
Content: []AnthropicContentBlock{},
Model: state.Model,
StopReason: nil,
Usage: AnthropicUsage{
InputTokens: 0,
OutputTokens: 0,
},
},
}}
}
func resToAnthHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if evt.Item == nil {
return nil
}
switch evt.Item.Type {
// function_call 与 custom_tool_callcustom/freeform 工具,如新版 apply_patch
// 同样映射为 Anthropic 的 tool_use 块。
case "function_call", "custom_tool_call":
var events []AnthropicStreamEvent
events = append(events, closeCurrentBlock(state)...)
idx := state.ContentBlockIndex
state.OutputIndexToBlockIdx[evt.OutputIndex] = idx
state.ContentBlockOpen = true
state.CurrentBlockType = "tool_use"
state.CurrentToolName = evt.Item.Name
state.CurrentToolArgs = ""
state.CurrentToolHadDelta = false
state.HasToolCall = true
events = append(events, AnthropicStreamEvent{
Type: "content_block_start",
Index: &idx,
ContentBlock: &AnthropicContentBlock{
Type: "tool_use",
ID: fromResponsesCallID(evt.Item.CallID),
Name: evt.Item.Name,
Input: json.RawMessage("{}"),
},
})
return events
case "reasoning":
var events []AnthropicStreamEvent
events = append(events, closeCurrentBlock(state)...)
idx := state.ContentBlockIndex
state.OutputIndexToBlockIdx[evt.OutputIndex] = idx
state.ContentBlockOpen = true
state.CurrentBlockType = "thinking"
state.PendingThinkingSignature = strings.TrimSpace(evt.Item.EncryptedContent)
events = append(events, AnthropicStreamEvent{
Type: "content_block_start",
Index: &idx,
ContentBlock: &AnthropicContentBlock{
Type: "thinking",
Thinking: "",
},
})
return events
case "message":
return nil
}
return nil
}
func resToAnthHandleTextDelta(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if evt.Delta == "" {
return nil
}
var events []AnthropicStreamEvent
if !state.ContentBlockOpen || state.CurrentBlockType != "text" {
events = append(events, closeCurrentBlock(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: "",
},
})
}
idx := state.ContentBlockIndex
events = append(events, AnthropicStreamEvent{
Type: "content_block_delta",
Index: &idx,
Delta: &AnthropicDelta{
Type: "text_delta",
Text: evt.Delta,
},
})
return events
}
func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if evt.Delta == "" {
return nil
}
if state.CurrentBlockType == "tool_use" && state.CurrentToolName == "Read" {
state.CurrentToolArgs += evt.Delta
if state.CurrentToolHadDelta || !json.Valid([]byte(state.CurrentToolArgs)) {
return nil
}
blockIdx, ok := state.OutputIndexToBlockIdx[evt.OutputIndex]
if !ok {
return nil
}
state.CurrentToolHadDelta = true
sanitized := sanitizeAnthropicToolUseInput(state.CurrentToolName, state.CurrentToolArgs)
return []AnthropicStreamEvent{{
Type: "content_block_delta",
Index: &blockIdx,
Delta: &AnthropicDelta{
Type: "input_json_delta",
PartialJSON: string(sanitized),
},
}}
}
if state.CurrentBlockType == "tool_use" {
state.CurrentToolHadDelta = true
}
blockIdx, ok := state.OutputIndexToBlockIdx[evt.OutputIndex]
if !ok {
return nil
}
return []AnthropicStreamEvent{{
Type: "content_block_delta",
Index: &blockIdx,
Delta: &AnthropicDelta{
Type: "input_json_delta",
PartialJSON: evt.Delta,
},
}}
}
func resToAnthHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if !state.ContentBlockOpen {
return nil
}
if state.CurrentBlockType != "tool_use" {
return resToAnthHandleBlockDone(state)
}
raw := evt.Arguments
if raw == "" {
raw = state.CurrentToolArgs
}
if raw == "" || state.CurrentToolHadDelta {
return closeCurrentBlock(state)
}
if state.CurrentToolName == "Read" {
sanitized := sanitizeAnthropicToolUseInput(state.CurrentToolName, raw)
if len(sanitized) == 0 {
return closeCurrentBlock(state)
}
raw = string(sanitized)
}
// 从事件的 OutputIndex 解析正确的 block index,与 resToAnthHandleFuncArgsDelta 对齐
blockIdx, ok := state.OutputIndexToBlockIdx[evt.OutputIndex]
if !ok {
blockIdx = state.ContentBlockIndex
}
// 如果 block 已关闭(ContentBlockIndex 已越过它),说明 arguments 已通过 delta 流式发完,不再补发
if !state.ContentBlockOpen || blockIdx != state.ContentBlockIndex {
return nil
}
events := []AnthropicStreamEvent{{
Type: "content_block_delta",
Index: &blockIdx,
Delta: &AnthropicDelta{
Type: "input_json_delta",
PartialJSON: raw,
},
}}
events = append(events, closeCurrentBlock(state)...)
return events
}
func resToAnthHandleReasoningDelta(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if evt.Delta == "" {
return nil
}
blockIdx, ok := state.OutputIndexToBlockIdx[evt.OutputIndex]
if !ok {
return nil
}
return []AnthropicStreamEvent{{
Type: "content_block_delta",
Index: &blockIdx,
Delta: &AnthropicDelta{
Type: "thinking_delta",
Thinking: evt.Delta,
},
}}
}
func resToAnthHandleBlockDone(state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if !state.ContentBlockOpen {
return nil
}
return closeCurrentBlock(state)
}
func resToAnthHandleOutputItemDone(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if evt.Item == nil {
return nil
}
// Handle web_search_call → synthesize server_tool_use + web_search_tool_result blocks.
if evt.Item.Type == "web_search_call" && evt.Item.Status == "completed" {
return resToAnthHandleWebSearchDone(evt, state)
}
// Capture encrypted_content on reasoning item done (often only present here).
if evt.Item.Type == "reasoning" {
if sig := strings.TrimSpace(evt.Item.EncryptedContent); sig != "" {
state.PendingThinkingSignature = sig
}
}
if state.ContentBlockOpen {
return closeCurrentBlock(state)
}
return nil
}
// resToAnthHandleWebSearchDone converts an OpenAI web_search_call output item
// into Anthropic server_tool_use + web_search_tool_result content block pairs.
// This allows Claude Code to count the searches performed.
func resToAnthHandleWebSearchDone(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
var events []AnthropicStreamEvent
events = append(events, closeCurrentBlock(state)...)
toolUseID := "srvtoolu_" + evt.Item.ID
query := ""
if evt.Item.Action != nil {
query = evt.Item.Action.Query
}
inputJSON, _ := json.Marshal(map[string]string{"query": query})
// Emit server_tool_use block (start + stop).
idx1 := state.ContentBlockIndex
events = append(events, AnthropicStreamEvent{
Type: "content_block_start",
Index: &idx1,
ContentBlock: &AnthropicContentBlock{
Type: "server_tool_use",
ID: toolUseID,
Name: "web_search",
Input: inputJSON,
},
})
events = append(events, AnthropicStreamEvent{
Type: "content_block_stop",
Index: &idx1,
})
state.ContentBlockIndex++
// Emit web_search_tool_result block (start + stop).
// Content is empty because OpenAI does not expose individual search results;
// the model consumes them internally and produces text output.
emptyResults, _ := json.Marshal([]struct{}{})
idx2 := state.ContentBlockIndex
events = append(events, AnthropicStreamEvent{
Type: "content_block_start",
Index: &idx2,
ContentBlock: &AnthropicContentBlock{
Type: "web_search_tool_result",
ToolUseID: toolUseID,
Content: emptyResults,
},
})
events = append(events, AnthropicStreamEvent{
Type: "content_block_stop",
Index: &idx2,
})
state.ContentBlockIndex++
return events
}
func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if state.MessageStopSent {
return nil
}
var events []AnthropicStreamEvent
events = append(events, closeCurrentBlock(state)...)
stopReason := "end_turn"
if evt.Usage != nil {
usage := anthropicUsageFromResponsesUsage(evt.Usage)
state.InputTokens = usage.InputTokens
state.OutputTokens = usage.OutputTokens
state.CacheReadInputTokens = usage.CacheReadInputTokens
state.CacheCreationInputTokens = usage.CacheCreationInputTokens
}
if evt.Response != nil {
if evt.Response.Usage != nil {
usage := anthropicUsageFromResponsesUsage(evt.Response.Usage)
state.InputTokens = usage.InputTokens
state.OutputTokens = usage.OutputTokens
state.CacheReadInputTokens = usage.CacheReadInputTokens
state.CacheCreationInputTokens = usage.CacheCreationInputTokens
}
switch evt.Response.Status {
case "incomplete":
if evt.Response.IncompleteDetails != nil && evt.Response.IncompleteDetails.Reason == "max_output_tokens" {
stopReason = "max_tokens"
}
case "completed":
if state.HasToolCall {
stopReason = "tool_use"
}
}
}
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
}
func closeCurrentBlock(state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if !state.ContentBlockOpen {
return nil
}
idx := state.ContentBlockIndex
var events []AnthropicStreamEvent
// Emit signature_delta before stop so Claude clients retain encrypted
// reasoning for the next turn (required for Grok multi-turn cache).
if state.CurrentBlockType == "thinking" {
if sig := strings.TrimSpace(state.PendingThinkingSignature); sig != "" {
events = append(events, AnthropicStreamEvent{
Type: "content_block_delta",
Index: &idx,
Delta: &AnthropicDelta{
Type: "signature_delta",
Signature: sig,
},
})
}
state.PendingThinkingSignature = ""
}
state.ContentBlockOpen = false
state.ContentBlockIndex++
state.CurrentToolName = ""
state.CurrentToolArgs = ""
state.CurrentToolHadDelta = false
events = append(events, AnthropicStreamEvent{
Type: "content_block_stop",
Index: &idx,
})
return events
}
@@ -0,0 +1,102 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// These tests drive the exact production path for Chat Completions clients on an
// Anthropic-platform group: ForwardAsChatCompletions runs
// ChatCompletionsToResponses → ResponsesToAnthropicRequest
// (gateway_forward_as_chat_completions.go), then forwards the Anthropic body
// upstream. They assert the tool-pairing repair holds through that full chain,
// not only for codex-style Responses input.
func ccChainToAnthropic(t *testing.T, ccReq *ChatCompletionsRequest) []AnthropicMessage {
t.Helper()
respReq, err := ChatCompletionsToResponses(ccReq)
require.NoError(t, err)
anthReq, err := ResponsesToAnthropicRequest(respReq)
require.NoError(t, err)
assertAnthropicPairing(t, anthReq.Messages)
return anthReq.Messages
}
// Reproduces the production 400:
//
// unexpected ...content.0: tool_use_id found in tool_result blocks:
// call_00_TgfbRvKlnD7oK6Dg00sL1661. Each tool_result block must have a
// corresponding tool_use block in the previous message.
//
// A Chat Completions client trimmed its history and kept a tool result whose
// announcing assistant tool_calls message was dropped (sliding-window context
// management). The orphan tool_result has no matching tool_use → upstream 400.
// The repair drops the orphan so the request is valid.
func TestCCChain_OrphanToolResultFromTrimmedHistory(t *testing.T) {
orphanID := "call_00_TgfbRvKlnD7oK6Dg00sL1661"
msgs := ccChainToAnthropic(t, &ChatCompletionsRequest{
Model: "deepseek-v4-pro",
Messages: []ChatMessage{
{Role: "user", Content: json.RawMessage(`"search the web for X"`)},
// The assistant tool_calls message that announced orphanID was trimmed.
{Role: "tool", ToolCallID: orphanID, Content: json.RawMessage(`"stale search results"`)},
{Role: "assistant", Content: json.RawMessage(`"Here is what I found."`)},
{Role: "user", Content: json.RawMessage(`"thanks, now do Y"`)},
},
})
for _, m := range msgs {
require.Falsef(t, hasToolResult(parseContentBlocks(m.Content), orphanID),
"orphan tool_result %s should have been dropped", orphanID)
}
}
// A parallel web_search where one sibling's result never came back (the tool
// failed/was skipped). The unanswered tool_use would otherwise trip Anthropic's
// "tool_use without tool_result" check; the repair drops it.
func TestCCChain_ParallelToolOneResultMissing(t *testing.T) {
msgs := ccChainToAnthropic(t, &ChatCompletionsRequest{
Model: "deepseek-v4-pro",
Messages: []ChatMessage{
{Role: "user", Content: json.RawMessage(`"search A and B"`)},
{Role: "assistant", Content: json.RawMessage(`"searching both"`), ToolCalls: []ChatToolCall{
{ID: "call_a", Type: "function", Function: ChatFunctionCall{Name: "web_search", Arguments: `{"q":"A"}`}},
{ID: "call_b", Type: "function", Function: ChatFunctionCall{Name: "web_search", Arguments: `{"q":"B"}`}},
}},
{Role: "tool", ToolCallID: "call_a", Content: json.RawMessage(`"result A"`)},
// call_b's result is missing.
},
})
for _, m := range msgs {
require.Falsef(t, hasToolUse(parseContentBlocks(m.Content), "call_b"),
"unanswered tool_use call_b should have been dropped")
}
}
// Baseline: a well-formed multi-round tool history (text + tool_calls per
// assistant turn) converts and pairs correctly through the full chain.
func TestCCChain_WellFormedMultiRound(t *testing.T) {
msgs := ccChainToAnthropic(t, &ChatCompletionsRequest{
Model: "deepseek-v4-pro",
Messages: []ChatMessage{
{Role: "user", Content: json.RawMessage(`"do A then B"`)},
{Role: "assistant", Content: json.RawMessage(`"running A"`), ToolCalls: []ChatToolCall{
{ID: "call_a", Type: "function", Function: ChatFunctionCall{Name: "exec", Arguments: `{"cmd":"A"}`}},
}},
{Role: "tool", ToolCallID: "call_a", Content: json.RawMessage(`"A ok"`)},
{Role: "assistant", Content: json.RawMessage(`"A done, running B"`), ToolCalls: []ChatToolCall{
{ID: "call_b", Type: "function", Function: ChatFunctionCall{Name: "exec", Arguments: `{"cmd":"B"}`}},
}},
{Role: "tool", ToolCallID: "call_b", Content: json.RawMessage(`"B ok"`)},
{Role: "assistant", Content: json.RawMessage(`"all done"`)},
},
})
// Both calls survive and stay paired (assertAnthropicPairing already checks).
var sawA, sawB bool
for _, m := range msgs {
blocks := parseContentBlocks(m.Content)
sawA = sawA || hasToolUse(blocks, "call_a")
sawB = sawB || hasToolUse(blocks, "call_b")
}
require.True(t, sawA && sawB, "both well-formed calls should be preserved")
}
@@ -0,0 +1,126 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponsesToAnthropicRequest_Instructions(t *testing.T) {
t.Run("instructions_becomes_system", func(t *testing.T) {
req := &ResponsesRequest{
Model: "claude-sonnet-4-20250514",
Instructions: "You are a helpful assistant.",
Input: json.RawMessage(`[{"role":"user","content":"hello"}]`),
}
result, err := ResponsesToAnthropicRequest(req)
require.NoError(t, err)
var system string
require.NoError(t, json.Unmarshal(result.System, &system))
assert.Equal(t, "You are a helpful assistant.", system)
assert.NotEmpty(t, result.Messages)
})
t.Run("empty_instructions_no_system", func(t *testing.T) {
req := &ResponsesRequest{
Model: "claude-sonnet-4-20250514",
Input: json.RawMessage(`[{"role":"user","content":"hello"}]`),
}
result, err := ResponsesToAnthropicRequest(req)
require.NoError(t, err)
assert.Nil(t, result.System)
})
t.Run("instructions_and_system_item_concatenated", func(t *testing.T) {
req := &ResponsesRequest{
Model: "claude-sonnet-4-20250514",
Instructions: "Top-level instruction.",
Input: json.RawMessage(`[
{"role":"system","content":"Input-level system prompt."},
{"role":"user","content":"hello"}
]`),
}
result, err := ResponsesToAnthropicRequest(req)
require.NoError(t, err)
var system string
require.NoError(t, json.Unmarshal(result.System, &system))
assert.Contains(t, system, "Top-level instruction.")
assert.Contains(t, system, "Input-level system prompt.")
})
t.Run("instructions_with_string_input", func(t *testing.T) {
req := &ResponsesRequest{
Model: "claude-sonnet-4-20250514",
Instructions: "Be concise.",
Input: json.RawMessage(`"What is Go?"`),
}
result, err := ResponsesToAnthropicRequest(req)
require.NoError(t, err)
var system string
require.NoError(t, json.Unmarshal(result.System, &system))
assert.Equal(t, "Be concise.", system)
require.Len(t, result.Messages, 1)
assert.Equal(t, "user", result.Messages[0].Role)
})
}
func TestConvertResponsesInputToAnthropic_DeveloperRole(t *testing.T) {
t.Run("developer_becomes_system", func(t *testing.T) {
input := `[
{"role":"developer","content":[{"type":"input_text","text":"You are a code reviewer."}]},
{"role":"user","content":"review this code"}
]`
system, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input))
require.NoError(t, err)
var systemText string
require.NoError(t, json.Unmarshal(system, &systemText))
assert.Equal(t, "You are a code reviewer.", systemText)
require.Len(t, messages, 1)
assert.Equal(t, "user", messages[0].Role)
})
t.Run("developer_does_not_become_user", func(t *testing.T) {
input := `[
{"role":"developer","content":[{"type":"input_text","text":"System prompt."}]},
{"role":"user","content":"hi"}
]`
_, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input))
require.NoError(t, err)
for _, m := range messages {
if m.Role == "user" {
var s string
if json.Unmarshal(m.Content, &s) == nil {
assert.NotContains(t, s, "System prompt.")
}
}
}
})
t.Run("instructions_and_developer_concatenated_in_order", func(t *testing.T) {
input := `[
{"role":"developer","content":"Extra context."},
{"role":"user","content":"hello"}
]`
system, _, err := convertResponsesInputToAnthropic("Main instruction.", json.RawMessage(input))
require.NoError(t, err)
var systemText string
require.NoError(t, json.Unmarshal(system, &systemText))
assert.Equal(t, "Main instruction.\n\nExtra context.", systemText)
})
}
@@ -0,0 +1,207 @@
package apicompat
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// anthropicInboundBlockTypes 是 Anthropic Messages 请求体里合法的 content block
// 类型。转换结果只能落在这个集合内——发出集合外的类型,上游一律回
// 400 "Request body format invalid"(见 issue #5329)。
var anthropicInboundBlockTypes = map[string]bool{
"text": true,
"image": true,
"document": true,
"tool_use": true,
"tool_result": true,
"thinking": true,
"redacted_thinking": true,
}
func responsesToAnthropicMessages(t *testing.T, input string) []AnthropicMessage {
t.Helper()
var req ResponsesRequest
require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":`+input+`}`), &req))
out, err := ResponsesToAnthropicRequest(&req)
require.NoError(t, err)
return out.Messages
}
// requireAnthropicMessagesAreSendable 断言消息序列不含 Anthropic 会拒收的形态:
// 未知 block 类型、空内容消息、纯空白 text 块。
func requireAnthropicMessagesAreSendable(t *testing.T, messages []AnthropicMessage) {
t.Helper()
for i, m := range messages {
raw := strings.TrimSpace(string(m.Content))
require.NotContains(t, []string{"", "null", `""`, "[]"}, raw,
"messages[%d] 内容为空,Anthropic 拒收空内容消息", i)
var s string
if err := json.Unmarshal(m.Content, &s); err == nil {
require.NotEmpty(t, strings.TrimSpace(s), "messages[%d] 字符串内容不能全为空白", i)
continue
}
blocks := parseContentBlocks(m.Content)
require.NotEmpty(t, blocks, "messages[%d] 解析不出任何 block", i)
for j, b := range blocks {
require.True(t, anthropicInboundBlockTypes[b.Type],
"messages[%d].content[%d] 是 Anthropic 不认识的 block 类型 %q", i, j, b.Type)
if b.Type == "text" {
require.NotEmpty(t, strings.TrimSpace(b.Text),
"messages[%d].content[%d] 是空白 text 块,Anthropic 拒收", i, j)
}
}
}
}
// issue #5329:工具执行后的下一轮,Codex 会把 reasoning item 一起回放。
// 该 item 带 content 数组时,reasoning_text 块以前会被原样塞进 Anthropic 请求体。
func TestResponsesToAnthropic_ReasoningItemWithContentIsDropped(t *testing.T) {
messages := responsesToAnthropicMessages(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"run a shell command"}]},
{"type":"reasoning","id":"rs_1","summary":[],"content":[{"type":"reasoning_text","text":"let me think"}]}
]`)
requireAnthropicMessagesAreSendable(t, messages)
require.Len(t, messages, 1)
require.NotContains(t, string(messages[0].Content), "reasoning_text")
require.NotContains(t, string(messages[0].Content), "let me think")
}
// Codex 的常见 reasoning 形态(只有 summary + encrypted_content)本来就会被丢弃,
// 这条守卫确保行为没有被改变。
func TestResponsesToAnthropic_ReasoningItemSummaryOnlyStillDropped(t *testing.T) {
messages := responsesToAnthropicMessages(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"s"}],"encrypted_content":"gAAAA"}
]`)
requireAnthropicMessagesAreSendable(t, messages)
require.Len(t, messages, 1)
require.NotContains(t, string(messages[0].Content), "gAAAA")
}
// 未知 item type 的 content 以前会被逐字透传,把 Responses 专有分片带进上游请求。
func TestResponsesToAnthropic_UnknownItemTypeContentIsSanitized(t *testing.T) {
messages := responsesToAnthropicMessages(t, `[
{"type":"web_search_call","id":"ws_1","content":[{"type":"web_search_result","text":"payload"}]}
]`)
requireAnthropicMessagesAreSendable(t, messages)
require.Empty(t, messages, "整条内容都无法映射时不应发出消息")
}
// 未知 item type 里夹带的可识别文本仍然保留,不做无谓丢弃。
func TestResponsesToAnthropic_UnknownItemTypeKeepsRecognizableText(t *testing.T) {
messages := responsesToAnthropicMessages(t, `[
{"type":"some_future_item","content":[
{"type":"input_text","text":"keep me"},
{"type":"reasoning_text","text":"drop me"}
]}
]`)
requireAnthropicMessagesAreSendable(t, messages)
require.Len(t, messages, 1)
require.Contains(t, string(messages[0].Content), "keep me")
require.NotContains(t, string(messages[0].Content), "drop me")
}
// user 消息的分片全部不可识别时,以前会退化成 content:""Anthropic 拒收空内容消息。
func TestResponsesToAnthropic_UserMessageWithOnlyUnknownPartsIsDropped(t *testing.T) {
messages := responsesToAnthropicMessages(t, `[
{"type":"message","role":"user","content":[{"type":"input_file","file_id":"file_1"}]}
]`)
requireAnthropicMessagesAreSendable(t, messages)
require.Empty(t, messages)
}
// assistant 侧同理:以前会退化成单个空 text 块,Anthropic 同样拒收。
func TestResponsesToAnthropic_AssistantMessageWithOnlyUnknownPartsIsDropped(t *testing.T) {
messages := responsesToAnthropicMessages(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
{"type":"message","role":"assistant","content":[{"type":"refusal","refusal":"no"}]}
]`)
requireAnthropicMessagesAreSendable(t, messages)
require.Len(t, messages, 1)
require.Equal(t, "user", messages[0].Role)
}
// 完整的 Codex 工具续接回放:tool_use / tool_result 配对必须保持不变,
// 同时整个序列满足可发送不变式。
func TestResponsesToAnthropic_CodexToolRoundStaysIntactAndSendable(t *testing.T) {
messages := responsesToAnthropicMessages(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"run ls"}]},
{"type":"reasoning","id":"rs_1","summary":[],"content":[{"type":"reasoning_text","text":"plan"}],"encrypted_content":"gAAAA"},
{"type":"function_call","id":"fc_1","call_id":"call_1","name":"shell","arguments":"{\"cmd\":\"ls\"}"},
{"type":"function_call_output","call_id":"call_1","output":"file1"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}
]`)
requireAnthropicMessagesAreSendable(t, messages)
var sawToolUse, sawToolResult bool
for _, m := range messages {
for _, b := range parseContentBlocks(m.Content) {
switch b.Type {
case "tool_use":
sawToolUse = true
require.Equal(t, "call_1", b.ID)
require.Equal(t, "shell", b.Name)
case "tool_result":
sawToolResult = true
require.Equal(t, "call_1", b.ToolUseID)
}
}
}
require.True(t, sawToolUse, "function_call 必须转成 tool_use")
require.True(t, sawToolResult, "function_call_output 必须转成 tool_result")
require.NotContains(t, string(mustMarshal(t, messages)), "reasoning_text")
require.NotContains(t, string(mustMarshal(t, messages)), "gAAAA")
}
func mustMarshal(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
require.NoError(t, err)
return b
}
func TestAnthropicContentIsEmpty(t *testing.T) {
cases := []struct {
raw string
want bool
}{
{``, true},
{`""`, true},
{`null`, true},
{`[]`, true},
{` [] `, true},
{`"hi"`, false},
{`[{"type":"text","text":"hi"}]`, false},
}
for _, tc := range cases {
require.Equal(t, tc.want, anthropicContentIsEmpty(json.RawMessage(tc.raw)), "raw=%q", tc.raw)
}
}
func TestAnthropicContentIsOnlyBlankText(t *testing.T) {
cases := []struct {
raw string
want bool
}{
{`[{"type":"text","text":""}]`, true},
{`[{"type":"text","text":" "}]`, true},
{`[{"type":"text","text":""},{"type":"text","text":" "}]`, true},
{`[{"type":"text","text":"hi"}]`, false},
{`[{"type":"text","text":""},{"type":"image","source":{}}]`, false},
{`[]`, false},
}
for _, tc := range cases {
require.Equal(t, tc.want, anthropicContentIsOnlyBlankText(json.RawMessage(tc.raw)), "raw=%q", tc.raw)
}
}
@@ -0,0 +1,277 @@
package apicompat
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func intPtr(v int) *int { return &v }
func strPtr(s string) *string { return &s }
// TestStreamingParallelToolUseNoGhostDelta reproduces the bug from issue #4193:
// when the CC→Responses→Anthropic bridge finalizes parallel tool calls whose
// arguments arrived packed in function_call_arguments.done (no prior delta),
// resToAnthHandleFuncArgsDone used state.ContentBlockIndex directly instead of
// looking up OutputIndexToBlockIdx. After the first tool's block was closed
// (ContentBlockIndex++), the second tool's .done emitted a content_block_delta
// on an index that was never content_block_start'ed — Claude Code reports
// "Content block not found".
//
// This test drives the full finalize path: CC chunks with two parallel
// tool_calls → ChatCompletionsChunkToResponsesEvents → FinalizeChatCompletionsResponsesStream
// → ResponsesEventToAnthropicEvents, and asserts every content_block_delta
// targets a block that was previously content_block_start'ed.
func TestStreamingParallelToolUseNoGhostDelta(t *testing.T) {
ccState := NewChatCompletionsToResponsesStreamState("glm-5.2")
anthropicState := NewResponsesEventToAnthropicState()
anthropicState.Model = "glm-5.2"
// Chunk 1: first tool_call arrives with id + name + packed arguments.
chatChunk1 := &ChatCompletionsChunk{
ID: "chatcmpl-1",
Model: "glm-5.2",
Choices: []ChatChunkChoice{{
Index: 0,
Delta: ChatDelta{
ToolCalls: []ChatToolCall{{
Index: intPtr(0),
ID: "call_weather",
Type: "function",
Function: ChatFunctionCall{
Name: "get_weather",
Arguments: `{"city":"Tokyo"}`,
},
}},
},
}},
}
// Chunk 2: second tool_call arrives with id + name + packed arguments.
chatChunk2 := &ChatCompletionsChunk{
ID: "chatcmpl-1",
Model: "glm-5.2",
Choices: []ChatChunkChoice{{
Index: 0,
Delta: ChatDelta{
ToolCalls: []ChatToolCall{{
Index: intPtr(1),
ID: "call_time",
Type: "function",
Function: ChatFunctionCall{
Name: "get_time",
Arguments: `{}`,
},
}},
},
}},
}
// Chunk 3: finish.
chatChunk3 := &ChatCompletionsChunk{
ID: "chatcmpl-1",
Model: "glm-5.2",
Choices: []ChatChunkChoice{{
Index: 0,
Delta: ChatDelta{},
FinishReason: strPtr("tool_calls"),
}},
}
// Feed chunks through the CC→Responses bridge, then through Responses→Anthropic.
var allAnthropicEvents []AnthropicStreamEvent
for _, chunk := range []*ChatCompletionsChunk{chatChunk1, chatChunk2, chatChunk3} {
responsesEvents := ChatCompletionsChunkToResponsesEvents(chunk, ccState)
for _, rEvent := range responsesEvents {
allAnthropicEvents = append(allAnthropicEvents, ResponsesEventToAnthropicEvents(&rEvent, anthropicState)...)
}
}
// Finalize: closeChatToolItems emits function_call_arguments.done for each tool.
finalResponsesEvents := FinalizeChatCompletionsResponsesStream(ccState)
for _, rEvent := range finalResponsesEvents {
allAnthropicEvents = append(allAnthropicEvents, ResponsesEventToAnthropicEvents(&rEvent, anthropicState)...)
}
// Build the set of block indices that were content_block_start'ed.
startedBlocks := make(map[int]string) // index → block type
for _, e := range allAnthropicEvents {
if e.Type == "content_block_start" && e.ContentBlock != nil {
startedBlocks[*e.Index] = e.ContentBlock.Type
}
}
// Assert: every content_block_delta targets a started block (no ghost deltas).
for _, e := range allAnthropicEvents {
if e.Type != "content_block_delta" || e.Index == nil {
continue
}
idx := *e.Index
_, ok := startedBlocks[idx]
require.Truef(t, ok,
"content_block_delta on index %d which was never content_block_start'ed (ghost delta bug #4193)", idx)
}
// Assert: every content_block_stop targets a started block.
for _, e := range allAnthropicEvents {
if e.Type != "content_block_stop" || e.Index == nil {
continue
}
idx := *e.Index
_, ok := startedBlocks[idx]
require.Truef(t, ok,
"content_block_stop on index %d which was never content_block_start'ed", idx)
}
// Assert: both tool_use blocks were opened.
var toolUseBlocks []int
for idx, blockType := range startedBlocks {
if blockType == "tool_use" {
toolUseBlocks = append(toolUseBlocks, idx)
}
}
assert.Len(t, toolUseBlocks, 2, "both parallel tool_use blocks should be opened")
// Assert: stop_reason is tool_use.
var sawMessageDelta bool
for _, e := range allAnthropicEvents {
if e.Type == "message_delta" {
sawMessageDelta = true
assert.Equal(t, "tool_use", e.Delta.StopReason)
}
}
assert.True(t, sawMessageDelta, "message_delta should be emitted")
}
// TestStreamingParallelToolUseSecondToolPackedArgsDone is a focused unit test
// for the exact bug: two tools, the first streams its arguments via deltas
// (CurrentToolHadDelta=true → closeCurrentBlock), the second has arguments
// packed only in .done (CurrentToolHadDelta=false). Before the fix, the second
// tool's .done emitted content_block_delta on state.ContentBlockIndex which
// had already been incremented past the second tool's block.
func TestStreamingParallelToolUseSecondToolPackedArgsDone(t *testing.T) {
state := NewResponsesEventToAnthropicState()
// response.created
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.created",
Response: &ResponsesResponse{ID: "resp_par", Model: "glm-5.2"},
}, state)
// Tool 1: output_item.added (index 0)
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_a", Name: "tool_a"},
}, state)
// Tool 1: arguments streamed via delta (CurrentToolHadDelta = true)
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 0,
Delta: `{"x":1}`,
}, state)
// Tool 1: arguments.done → CurrentToolHadDelta=true → closeCurrentBlock
// ContentBlockIndex goes from 0 → 1
eventsTool1Done := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"x":1}`,
}, state)
// Should only emit content_block_stop (delta already streamed)
for _, e := range eventsTool1Done {
assert.NotEqual(t, "content_block_delta", e.Type,
"tool 1 .done should not re-emit delta (args already streamed)")
}
// Tool 2: output_item.added (index 1) → opens block at ContentBlockIndex=1
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 1,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_b", Name: "tool_b"},
}, state)
// Tool 2: NO delta — arguments arrive packed in .done only.
// This is the exact scenario that triggered the ghost delta bug.
eventsTool2Done := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 1,
Arguments: `{"y":2}`,
}, state)
// The fix ensures the delta targets index 1 (the tool 2 block), not
// state.ContentBlockIndex (which would be 1 before close, matching by
// luck in this 2-tool case, but wrong in 3+ tool cases or when the
// block is already closed).
//
// The critical assertion: the delta must be on index 1 and must be
// followed by content_block_stop on the same index.
var sawDelta, sawStop bool
var deltaIndex, stopIndex int
for _, e := range eventsTool2Done {
if e.Type == "content_block_delta" && e.Index != nil {
sawDelta = true
deltaIndex = *e.Index
assert.Equal(t, "input_json_delta", e.Delta.Type)
assert.Equal(t, `{"y":2}`, e.Delta.PartialJSON)
}
if e.Type == "content_block_stop" && e.Index != nil {
sawStop = true
stopIndex = *e.Index
}
}
assert.True(t, sawDelta, "tool 2 .done with packed args should emit content_block_delta")
assert.True(t, sawStop, "tool 2 .done should close the block")
assert.Equal(t, 1, deltaIndex, "delta must target the tool 2 block (index 1)")
assert.Equal(t, deltaIndex, stopIndex, "delta and stop must be on the same block")
}
// TestStreamingThreeParallelToolsAllPackedDone tests the most extreme case:
// three parallel tools, ALL with arguments packed in .done (no deltas at all).
// Before the fix, tool 2 and tool 3 would emit ghost deltas on wrong indices.
func TestStreamingThreeParallelToolsAllPackedDone(t *testing.T) {
state := NewResponsesEventToAnthropicState()
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.created",
Response: &ResponsesResponse{ID: "resp_3par", Model: "glm-5.2"},
}, state)
// Open three tool blocks at indices 0, 1, 2.
for i, name := range []string{"tool_a", "tool_b", "tool_c"} {
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: i,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_" + name, Name: name},
}, state)
}
// Track started blocks.
started := map[int]bool{0: true, 1: true, 2: true}
// All three .done events with packed arguments (no prior delta).
for i, args := range []string{`{"a":1}`, `{"b":2}`, `{"c":3}`} {
events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: i,
Arguments: args,
}, state)
for _, e := range events {
if e.Type == "content_block_delta" && e.Index != nil {
idx := *e.Index
require.Truef(t, started[idx],
"ghost delta: tool %d .done emitted content_block_delta on index %d (never started)", i, idx)
require.Equal(t, i, idx,
"tool %d .done delta should target its own block index %d, got %d", i, i, idx)
}
if e.Type == "content_block_stop" && e.Index != nil {
idx := *e.Index
require.Truef(t, started[idx],
"ghost stop: tool %d .done emitted content_block_stop on index %d (never started)", i, idx)
}
}
}
}
@@ -0,0 +1,115 @@
package apicompat
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResToAnthFuncArgsDelta_ReadToolWaitsForCompleteJSON(t *testing.T) {
state := NewResponsesEventToAnthropicState()
state.MessageStartSent = true
state.ContentBlockOpen = true
state.CurrentBlockType = "tool_use"
state.CurrentToolName = "Read"
state.OutputIndexToBlockIdx = map[int]int{0: 0}
events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 0,
Delta: `{"file_path":"/tmp/te`,
}, state)
assert.Empty(t, events, "partial Read JSON must wait for sanitization")
assert.False(t, state.CurrentToolHadDelta)
events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 0,
Delta: `st.go","pages":""}`,
}, state)
require.Len(t, events, 1)
assert.Equal(t, "content_block_delta", events[0].Type)
assert.Equal(t, "input_json_delta", events[0].Delta.Type)
assert.JSONEq(t, `{"file_path":"/tmp/test.go"}`, events[0].Delta.PartialJSON)
assert.Equal(t, `{"file_path":"/tmp/test.go","pages":""}`, state.CurrentToolArgs)
assert.True(t, state.CurrentToolHadDelta)
events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"file_path":"/tmp/test.go","pages":""}`,
}, state)
require.Len(t, events, 1)
assert.Equal(t, "content_block_stop", events[0].Type)
events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"file_path":"/tmp/test.go","pages":""}`,
}, state)
assert.Empty(t, events, "duplicate done must be idempotent")
}
func TestResponsesEventToAnthropicEvents_ReadToolWithoutArgumentsDoneClosesOnCompleted(t *testing.T) {
state := NewResponsesEventToAnthropicState()
events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.created",
Response: &ResponsesResponse{ID: "resp_read", Model: "gpt-5.5"},
}, state)
require.Len(t, events, 1)
assert.Equal(t, "message_start", events[0].Type)
events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_read", Name: "Read"},
}, state)
require.Len(t, events, 1)
assert.Equal(t, "content_block_start", events[0].Type)
events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 0,
Delta: `{"file_path":"/tmp/test.go","pages":""}`,
}, state)
require.Len(t, events, 1)
assert.Equal(t, "content_block_delta", events[0].Type)
assert.Equal(t, "input_json_delta", events[0].Delta.Type)
assert.JSONEq(t, `{"file_path":"/tmp/test.go"}`, events[0].Delta.PartialJSON)
events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
Type: "response.completed",
Response: &ResponsesResponse{
Status: "completed",
},
}, state)
require.Len(t, events, 3)
assert.Equal(t, "content_block_stop", events[0].Type)
assert.Equal(t, "message_delta", events[1].Type)
assert.Equal(t, "tool_use", events[1].Delta.StopReason)
assert.Equal(t, "message_stop", events[2].Type)
assert.Empty(t, FinalizeResponsesAnthropicStream(state), "terminal event already finalized the stream")
}
func TestResToAnthFuncArgsDelta_NonReadToolStreamsPartialJSONImmediately(t *testing.T) {
state := NewResponsesEventToAnthropicState()
state.MessageStartSent = true
state.CurrentBlockType = "tool_use"
state.CurrentToolName = "Write"
state.OutputIndexToBlockIdx = map[int]int{0: 0}
evt := &ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 0,
Delta: `{"file_path":"/tmp/out`,
}
events := ResponsesEventToAnthropicEvents(evt, state)
require.Len(t, events, 1)
assert.Equal(t, "content_block_delta", events[0].Type)
assert.Equal(t, `{"file_path":"/tmp/out`, events[0].Delta.PartialJSON)
assert.True(t, state.CurrentToolHadDelta)
}
@@ -0,0 +1,731 @@
package apicompat
import (
"encoding/json"
"fmt"
"strings"
)
// ResponsesToAnthropicRequest converts a Responses API request into an
// Anthropic Messages request. This is the reverse of AnthropicToResponses and
// enables Anthropic platform groups to accept OpenAI Responses API requests
// by converting them to the native /v1/messages format before forwarding upstream.
func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, error) {
system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input)
if err != nil {
return nil, err
}
out := &AnthropicRequest{
Model: req.Model,
Messages: messages,
Temperature: req.Temperature,
TopP: req.TopP,
Stream: req.Stream,
}
if len(system) > 0 {
out.System = system
}
// max_output_tokens → max_tokens
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
out.MaxTokens = *req.MaxOutputTokens
}
if out.MaxTokens == 0 {
// Anthropic requires max_tokens; default to a sensible value.
out.MaxTokens = 8192
}
// Convert tools
if len(req.Tools) > 0 {
out.Tools = convertResponsesToAnthropicTools(req.Tools)
}
// Convert tool_choice (reverse of convertAnthropicToolChoiceToResponses)
if len(req.ToolChoice) > 0 {
tc, err := convertResponsesToAnthropicToolChoice(req.ToolChoice)
if err != nil {
return nil, fmt.Errorf("convert tool_choice: %w", err)
}
out.ToolChoice = tc
}
// reasoning.effort → output_config.effort + thinking
if req.Reasoning != nil && req.Reasoning.Effort != "" {
effort := mapResponsesEffortToAnthropic(req.Reasoning.Effort)
out.OutputConfig = &AnthropicOutputConfig{Effort: effort}
// Enable thinking for non-low efforts
if effort != "low" {
out.Thinking = &AnthropicThinking{
Type: "enabled",
BudgetTokens: defaultThinkingBudget(effort),
}
}
}
return out, nil
}
// defaultThinkingBudget returns a sensible thinking budget based on effort level.
func defaultThinkingBudget(effort string) int {
switch effort {
case "low":
return 1024
case "medium":
return 4096
case "high":
return 10240
case "max":
return 32768
default:
return 10240
}
}
// mapResponsesEffortToAnthropic converts OpenAI Responses reasoning effort to
// Anthropic effort levels. Reverse of mapAnthropicEffortToResponses.
//
// low → low
// medium → medium
// high → high
// xhigh → max
func mapResponsesEffortToAnthropic(effort string) string {
if effort == "xhigh" {
return "max"
}
return effort // low→low, medium→medium, high→high, unknown→passthrough
}
// convertResponsesInputToAnthropic extracts system prompt and messages from
// a Responses API instructions + input array. Returns the system as raw JSON
// (for Anthropic's polymorphic system field) and a list of Anthropic messages.
func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMessage) (json.RawMessage, []AnthropicMessage, error) {
var systemParts []string
if strings.TrimSpace(instructions) != "" {
systemParts = append(systemParts, strings.TrimSpace(instructions))
}
// Try as plain string input.
var inputStr string
if err := json.Unmarshal(inputRaw, &inputStr); err == nil {
content, _ := json.Marshal(inputStr)
var system json.RawMessage
if len(systemParts) > 0 {
system, _ = json.Marshal(strings.Join(systemParts, "\n\n"))
}
return system, []AnthropicMessage{{Role: "user", Content: content}}, nil
}
var items []ResponsesInputItem
if err := json.Unmarshal(inputRaw, &items); err != nil {
return nil, nil, fmt.Errorf("parse responses input: %w", err)
}
var messages []AnthropicMessage
for _, item := range items {
switch {
case item.Role == "system" || item.Role == "developer":
text := extractTextFromContent(item.Content)
if text != "" {
systemParts = append(systemParts, text)
}
case item.Type == "function_call":
// function_call → assistant message with tool_use block
input := json.RawMessage("{}")
if item.Arguments != "" {
input = json.RawMessage(item.Arguments)
}
block := AnthropicContentBlock{
Type: "tool_use",
ID: fromResponsesCallIDToAnthropic(item.CallID),
Name: item.Name,
Input: input,
}
blockJSON, _ := json.Marshal([]AnthropicContentBlock{block})
messages = append(messages, AnthropicMessage{
Role: "assistant",
Content: blockJSON,
})
case item.Type == "function_call_output":
// function_call_output → user message with tool_result block
contentJSON := responsesFunctionOutputToAnthropicContent(item)
block := AnthropicContentBlock{
Type: "tool_result",
ToolUseID: fromResponsesCallIDToAnthropic(item.CallID),
Content: contentJSON,
}
blockJSON, _ := json.Marshal([]AnthropicContentBlock{block})
messages = append(messages, AnthropicMessage{
Role: "user",
Content: blockJSON,
})
case item.Type == "reasoning":
// Anthropic 无法摄入 OpenAI 的 reasoningencrypted_content 是不透明的,
// 而 thinking 块的重放需要 Anthropic 自己签发的 signature,无法伪造。
// Codex 常见形态(只带 summary + encrypted_content)本来就会被丢弃,
// 这里让带 content 数组的形态保持同样行为——否则 reasoning_text 块会被
// 原样塞进 Anthropic 请求体,上游直接回 400。
case item.Role == "user":
content, err := convertResponsesUserToAnthropicContent(item.Content)
if err != nil {
return nil, nil, err
}
// 内容里只有网关不认识的分片时,sanitize 会得到空串。Anthropic 拒收
// 空内容消息("all messages must have non-empty content"),整条丢掉
// 比发一条必然 400 的消息更可用。
if anthropicContentIsEmpty(content) {
continue
}
messages = append(messages, AnthropicMessage{
Role: "user",
Content: content,
})
case item.Role == "assistant":
content, err := convertResponsesAssistantToAnthropicContent(item.Content)
if err != nil {
return nil, nil, err
}
// 同上:分片全不认识时会退化成单个空 text 块,而 Anthropic 拒收
// 空文本块("text content blocks must contain non-whitespace text")。
if anthropicContentIsEmpty(content) || anthropicContentIsOnlyBlankText(content) {
continue
}
messages = append(messages, AnthropicMessage{
Role: "assistant",
Content: content,
})
default:
// 未知 role/type —— 尽量当作 user 消息保留其中的文本/图片。
// 必须走与真实 user 消息同一套白名单转换:直接透传 item.Content 会把
// Responses 专有的分片类型(reasoning_text、web_search_call 的载荷等)
// 原样发给 Anthropic,上游只会回 400 把整轮打挂。
if item.Content == nil {
continue
}
content, err := convertResponsesUserToAnthropicContent(item.Content)
if err != nil {
return nil, nil, err
}
if anthropicContentIsEmpty(content) {
continue
}
messages = append(messages, AnthropicMessage{
Role: "user",
Content: content,
})
}
}
// Repair tool_use/tool_result pairing, then merge consecutive same-role
// messages (Anthropic requires alternating roles). The first merge groups
// parallel calls (and their results) so the pairing pass sees them together;
// the pairing pass may re-split a user turn (e.g. when an injected message
// sat between a call and its output), so a second merge restores alternation.
messages = mergeConsecutiveMessages(messages)
messages = normalizeAnthropicToolPairing(messages)
messages = mergeConsecutiveMessages(messages)
var system json.RawMessage
if len(systemParts) > 0 {
system, _ = json.Marshal(strings.Join(systemParts, "\n\n"))
}
return system, messages, nil
}
func responsesFunctionOutputToAnthropicContent(item ResponsesInputItem) json.RawMessage {
if len(item.outputRaw) == 0 {
output := item.Output
if output == "" {
output = "(empty)"
}
content, _ := json.Marshal(output)
return content
}
var parts []ResponsesContentPart
if err := json.Unmarshal(item.outputRaw, &parts); err == nil {
blocks := make([]AnthropicContentBlock, 0, len(parts))
for _, part := range parts {
switch part.Type {
case "input_text", "output_text", "text":
if part.Text != "" {
blocks = append(blocks, AnthropicContentBlock{Type: "text", Text: part.Text})
}
case "input_image":
if source := dataURIToAnthropicImageSource(part.ImageURL); source != nil {
blocks = append(blocks, AnthropicContentBlock{Type: "image", Source: source})
}
}
}
if len(blocks) > 0 {
content, _ := json.Marshal(blocks)
return content
}
if len(parts) == 0 {
content, _ := json.Marshal("(empty)")
return content
}
}
content, _ := json.Marshal(item.Output)
return content
}
// normalizeAnthropicToolPairing rebuilds the message sequence so it satisfies
// Anthropic's tool_use/tool_result invariants, which the naive item-by-item
// conversion violates whenever the Responses history interleaves anything
// between a function_call and its function_call_output:
//
// - every tool_result block must have a matching tool_use in the immediately
// preceding assistant message ("tool_result ... must have a corresponding
// tool_use block in the previous message");
// - every tool_use block must be answered by a tool_result in the immediately
// following user message (Anthropic rejects unanswered tool_use ids);
// - user/assistant turns must alternate.
//
// codex (Responses, store:false) re-sends the whole history each turn and
// frequently injects items between a call and its output — a developer/approval
// notice, or a sibling parallel call whose output never arrived. The unrepaired
// converter emits each function_call as its own assistant message and each
// output as its own user message, so any such interleaving breaks
// tool_use↔tool_result adjacency and yields an upstream 400.
//
// The repair indexes every tool_result by its tool_use id, then for each
// assistant message carrying tool_use blocks keeps only the answered ones
// (dropping unanswered/dangling calls — and the assistant message entirely if it
// has no other content) and emits the matching tool_result blocks, in call
// order, as the very next user message. Standalone tool_result blocks are
// dropped from their original position (re-emitted adjacent to their call);
// orphan tool_results with no announcing tool_use are dropped. Non-tool content
// passes through in place. This mirrors normalizeChatMessages on the
// Responses→Chat path.
func normalizeAnthropicToolPairing(messages []AnthropicMessage) []AnthropicMessage {
// Index every tool_result block by its tool_use id (last wins on dup).
results := make(map[string]AnthropicContentBlock)
for _, m := range messages {
if m.Role != "user" {
continue
}
for _, b := range parseContentBlocks(m.Content) {
if b.Type == "tool_result" && b.ToolUseID != "" {
results[b.ToolUseID] = b
}
}
}
out := make([]AnthropicMessage, 0, len(messages))
for _, m := range messages {
blocks := parseContentBlocks(m.Content)
switch m.Role {
case "assistant":
var toolUses, others []AnthropicContentBlock
for _, b := range blocks {
if b.Type == "tool_use" {
toolUses = append(toolUses, b)
} else {
others = append(others, b)
}
}
if len(toolUses) == 0 {
out = append(out, m)
continue
}
kept := make([]AnthropicContentBlock, 0, len(toolUses))
for _, tu := range toolUses {
if _, ok := results[tu.ID]; ok {
kept = append(kept, tu)
}
}
if len(kept) == 0 {
// No answered calls: keep any non-tool content, else drop.
if len(others) > 0 {
out = append(out, anthropicMessageFromBlocks("assistant", others))
}
continue
}
asstBlocks := make([]AnthropicContentBlock, 0, len(others)+len(kept))
asstBlocks = append(asstBlocks, others...)
asstBlocks = append(asstBlocks, kept...)
out = append(out, anthropicMessageFromBlocks("assistant", asstBlocks))
resBlocks := make([]AnthropicContentBlock, 0, len(kept))
for _, tu := range kept {
resBlocks = append(resBlocks, results[tu.ID])
}
out = append(out, anthropicMessageFromBlocks("user", resBlocks))
case "user":
var nonResult []AnthropicContentBlock
hasResult := false
for _, b := range blocks {
if b.Type == "tool_result" {
hasResult = true
continue
}
nonResult = append(nonResult, b)
}
if !hasResult {
out = append(out, m)
continue
}
// The tool_result blocks are re-emitted next to their call; keep any
// other content of this user turn in place, drop it if there is none.
if len(nonResult) > 0 {
out = append(out, anthropicMessageFromBlocks("user", nonResult))
}
default:
out = append(out, m)
}
}
return out
}
// anthropicMessageFromBlocks builds an AnthropicMessage whose content is the
// marshaled block array.
func anthropicMessageFromBlocks(role string, blocks []AnthropicContentBlock) AnthropicMessage {
content, _ := json.Marshal(blocks)
return AnthropicMessage{Role: role, Content: content}
}
// extractTextFromContent extracts text from a content field that may be a
// plain string or an array of content parts.
func extractTextFromContent(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
var parts []ResponsesContentPart
if err := json.Unmarshal(raw, &parts); err == nil {
var texts []string
for _, p := range parts {
if (p.Type == "input_text" || p.Type == "output_text" || p.Type == "text") && p.Text != "" {
texts = append(texts, p.Text)
}
}
return strings.Join(texts, "\n\n")
}
return ""
}
// convertResponsesUserToAnthropicContent converts a Responses user message
// content field into Anthropic content blocks JSON.
// anthropicContentIsEmpty 判断转换结果是否为"空内容"。
// convertResponsesUserToAnthropicContent 在没有任何可识别分片时返回 JSON 空串,
// 而 Anthropic 拒收空内容消息。
func anthropicContentIsEmpty(content json.RawMessage) bool {
trimmed := strings.TrimSpace(string(content))
switch trimmed {
case "", "null", `""`, "[]":
return true
}
return false
}
// anthropicContentIsOnlyBlankText 判断内容是否只由空白 text 块组成。
func anthropicContentIsOnlyBlankText(content json.RawMessage) bool {
blocks := parseContentBlocks(content)
if len(blocks) == 0 {
return false
}
for _, b := range blocks {
if b.Type != "text" || strings.TrimSpace(b.Text) != "" {
return false
}
}
return true
}
func convertResponsesUserToAnthropicContent(raw json.RawMessage) (json.RawMessage, error) {
if len(raw) == 0 {
return json.Marshal("") // empty string content
}
// Try plain string.
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return json.Marshal(s)
}
// Array of content parts → Anthropic content blocks.
var parts []ResponsesContentPart
if err := json.Unmarshal(raw, &parts); err != nil {
// Pass through as-is if we can't parse
return raw, nil
}
var blocks []AnthropicContentBlock
for _, p := range parts {
switch p.Type {
case "input_text", "text":
if p.Text != "" {
blocks = append(blocks, AnthropicContentBlock{
Type: "text",
Text: p.Text,
})
}
case "input_image":
src := dataURIToAnthropicImageSource(p.ImageURL)
if src != nil {
blocks = append(blocks, AnthropicContentBlock{
Type: "image",
Source: src,
})
}
}
}
if len(blocks) == 0 {
return json.Marshal("")
}
return json.Marshal(blocks)
}
// convertResponsesAssistantToAnthropicContent converts a Responses assistant
// message content field into Anthropic content blocks JSON.
func convertResponsesAssistantToAnthropicContent(raw json.RawMessage) (json.RawMessage, error) {
if len(raw) == 0 {
return json.Marshal([]AnthropicContentBlock{{Type: "text", Text: ""}})
}
// Try plain string.
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return json.Marshal([]AnthropicContentBlock{{Type: "text", Text: s}})
}
// Array of content parts → Anthropic content blocks.
var parts []ResponsesContentPart
if err := json.Unmarshal(raw, &parts); err != nil {
return raw, nil
}
var blocks []AnthropicContentBlock
for _, p := range parts {
switch p.Type {
case "output_text", "text":
if p.Text != "" {
blocks = append(blocks, AnthropicContentBlock{
Type: "text",
Text: p.Text,
})
}
}
}
if len(blocks) == 0 {
blocks = append(blocks, AnthropicContentBlock{Type: "text", Text: ""})
}
return json.Marshal(blocks)
}
// fromResponsesCallIDToAnthropic converts an OpenAI function call ID back to
// Anthropic format. Reverses toResponsesCallID.
func fromResponsesCallIDToAnthropic(id string) string {
// If it has our "fc_" prefix wrapping a known Anthropic prefix, strip it
if after, ok := strings.CutPrefix(id, "fc_"); ok {
if strings.HasPrefix(after, "toolu_") || strings.HasPrefix(after, "call_") {
return after
}
}
// Generate a synthetic Anthropic tool ID
if !strings.HasPrefix(id, "toolu_") && !strings.HasPrefix(id, "call_") {
return "toolu_" + id
}
return id
}
// dataURIToAnthropicImageSource parses a data URI into an AnthropicImageSource.
func dataURIToAnthropicImageSource(dataURI string) *AnthropicImageSource {
if !strings.HasPrefix(dataURI, "data:") {
return nil
}
// Format: data:<media_type>;base64,<data>
rest := strings.TrimPrefix(dataURI, "data:")
semicolonIdx := strings.Index(rest, ";")
if semicolonIdx < 0 {
return nil
}
mediaType := rest[:semicolonIdx]
rest = rest[semicolonIdx+1:]
if !strings.HasPrefix(rest, "base64,") {
return nil
}
data := strings.TrimPrefix(rest, "base64,")
return &AnthropicImageSource{
Type: "base64",
MediaType: mediaType,
Data: data,
}
}
// mergeConsecutiveMessages merges consecutive messages with the same role
// because Anthropic requires alternating user/assistant turns.
func mergeConsecutiveMessages(messages []AnthropicMessage) []AnthropicMessage {
if len(messages) <= 1 {
return messages
}
var merged []AnthropicMessage
for _, msg := range messages {
if len(merged) == 0 || merged[len(merged)-1].Role != msg.Role {
merged = append(merged, msg)
continue
}
// Same role — merge content arrays
last := &merged[len(merged)-1]
lastBlocks := parseContentBlocks(last.Content)
newBlocks := parseContentBlocks(msg.Content)
combined := append(lastBlocks, newBlocks...)
last.Content, _ = json.Marshal(combined)
}
return merged
}
// parseContentBlocks attempts to parse content as []AnthropicContentBlock.
// If it's a string, wraps it in a text block.
func parseContentBlocks(raw json.RawMessage) []AnthropicContentBlock {
var blocks []AnthropicContentBlock
if err := json.Unmarshal(raw, &blocks); err == nil {
return blocks
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return []AnthropicContentBlock{{Type: "text", Text: s}}
}
return nil
}
// convertResponsesToAnthropicTools maps Responses API tools to Anthropic format.
// Reverse of convertAnthropicToolsToResponses.
func convertResponsesToAnthropicTools(tools []ResponsesTool) []AnthropicTool {
var out []AnthropicTool
for _, t := range tools {
switch t.Type {
case "web_search", "google_search", "web_search_20250305":
out = append(out, AnthropicTool{
Type: "web_search_20250305",
Name: "web_search",
})
case "function":
out = append(out, AnthropicTool{
Name: t.Name,
Description: t.Description,
InputSchema: normalizeAnthropicInputSchema(t.Parameters),
})
case "custom":
out = append(out, AnthropicTool{
Name: t.Name,
Description: t.Description,
InputSchema: normalizeAnthropicInputSchema(t.Parameters),
})
default:
// Pass through unknown tool types
out = append(out, AnthropicTool{
Type: t.Type,
Name: t.Name,
Description: t.Description,
InputSchema: normalizeAnthropicInputSchema(t.Parameters),
})
}
}
return out
}
// normalizeAnthropicInputSchema ensures input_schema is a valid object schema.
func normalizeAnthropicInputSchema(schema json.RawMessage) json.RawMessage {
const emptyObjectSchema = `{"type":"object","properties":{}}`
trimmed := strings.TrimSpace(string(schema))
if trimmed == "" || trimmed == "null" {
return json.RawMessage(emptyObjectSchema)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(schema, &m); err != nil {
return json.RawMessage(`{"type":"object","properties":{}}`)
}
typeRaw, ok := m["type"]
if !ok || strings.TrimSpace(string(typeRaw)) == "" || string(typeRaw) == "null" {
m["type"] = json.RawMessage(`"object"`)
} else {
var typ string
if err := json.Unmarshal(typeRaw, &typ); err != nil || typ != "object" {
return json.RawMessage(emptyObjectSchema)
}
}
if _, ok := m["properties"]; !ok {
m["properties"] = json.RawMessage(`{}`)
}
out, err := json.Marshal(m)
if err != nil {
return json.RawMessage(emptyObjectSchema)
}
return out
}
// convertResponsesToAnthropicToolChoice maps Responses tool_choice to Anthropic format.
// Reverse of convertAnthropicToolChoiceToResponses.
//
// "auto" → {"type":"auto"}
// "required" → {"type":"any"}
// "none" → {"type":"none"}
// {"type":"function","name":"X"} → {"type":"tool","name":"X"}
// {"type":"function","function":{"name":"X"}} → {"type":"tool","name":"X"} // legacy
func convertResponsesToAnthropicToolChoice(raw json.RawMessage) (json.RawMessage, error) {
// Try as string first
var s string
if err := json.Unmarshal(raw, &s); err == nil {
switch s {
case "auto":
return json.Marshal(map[string]string{"type": "auto"})
case "required":
return json.Marshal(map[string]string{"type": "any"})
case "none":
return json.Marshal(map[string]string{"type": "none"})
default:
return raw, nil
}
}
// Try as object with type=function
var tc struct {
Type string `json:"type"`
Name string `json:"name"`
Function struct {
Name string `json:"name"`
} `json:"function"`
}
if err := json.Unmarshal(raw, &tc); err == nil && tc.Type == "function" {
name := strings.TrimSpace(tc.Name)
if name == "" {
name = strings.TrimSpace(tc.Function.Name)
}
if name == "" {
return raw, nil
}
return json.Marshal(map[string]string{
"type": "tool",
"name": name,
})
}
// Pass through unknown
return raw, nil
}
@@ -0,0 +1,190 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// assertAnthropicPairing enforces the Anthropic Messages tool-pairing invariants
// that, when violated, surface as upstream 400s.
func assertAnthropicPairing(t *testing.T, messages []AnthropicMessage) {
t.Helper()
for i, m := range messages {
blocks := parseContentBlocks(m.Content)
// No two consecutive same-role messages.
if i > 0 {
require.NotEqualf(t, messages[i-1].Role, m.Role, "consecutive %s messages at %d", m.Role, i)
}
for _, b := range blocks {
switch b.Type {
case "tool_result":
// Must have a matching tool_use in the immediately previous message.
require.Positivef(t, i, "tool_result %s has no previous message", b.ToolUseID)
prev := parseContentBlocks(messages[i-1].Content)
require.Truef(t, hasToolUse(prev, b.ToolUseID),
"tool_result %s has no corresponding tool_use in previous message", b.ToolUseID)
case "tool_use":
// Must be answered by a tool_result in the immediately next message.
require.Lessf(t, i+1, len(messages), "tool_use %s has no following message", b.ID)
next := parseContentBlocks(messages[i+1].Content)
require.Truef(t, hasToolResult(next, b.ID),
"tool_use %s is not answered in the next message", b.ID)
}
}
}
}
func hasToolUse(blocks []AnthropicContentBlock, id string) bool {
for _, b := range blocks {
if b.Type == "tool_use" && b.ID == id {
return true
}
}
return false
}
func hasToolResult(blocks []AnthropicContentBlock, toolUseID string) bool {
for _, b := range blocks {
if b.Type == "tool_result" && b.ToolUseID == toolUseID {
return true
}
}
return false
}
func convertAnthropic(t *testing.T, input string) []AnthropicMessage {
t.Helper()
_, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input))
require.NoError(t, err)
assertAnthropicPairing(t, messages)
return messages
}
// Tests use call_-prefixed ids because fromResponsesCallIDToAnthropic passes
// those through unchanged (matching codex's real call_00_... ids); bare ids
// would be rewritten to toolu_<id>.
// A developer/approval message injected between a function_call and its output
// must be moved out of the tool_use→tool_result adjacency. This is the shape
// that produced the production 400 "tool_result ... must have a corresponding
// tool_use block in the previous message".
func TestAnthropicPairing_DeveloperMessageBetween(t *testing.T) {
msgs := convertAnthropic(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"do it"}]},
{"type":"function_call","call_id":"call_A","name":"exec","arguments":"{}"},
{"type":"message","role":"developer","content":[{"type":"input_text","text":"Approved command prefix saved"}]},
{"type":"function_call_output","call_id":"call_A","output":"ok"}
]`)
// The assistant tool_use message is immediately followed by its tool_result.
for i, m := range msgs {
if hasToolUse(parseContentBlocks(m.Content), "call_A") {
require.Equal(t, "user", msgs[i+1].Role)
require.True(t, hasToolResult(parseContentBlocks(msgs[i+1].Content), "call_A"))
}
}
}
// Parallel tool calls where both outputs arrive stay grouped: one assistant
// message with both tool_use blocks, the next user message with both results.
func TestAnthropicPairing_ParallelBothAnswered(t *testing.T) {
msgs := convertAnthropic(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"features?"}]},
{"type":"function_call","call_id":"call_c0","name":"exec","arguments":"{}"},
{"type":"function_call","call_id":"call_c1","name":"exec","arguments":"{}"},
{"type":"function_call_output","call_id":"call_c0","output":"log"},
{"type":"function_call_output","call_id":"call_c1","output":"tags"}
]`)
var sawGrouped bool
for _, m := range msgs {
blocks := parseContentBlocks(m.Content)
if hasToolUse(blocks, "call_c0") && hasToolUse(blocks, "call_c1") {
sawGrouped = true
}
}
require.True(t, sawGrouped, "parallel tool_use blocks should share one assistant message")
}
// A parallel call whose sibling output never arrived must be dropped so every
// remaining tool_use is answered.
func TestAnthropicPairing_ParallelOneUnanswered(t *testing.T) {
msgs := convertAnthropic(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
{"type":"function_call","call_id":"call_A","name":"exec","arguments":"{}"},
{"type":"function_call","call_id":"call_B","name":"exec","arguments":"{}"},
{"type":"function_call_output","call_id":"call_A","output":"oa"}
]`)
for _, m := range msgs {
require.Falsef(t, hasToolUse(parseContentBlocks(m.Content), "call_B"),
"unanswered tool_use call_B should have been dropped")
}
}
// An orphan tool_result whose tool_use was never announced must be dropped.
func TestAnthropicPairing_OrphanToolResultDropped(t *testing.T) {
msgs := convertAnthropic(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
{"type":"function_call_output","call_id":"call_ghost","output":"orphan"}
]`)
for _, m := range msgs {
require.Falsef(t, hasToolResult(parseContentBlocks(m.Content), "call_ghost"),
"orphan tool_result should have been dropped")
}
}
// A dangling tool_call at the end of the history (no output yet) drops the
// assistant message holding only that call, leaving no tool_use behind.
func TestAnthropicPairing_DanglingCallDropped(t *testing.T) {
msgs := convertAnthropic(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]},
{"type":"function_call","call_id":"call_A","name":"exec","arguments":"{}"}
]`)
for _, m := range msgs {
require.Falsef(t, hasToolUse(parseContentBlocks(m.Content), "call_A"),
"dangling tool_use call_A should have been dropped")
}
}
// Baseline: a single answered call pairs correctly and preserves the surrounding
// turns.
func TestAnthropicPairing_SingleCall(t *testing.T) {
msgs := convertAnthropic(t, `[
{"type":"message","role":"user","content":[{"type":"input_text","text":"latest sha?"}]},
{"type":"function_call","call_id":"call_A","name":"exec","arguments":"{\"cmd\":\"git rev-parse HEAD\"}"},
{"type":"function_call_output","call_id":"call_A","output":"deadbeef"},
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"It is deadbeef."}]}
]`)
// user, assistant(tool_use), user(tool_result), assistant(text)
require.GreaterOrEqual(t, len(msgs), 4)
require.Equal(t, "user", msgs[0].Role)
require.True(t, hasToolUse(parseContentBlocks(msgs[1].Content), "call_A"))
require.True(t, hasToolResult(parseContentBlocks(msgs[2].Content), "call_A"))
}
func TestResponsesToAnthropic_FunctionOutputContentArray(t *testing.T) {
msgs := convertAnthropic(t, `[
{"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
{"type":"function_call_output","call_id":"call_A","output":[
{"type":"input_text","text":"image loaded"},
{"type":"input_image","image_url":"data:image/png;base64,YQ=="}
]}
]`)
require.Len(t, msgs, 2)
resultBlocks := parseContentBlocks(msgs[1].Content)
require.Len(t, resultBlocks, 1)
require.Equal(t, "tool_result", resultBlocks[0].Type)
var content []AnthropicContentBlock
require.NoError(t, json.Unmarshal(resultBlocks[0].Content, &content))
require.Len(t, content, 2)
require.Equal(t, "text", content[0].Type)
require.Equal(t, "image loaded", content[0].Text)
require.Equal(t, "image", content[1].Type)
require.NotNil(t, content[1].Source)
require.Equal(t, "image/png", content[1].Source.MediaType)
require.Equal(t, "YQ==", content[1].Source.Data)
}
@@ -0,0 +1,140 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func requireObjectInputSchema(t *testing.T, schema json.RawMessage) map[string]json.RawMessage {
t.Helper()
require.NotEmpty(t, schema)
var parsed map[string]json.RawMessage
require.NoError(t, json.Unmarshal(schema, &parsed))
require.JSONEq(t, `"object"`, string(parsed["type"]))
require.Contains(t, parsed, "properties")
var properties map[string]json.RawMessage
require.NoError(t, json.Unmarshal(parsed["properties"], &properties))
return parsed
}
func TestResponsesToAnthropic_CustomGrammarToolUsesObjectSchema(t *testing.T) {
body := []byte(`{
"model": "gpt-5.2",
"input": "apply this patch",
"tools": [{
"type": "custom",
"name": "apply_patch",
"description": "Apply a patch to the working tree",
"format": {
"type": "grammar",
"syntax": "lark",
"definition": "start: /.+/"
}
}]
}`)
var req ResponsesRequest
require.NoError(t, json.Unmarshal(body, &req))
anthropicReq, err := ResponsesToAnthropicRequest(&req)
require.NoError(t, err)
require.Len(t, anthropicReq.Tools, 1)
tool := anthropicReq.Tools[0]
assert.Empty(t, tool.Type)
assert.Equal(t, "apply_patch", tool.Name)
assert.Equal(t, "Apply a patch to the working tree", tool.Description)
requireObjectInputSchema(t, tool.InputSchema)
assert.JSONEq(t, `{"type":"object","properties":{}}`, string(tool.InputSchema))
wire, err := json.Marshal(tool)
require.NoError(t, err)
assert.NotContains(t, string(wire), `"type":"custom"`)
assert.NotContains(t, string(wire), `"format"`)
assert.NotContains(t, string(wire), `"grammar"`)
}
func TestResponsesToAnthropic_CustomToolPreservesSchemaParameters(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "custom",
Name: "edit_file",
Description: "Edit a file",
Parameters: json.RawMessage(`{"type":"object","properties":{"patch":{"type":"string"}},"required":["patch"]}`),
}})
require.Len(t, tools, 1)
assert.Empty(t, tools[0].Type)
assert.Equal(t, "edit_file", tools[0].Name)
schema := requireObjectInputSchema(t, tools[0].InputSchema)
assert.JSONEq(t, `{"patch":{"type":"string"}}`, string(schema["properties"]))
assert.JSONEq(t, `["patch"]`, string(schema["required"]))
}
func TestResponsesToAnthropic_FunctionToolSchemaUnchanged(t *testing.T) {
parameters := json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`)
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "get_weather",
Description: "Get weather",
Parameters: parameters,
}})
require.Len(t, tools, 1)
assert.Empty(t, tools[0].Type)
assert.Equal(t, "get_weather", tools[0].Name)
assert.Equal(t, "Get weather", tools[0].Description)
assert.JSONEq(t, string(parameters), string(tools[0].InputSchema))
}
func TestResponsesToAnthropic_MixedToolsProduceValidAnthropicTools(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{
{
Type: "function",
Name: "read_file",
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`),
},
{
Type: "custom",
Name: "apply_patch",
},
{
Type: "web_search",
},
})
require.Len(t, tools, 3)
assert.Empty(t, tools[0].Type)
assert.Equal(t, "read_file", tools[0].Name)
requireObjectInputSchema(t, tools[0].InputSchema)
assert.Empty(t, tools[1].Type)
assert.Equal(t, "apply_patch", tools[1].Name)
assert.JSONEq(t, `{"type":"object","properties":{}}`, string(tools[1].InputSchema))
assert.Equal(t, "web_search_20250305", tools[2].Type)
assert.Equal(t, "web_search", tools[2].Name)
assert.Empty(t, tools[2].InputSchema)
serverToolWire, err := json.Marshal(tools[2])
require.NoError(t, err)
assert.NotContains(t, string(serverToolWire), `"input_schema"`)
}
func TestResponsesToAnthropic_DefaultToolNormalizesInputSchema(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "local_shell",
Name: "shell",
}})
require.Len(t, tools, 1)
assert.Equal(t, "local_shell", tools[0].Type)
assert.Equal(t, "shell", tools[0].Name)
assert.JSONEq(t, `{"type":"object","properties":{}}`, string(tools[0].InputSchema))
}
@@ -0,0 +1,552 @@
package apicompat
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
)
// ---------------------------------------------------------------------------
// Non-streaming: ResponsesResponse → ChatCompletionsResponse
// ---------------------------------------------------------------------------
// ResponsesToChatCompletions converts a Responses API response into a Chat
// Completions response. Text output items are concatenated into
// choices[0].message.content; function_call items become tool_calls.
func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatCompletionsResponse {
id := resp.ID
if id == "" {
id = generateChatCmplID()
}
out := &ChatCompletionsResponse{
ID: id,
Object: "chat.completion",
Created: time.Now().Unix(),
Model: model,
}
var contentText string
var reasoningText string
var toolCalls []ChatToolCall
for _, item := range resp.Output {
switch item.Type {
case "message":
for _, part := range item.Content {
if part.Type == "output_text" && part.Text != "" {
contentText += part.Text
}
}
case "function_call":
toolCalls = append(toolCalls, ChatToolCall{
ID: item.CallID,
Type: "function",
Function: ChatFunctionCall{
Name: item.Name,
Arguments: item.Arguments,
},
})
case "reasoning":
for _, s := range item.Summary {
if s.Type == "summary_text" && s.Text != "" {
reasoningText += s.Text
}
}
case "web_search_call":
// silently consumed — results already incorporated into text output
}
}
msg := ChatMessage{Role: "assistant"}
if len(toolCalls) > 0 {
msg.ToolCalls = toolCalls
}
if contentText != "" {
raw, _ := json.Marshal(contentText)
msg.Content = raw
}
if reasoningText != "" {
msg.ReasoningContent = reasoningText
}
finishReason := responsesStatusToChatFinishReason(resp.Status, resp.IncompleteDetails, toolCalls)
out.Choices = []ChatChoice{{
Index: 0,
Message: msg,
FinishReason: finishReason,
}}
out.Usage = chatUsageFromResponsesUsage(resp.Usage)
return out
}
func responsesStatusToChatFinishReason(status string, details *ResponsesIncompleteDetails, toolCalls []ChatToolCall) string {
switch status {
case "incomplete":
if details != nil {
switch details.Reason {
case "max_output_tokens":
return "length"
case "content_filter":
return "content_filter"
}
}
return "stop"
case "completed":
if len(toolCalls) > 0 {
return "tool_calls"
}
return "stop"
default:
return "stop"
}
}
// ---------------------------------------------------------------------------
// Streaming: ResponsesStreamEvent → []ChatCompletionsChunk (stateful converter)
// ---------------------------------------------------------------------------
// ResponsesEventToChatState tracks state for converting a sequence of Responses
// SSE events into Chat Completions SSE chunks.
type ResponsesEventToChatState struct {
ID string
Model string
Created int64
SentRole bool
SawToolCall bool
SawText bool
Finalized bool // true after finish chunk has been emitted
NextToolCallIndex int // next sequential tool_call index to assign
OutputIndexToToolIndex map[int]int // Responses output_index → Chat tool_calls index
IncludeUsage bool
Usage *ChatUsage
}
// NewResponsesEventToChatState returns an initialised stream state.
func NewResponsesEventToChatState() *ResponsesEventToChatState {
return &ResponsesEventToChatState{
ID: generateChatCmplID(),
Created: time.Now().Unix(),
OutputIndexToToolIndex: make(map[int]int),
}
}
// ResponsesEventToChatChunks converts a single Responses SSE event into zero
// or more Chat Completions chunks, updating state as it goes.
func ResponsesEventToChatChunks(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
switch evt.Type {
case "response.created":
return resToChatHandleCreated(evt, state)
case "response.output_text.delta":
return resToChatHandleTextDelta(evt, state)
case "response.output_item.added":
return resToChatHandleOutputItemAdded(evt, state)
case "response.function_call_arguments.delta",
// custom/freeform 工具(如新版 apply_patch)的输入增量与 function_call 参数增量同形,
// 均按 OutputIndex 累加到对应工具调用。
"response.custom_tool_call_input.delta":
return resToChatHandleFuncArgsDelta(evt, state)
case "response.reasoning_summary_text.delta",
// 原始推理文本增量(真实 Codex 客户端消费的 reasoning_text.delta),
// 与 reasoning summary 一样映射为 reasoning_content。
"response.reasoning_text.delta":
return resToChatHandleReasoningDelta(evt, state)
case "response.reasoning_summary_text.done":
return nil
// response.done 是 Realtime/WS 与项目透传路径使用的终止别名;
// 普通 Responses HTTP SSE 的公开终止事件仍以 response.completed 为主。
case "response.completed", "response.done", "response.incomplete", "response.failed":
return resToChatHandleCompleted(evt, state)
default:
return nil
}
}
// FinalizeResponsesChatStream emits a final chunk with finish_reason if the
// stream ended without a proper completion event (e.g. upstream disconnect).
// It is idempotent: if a completion event already emitted the finish chunk,
// this returns nil.
func FinalizeResponsesChatStream(state *ResponsesEventToChatState) []ChatCompletionsChunk {
if state.Finalized {
return nil
}
state.Finalized = true
finishReason := "stop"
if state.SawToolCall {
finishReason = "tool_calls"
}
chunks := []ChatCompletionsChunk{makeChatFinishChunk(state, finishReason)}
if state.IncludeUsage && state.Usage != nil {
chunks = append(chunks, ChatCompletionsChunk{
ID: state.ID,
Object: "chat.completion.chunk",
Created: state.Created,
Model: state.Model,
Choices: []ChatChunkChoice{},
Usage: state.Usage,
})
}
return chunks
}
// ChatChunkToSSE formats a ChatCompletionsChunk as an SSE data line.
func ChatChunkToSSE(chunk ChatCompletionsChunk) (string, error) {
data, err := json.Marshal(chunk)
if err != nil {
return "", err
}
return fmt.Sprintf("data: %s\n\n", data), nil
}
// --- internal handlers ---
func resToChatHandleCreated(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
if evt.Response != nil {
if evt.Response.ID != "" {
state.ID = evt.Response.ID
}
if state.Model == "" && evt.Response.Model != "" {
state.Model = evt.Response.Model
}
}
// Emit the role chunk.
if state.SentRole {
return nil
}
state.SentRole = true
role := "assistant"
return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{Role: role})}
}
func resToChatHandleTextDelta(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
if evt.Delta == "" {
return nil
}
state.SawText = true
content := evt.Delta
return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{Content: &content})}
}
func resToChatHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
// function_call 与 custom_tool_callcustom/freeform 工具)均按工具调用注册,
// 以便后续 *_input.delta / *_arguments.delta 能映射到正确的工具索引。
if evt.Item == nil || (evt.Item.Type != "function_call" && evt.Item.Type != "custom_tool_call") {
return nil
}
state.SawToolCall = true
idx := state.NextToolCallIndex
state.OutputIndexToToolIndex[evt.OutputIndex] = idx
state.NextToolCallIndex++
return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{
ToolCalls: []ChatToolCall{{
Index: &idx,
ID: evt.Item.CallID,
Type: "function",
Function: ChatFunctionCall{
Name: evt.Item.Name,
},
}},
})}
}
func resToChatHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
if evt.Delta == "" {
return nil
}
idx, ok := state.OutputIndexToToolIndex[evt.OutputIndex]
if !ok {
return nil
}
return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{
ToolCalls: []ChatToolCall{{
Index: &idx,
Function: ChatFunctionCall{
Arguments: evt.Delta,
},
}},
})}
}
func resToChatHandleReasoningDelta(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
if evt.Delta == "" {
return nil
}
reasoning := evt.Delta
return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{ReasoningContent: &reasoning})}
}
func resToChatHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
state.Finalized = true
finishReason := "stop"
if evt.Usage != nil {
state.Usage = chatUsageFromResponsesUsage(evt.Usage)
}
if evt.Response != nil {
if evt.Response.Usage != nil {
state.Usage = chatUsageFromResponsesUsage(evt.Response.Usage)
}
switch evt.Response.Status {
case "incomplete":
if evt.Response.IncompleteDetails != nil {
switch evt.Response.IncompleteDetails.Reason {
case "max_output_tokens":
finishReason = "length"
case "content_filter":
finishReason = "content_filter"
}
}
case "completed":
if state.SawToolCall {
finishReason = "tool_calls"
}
}
} else if state.SawToolCall {
finishReason = "tool_calls"
}
var chunks []ChatCompletionsChunk
chunks = append(chunks, makeChatFinishChunk(state, finishReason))
if state.IncludeUsage && state.Usage != nil {
chunks = append(chunks, ChatCompletionsChunk{
ID: state.ID,
Object: "chat.completion.chunk",
Created: state.Created,
Model: state.Model,
Choices: []ChatChunkChoice{},
Usage: state.Usage,
})
}
return chunks
}
func chatUsageFromResponsesUsage(u *ResponsesUsage) *ChatUsage {
if u == nil {
return nil
}
usage := &ChatUsage{
PromptTokens: u.InputTokens,
CompletionTokens: u.OutputTokens,
TotalTokens: u.InputTokens + u.OutputTokens,
}
usage.PromptTokensDetails = promptDetailsFromResponses(u.InputTokensDetails)
if u.CacheCreationInputTokens > 0 {
if usage.PromptTokensDetails == nil {
usage.PromptTokensDetails = &ChatTokenDetails{}
}
if usage.PromptTokensDetails.CacheWriteTokens == 0 && usage.PromptTokensDetails.CacheCreationTokens == 0 {
usage.PromptTokensDetails.CacheCreationTokens = u.CacheCreationInputTokens
}
}
usage.CompletionTokensDetails = completionDetailsFromResponses(u.OutputTokensDetails)
return usage
}
// promptDetailsFromResponses maps Responses-API input_tokens_details into a
// Chat-Completions prompt_tokens_details. Returns nil when nothing would be
// emitted, so upstreams that do not break down prompt usage stay clean.
func promptDetailsFromResponses(src *ResponsesInputTokensDetails) *ChatTokenDetails {
if src == nil {
return nil
}
if src.CachedTokens == 0 && src.AudioTokens == 0 && src.CacheCreationTokens == 0 && src.CacheWriteTokens == 0 {
return nil
}
return &ChatTokenDetails{
CachedTokens: src.CachedTokens,
AudioTokens: src.AudioTokens,
CacheCreationTokens: src.CacheCreationTokens,
CacheWriteTokens: src.CacheWriteTokens,
}
}
// completionDetailsFromResponses maps Responses-API output_tokens_details
// into a Chat-Completions completion_tokens_details. Mirrors the OpenAI
// official CompletionUsage schema: reasoning_tokens, audio_tokens, and
// the predicted-outputs accepted/rejected counts. Returns nil when nothing
// would be emitted so non-reasoning, non-audio responses stay clean.
func completionDetailsFromResponses(src *ResponsesOutputTokensDetails) *ChatTokenDetails {
if src == nil {
return nil
}
if src.ReasoningTokens == 0 && src.AudioTokens == 0 &&
src.AcceptedPredictionTokens == 0 && src.RejectedPredictionTokens == 0 {
return nil
}
return &ChatTokenDetails{
ReasoningTokens: src.ReasoningTokens,
AudioTokens: src.AudioTokens,
AcceptedPredictionTokens: src.AcceptedPredictionTokens,
RejectedPredictionTokens: src.RejectedPredictionTokens,
}
}
func makeChatDeltaChunk(state *ResponsesEventToChatState, delta ChatDelta) ChatCompletionsChunk {
return ChatCompletionsChunk{
ID: state.ID,
Object: "chat.completion.chunk",
Created: state.Created,
Model: state.Model,
Choices: []ChatChunkChoice{{
Index: 0,
Delta: delta,
FinishReason: nil,
}},
}
}
func makeChatFinishChunk(state *ResponsesEventToChatState, finishReason string) ChatCompletionsChunk {
empty := ""
return ChatCompletionsChunk{
ID: state.ID,
Object: "chat.completion.chunk",
Created: state.Created,
Model: state.Model,
Choices: []ChatChunkChoice{{
Index: 0,
Delta: ChatDelta{Content: &empty},
FinishReason: &finishReason,
}},
}
}
// generateChatCmplID returns a "chatcmpl-" prefixed random hex ID.
func generateChatCmplID() string {
b := make([]byte, 12)
_, _ = rand.Read(b)
return "chatcmpl-" + hex.EncodeToString(b)
}
// ---------------------------------------------------------------------------
// BufferedResponseAccumulator: accumulates SSE delta events for non-streaming
// paths where the terminal event may have empty output.
// ---------------------------------------------------------------------------
type bufferedFuncCall struct {
CallID string
Name string
Args strings.Builder
}
// BufferedResponseAccumulator collects content from Responses SSE delta events
// so that non-streaming handlers can reconstruct output when the terminal event
// (response.completed / response.done) carries an empty output array.
type BufferedResponseAccumulator struct {
text strings.Builder
reasoning strings.Builder
funcCalls []bufferedFuncCall
outputIndexToFuncIdx map[int]int
}
// NewBufferedResponseAccumulator returns an initialised accumulator.
func NewBufferedResponseAccumulator() *BufferedResponseAccumulator {
return &BufferedResponseAccumulator{
outputIndexToFuncIdx: make(map[int]int),
}
}
// ProcessEvent inspects a single Responses SSE event and accumulates any
// content it carries. Only delta events that contribute to the final output
// are handled; all other event types are silently ignored.
func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) {
switch event.Type {
case "response.output_text.delta":
if event.Delta != "" {
_, _ = a.text.WriteString(event.Delta)
}
case "response.output_item.added":
if event.Item != nil && (event.Item.Type == "function_call" || event.Item.Type == "custom_tool_call") {
idx := len(a.funcCalls)
a.outputIndexToFuncIdx[event.OutputIndex] = idx
a.funcCalls = append(a.funcCalls, bufferedFuncCall{
CallID: event.Item.CallID,
Name: event.Item.Name,
})
}
case "response.function_call_arguments.delta", "response.custom_tool_call_input.delta":
if event.Delta != "" {
if idx, ok := a.outputIndexToFuncIdx[event.OutputIndex]; ok {
_, _ = a.funcCalls[idx].Args.WriteString(event.Delta)
}
}
case "response.reasoning_summary_text.delta", "response.reasoning_text.delta":
if event.Delta != "" {
_, _ = a.reasoning.WriteString(event.Delta)
}
}
}
// HasContent reports whether any content has been accumulated.
func (a *BufferedResponseAccumulator) HasContent() bool {
return a.text.Len() > 0 || len(a.funcCalls) > 0 || a.reasoning.Len() > 0
}
// BuildOutput constructs a []ResponsesOutput from the accumulated delta
// content. The order matches what ResponsesToChatCompletions expects:
// reasoning → message → function_calls.
func (a *BufferedResponseAccumulator) BuildOutput() []ResponsesOutput {
var out []ResponsesOutput
if a.reasoning.Len() > 0 {
out = append(out, ResponsesOutput{
Type: "reasoning",
Summary: []ResponsesSummary{{
Type: "summary_text",
Text: a.reasoning.String(),
}},
})
}
if a.text.Len() > 0 {
out = append(out, ResponsesOutput{
Type: "message",
Role: "assistant",
Content: []ResponsesContentPart{{
Type: "output_text",
Text: a.text.String(),
}},
})
}
for i := range a.funcCalls {
out = append(out, ResponsesOutput{
Type: "function_call",
CallID: a.funcCalls[i].CallID,
Name: a.funcCalls[i].Name,
Arguments: a.funcCalls[i].Args.String(),
})
}
return out
}
// SupplementResponseOutput fills resp.Output from accumulated delta content
// when the terminal event delivered an empty output array. If resp.Output is
// already populated, this is a no-op (preserves backward compatibility).
func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesResponse) {
if resp == nil || len(resp.Output) > 0 {
return
}
if !a.HasContent() {
return
}
resp.Output = a.BuildOutput()
}
@@ -0,0 +1,78 @@
package apicompat
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// custom_tool_callcustom/freeform 工具,如新版 apply_patch)应像 function_call 一样
// 注册为工具调用,其 *_input.delta 增量映射到正确的工具索引。
func TestResponsesEventToChatChunks_CustomToolCallInputDelta(t *testing.T) {
state := NewResponsesEventToChatState()
state.Model = "gpt-5-codex"
state.SentRole = true
chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 1,
Item: &ResponsesOutput{
Type: "custom_tool_call",
CallID: "call_patch",
Name: "apply_patch",
},
}, state)
require.Len(t, chunks, 1)
require.Len(t, chunks[0].Choices[0].Delta.ToolCalls, 1)
tc := chunks[0].Choices[0].Delta.ToolCalls[0]
assert.Equal(t, "call_patch", tc.ID)
assert.Equal(t, "apply_patch", tc.Function.Name)
chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.custom_tool_call_input.delta",
OutputIndex: 1,
Delta: "*** Begin Patch",
}, state)
require.Len(t, chunks, 1)
tc = chunks[0].Choices[0].Delta.ToolCalls[0]
require.NotNil(t, tc.Index)
assert.Equal(t, 0, *tc.Index)
assert.Equal(t, "*** Begin Patch", tc.Function.Arguments)
}
// 原始推理文本增量 reasoning_text.delta 应像 reasoning_summary_text.delta 一样
// 映射为 reasoning_content。
func TestResponsesEventToChatChunks_ReasoningTextDelta(t *testing.T) {
state := NewResponsesEventToChatState()
state.Model = "gpt-5-codex"
state.SentRole = true
chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.reasoning_text.delta",
Delta: "thinking step",
}, state)
require.Len(t, chunks, 1)
require.NotNil(t, chunks[0].Choices[0].Delta.ReasoningContent)
assert.Equal(t, "thinking step", *chunks[0].Choices[0].Delta.ReasoningContent)
}
// 缓冲(非流式)累加器同样需识别两类新事件。
func TestBufferedResponseAccumulator_CodexEvents(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "custom_tool_call", CallID: "c1", Name: "apply_patch"},
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.custom_tool_call_input.delta",
OutputIndex: 0,
Delta: "patch-body",
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.reasoning_text.delta",
Delta: "raw-reasoning",
})
require.True(t, acc.HasContent())
}
@@ -0,0 +1,263 @@
package apicompat
import (
"encoding/json"
"fmt"
"strings"
)
type responsesDiscoveredToolIdentity struct {
typ string
name string
namespace string
encoded string
ambiguous bool
}
// promoteResponsesToolSearchDiscoveries makes successfully discovered client
// tools callable for function-only upstreams. The original tool_search_output
// remains request history and is normalized separately; declarations are
// appended after static tools so the client's declaration order stays stable.
func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) {
tools, ok := req["tools"].([]any)
if !ok || len(tools) == 0 || !hasResponsesToolSearchDeclaration(tools) {
return false, nil
}
input, ok := req["input"].([]any)
if !ok || len(input) == 0 {
return false, nil
}
known := make(map[string]responsesDiscoveredToolIdentity)
for _, raw := range tools {
registerExistingResponsesToolIdentity(known, raw)
}
promoted := make([]any, 0)
for _, rawItem := range input {
item, ok := rawItem.(map[string]any)
if !ok || strings.TrimSpace(stringValue(item["type"])) != "tool_search_output" || !usableResponsesToolSearchOutput(item) {
continue
}
discoveries, ok := item["tools"].([]any)
if !ok || len(discoveries) == 0 {
continue
}
if _, err := json.Marshal(discoveries); err != nil {
continue
}
for _, rawDiscovery := range discoveries {
discovery, ok := rawDiscovery.(map[string]any)
if !ok {
continue
}
typ := strings.TrimSpace(stringValue(discovery["type"]))
switch typ {
case "function", "custom":
copy, identity, ok := responsesDirectToolDiscovery(discovery, typ)
if !ok {
continue
}
appendTool, err := admitResponsesDiscoveredTool(known, identity.name, identity)
if err != nil {
return false, err
}
if appendTool {
promoted = append(promoted, copy)
}
case "namespace":
copy, identities, ok := responsesNamespaceToolDiscovery(discovery)
if !ok {
continue
}
children := make([]any, 0, len(identities))
for _, candidate := range identities {
appendTool, err := admitResponsesDiscoveredTool(known, candidate.flat, candidate.identity)
if err != nil {
return false, err
}
if appendTool {
children = append(children, candidate.child)
}
}
if len(children) > 0 {
copy["tools"] = children
delete(copy, "children")
promoted = append(promoted, copy)
}
}
}
}
if len(promoted) == 0 {
return false, nil
}
req["tools"] = append(tools, promoted...)
return true, nil
}
func hasResponsesToolSearchDeclaration(tools []any) bool {
for _, raw := range tools {
tool, ok := raw.(map[string]any)
if ok && strings.TrimSpace(stringValue(tool["type"])) == "tool_search" {
return true
}
}
return false
}
func usableResponsesToolSearchOutput(item map[string]any) bool {
status, present := item["status"]
if !present {
return true
}
text, ok := status.(string)
return ok && strings.TrimSpace(text) == "completed"
}
func registerExistingResponsesToolIdentity(known map[string]responsesDiscoveredToolIdentity, raw any) {
tool, ok := raw.(map[string]any)
if !ok {
return
}
typ := strings.TrimSpace(stringValue(tool["type"]))
switch typ {
case "function", "custom":
_, identity, ok := responsesDirectToolDiscovery(tool, typ)
if ok {
registerExistingResponsesIdentity(known, identity.name, identity)
}
case "namespace":
_, identities, ok := responsesNamespaceToolDiscovery(tool)
if !ok {
return
}
for _, candidate := range identities {
registerExistingResponsesIdentity(known, candidate.flat, candidate.identity)
}
}
}
func registerExistingResponsesIdentity(known map[string]responsesDiscoveredToolIdentity, key string, identity responsesDiscoveredToolIdentity) {
previous, exists := known[key]
if !exists {
known[key] = identity
return
}
if !sameResponsesDiscoveredTool(previous, identity) {
previous.ambiguous = true
known[key] = previous
}
}
func admitResponsesDiscoveredTool(known map[string]responsesDiscoveredToolIdentity, key string, identity responsesDiscoveredToolIdentity) (bool, error) {
previous, exists := known[key]
if !exists {
known[key] = identity
return true, nil
}
if !previous.ambiguous && sameResponsesDiscoveredTool(previous, identity) {
return false, nil
}
return false, fmt.Errorf("discovered tool %q conflicts with an existing declaration; this upstream cannot safely disambiguate different names, namespaces, or schemas", key)
}
func sameResponsesDiscoveredTool(left, right responsesDiscoveredToolIdentity) bool {
return left.typ == right.typ && left.name == right.name && left.namespace == right.namespace && left.encoded == right.encoded
}
func responsesDirectToolDiscovery(tool map[string]any, typ string) (map[string]any, responsesDiscoveredToolIdentity, bool) {
name := strings.TrimSpace(stringValue(tool["name"]))
if name == "" {
return nil, responsesDiscoveredToolIdentity{}, false
}
copy := copyClientTool(tool)
copy["type"] = typ
copy["name"] = name
identityCopy := copy
if typ == "custom" {
identityCopy = copyClientTool(copy)
identityCopy["type"] = "function"
identityCopy["parameters"] = json.RawMessage(customToolInputSchema)
delete(identityCopy, "format")
}
encoded, err := json.Marshal(identityCopy)
if err != nil {
return nil, responsesDiscoveredToolIdentity{}, false
}
return copy, responsesDiscoveredToolIdentity{typ: typ, name: name, encoded: string(encoded)}, true
}
// restoreInheritedResponsesClientToolDeclarations reverses only the declaration
// identities recorded by ResponsesClientToolMapping. It is used when a WS
// continuation omits tools but the HTTP function upstream still needs the
// effective session declarations on every request.
func restoreInheritedResponsesClientToolDeclarations(lowered []any, mapping ResponsesClientToolMapping) []any {
restored := make([]any, 0, len(lowered))
for _, raw := range lowered {
tool, ok := raw.(map[string]any)
if !ok {
restored = append(restored, raw)
continue
}
name := strings.TrimSpace(stringValue(tool["name"]))
switch {
case mapping.ToolSearch && name == toolSearchProxyName:
restored = append(restored, map[string]any{"type": "tool_search"})
case mapping.CustomTools[name]:
copy := copyClientTool(tool)
copy["type"] = "custom"
restored = append(restored, copy)
case mapping.NamespaceTools[name].Namespace != "":
identity := mapping.NamespaceTools[name]
child := copyClientTool(tool)
child["type"] = "function"
child["name"] = identity.Name
restored = append(restored, map[string]any{
"type": "namespace", "name": identity.Namespace, "tools": []any{child},
})
default:
restored = append(restored, copyClientTool(tool))
}
}
return restored
}
type responsesNamespaceToolCandidate struct {
flat string
child map[string]any
identity responsesDiscoveredToolIdentity
}
func responsesNamespaceToolDiscovery(tool map[string]any) (map[string]any, []responsesNamespaceToolCandidate, bool) {
namespace := strings.TrimSpace(stringValue(tool["name"]))
children := namespaceChildren(tool)
if namespace == "" || len(children) == 0 {
return nil, nil, false
}
copy := copyClientTool(tool)
copy["type"] = "namespace"
copy["name"] = namespace
identities := make([]responsesNamespaceToolCandidate, 0, len(children))
for _, rawChild := range children {
child, ok := rawChild.(map[string]any)
if !ok || strings.TrimSpace(stringValue(child["type"])) != "function" {
continue
}
childCopy, direct, ok := responsesDirectToolDiscovery(child, "function")
if !ok {
continue
}
flat := flattenNamespaceToolName(namespace, direct.name)
identities = append(identities, responsesNamespaceToolCandidate{
flat: flat,
child: childCopy,
identity: responsesDiscoveredToolIdentity{
typ: "namespace", name: direct.name, namespace: namespace, encoded: direct.encoded,
},
})
}
if len(identities) == 0 {
return nil, nil, false
}
return copy, identities, true
}
@@ -0,0 +1,145 @@
package apicompat
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAnthropicStreamingMaxTokens_MapsToIncomplete(t *testing.T) {
state := NewAnthropicEventToResponsesState()
AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_start",
Message: &AnthropicResponse{ID: "msg_test", Model: "claude-opus-4-6", Role: "assistant"},
}, state)
AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_delta",
Delta: &AnthropicDelta{
StopReason: "max_tokens",
},
Usage: &AnthropicUsage{OutputTokens: 4096},
}, state)
require.Equal(t, "max_tokens", state.StopReason)
events := AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_stop",
}, state)
var completed *ResponsesStreamEvent
for i := range events {
if events[i].Type == "response.completed" || events[i].Type == "response.incomplete" {
completed = &events[i]
break
}
}
require.NotNil(t, completed, "should have terminal event")
assert.Equal(t, "response.incomplete", completed.Type)
require.NotNil(t, completed.Response)
assert.Equal(t, "incomplete", completed.Response.Status)
require.NotNil(t, completed.Response.IncompleteDetails)
assert.Equal(t, "max_output_tokens", completed.Response.IncompleteDetails.Reason)
}
func TestAnthropicStreamingMaxTokens_FinalizeMapsToIncompleteWithoutMessageStop(t *testing.T) {
state := NewAnthropicEventToResponsesState()
AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_start",
Message: &AnthropicResponse{ID: "msg_test", Model: "claude-opus-4-6", Role: "assistant"},
}, state)
AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_delta",
Delta: &AnthropicDelta{StopReason: "max_tokens"},
Usage: &AnthropicUsage{OutputTokens: 4096},
}, state)
events := FinalizeAnthropicResponsesStream(state)
require.Len(t, events, 1)
assert.Equal(t, "response.incomplete", events[0].Type)
require.NotNil(t, events[0].Response)
assert.Equal(t, "incomplete", events[0].Response.Status)
require.NotNil(t, events[0].Response.IncompleteDetails)
assert.Equal(t, "max_output_tokens", events[0].Response.IncompleteDetails.Reason)
assert.Empty(t, FinalizeAnthropicResponsesStream(state), "repeated finalization must be idempotent")
}
func TestAnthropicStreamingEndTurn_MapsToCompleted(t *testing.T) {
state := NewAnthropicEventToResponsesState()
AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_start",
Message: &AnthropicResponse{ID: "msg_test", Model: "claude-opus-4-6", Role: "assistant"},
}, state)
AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_delta",
Delta: &AnthropicDelta{StopReason: "end_turn"},
Usage: &AnthropicUsage{OutputTokens: 100},
}, state)
events := AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_stop",
}, state)
var completed *ResponsesStreamEvent
for i := range events {
if events[i].Type == "response.completed" {
completed = &events[i]
break
}
}
require.NotNil(t, completed)
assert.Equal(t, "completed", completed.Response.Status)
assert.Nil(t, completed.Response.IncompleteDetails)
}
func TestResponsesToChatCompletions_ContentFilter(t *testing.T) {
resp := &ResponsesResponse{
ID: "resp_cf",
Status: "incomplete",
IncompleteDetails: &ResponsesIncompleteDetails{
Reason: "content_filter",
},
Output: []ResponsesOutput{{
Type: "message",
Content: []ResponsesContentPart{{Type: "output_text", Text: "partial"}},
}},
Usage: &ResponsesUsage{InputTokens: 10, OutputTokens: 5},
}
cc := ResponsesToChatCompletions(resp, "gpt-5.5")
require.Len(t, cc.Choices, 1)
assert.Equal(t, "content_filter", cc.Choices[0].FinishReason)
}
func TestResponsesToChatCompletionsStreaming_ContentFilter(t *testing.T) {
state := NewResponsesEventToChatState()
state.ID = "resp_cf"
state.Model = "gpt-5.5"
state.SentRole = true
events := ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.completed",
Response: &ResponsesResponse{
ID: "resp_cf",
Status: "incomplete",
IncompleteDetails: &ResponsesIncompleteDetails{
Reason: "content_filter",
},
},
}, state)
hasContentFilter := false
for _, chunk := range events {
for _, choice := range chunk.Choices {
if choice.FinishReason != nil && *choice.FinishReason == "content_filter" {
hasContentFilter = true
}
}
}
assert.True(t, hasContentFilter, "streaming content_filter should map to finish_reason content_filter")
}
@@ -0,0 +1,19 @@
{
"id": "chatcmpl-sanitized",
"object": "chat.completion",
"model": "reasoning-model",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "final answer",
"reasoning": "fallback reasoning",
"reasoning_details": [
{"type": "reasoning.text", "text": "fallback reasoning"}
]
},
"finish_reason": "stop"
}
]
}
@@ -0,0 +1,15 @@
{
"id": "chatcmpl-sanitized",
"object": "chat.completion.chunk",
"model": "reasoning-model",
"choices": [
{
"index": 0,
"delta": {
"reasoning_content": "preferred reasoning",
"reasoning": "fallback reasoning"
},
"finish_reason": null
}
]
}
@@ -0,0 +1,17 @@
{
"id": "chatcmpl-sanitized",
"object": "chat.completion.chunk",
"model": "reasoning-model",
"choices": [
{
"index": 0,
"delta": {
"reasoning": "streamed fallback",
"reasoning_details": [
{"type": "reasoning.text", "text": "streamed fallback"}
]
},
"finish_reason": null
}
]
}
+815
View File
@@ -0,0 +1,815 @@
// Package apicompat provides type definitions and conversion utilities for
// translating between Anthropic Messages and OpenAI Responses API formats.
// It enables multi-protocol support so that clients using different API
// formats can be served through a unified gateway.
package apicompat
import (
"bytes"
"encoding/json"
)
// ---------------------------------------------------------------------------
// Anthropic Messages API types
// ---------------------------------------------------------------------------
// AnthropicRequest is the request body for POST /v1/messages.
type AnthropicRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System json.RawMessage `json:"system,omitempty"` // string or []AnthropicContentBlock
Messages []AnthropicMessage `json:"messages"`
Tools []AnthropicTool `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
StopSeqs []string `json:"stop_sequences,omitempty"`
Thinking *AnthropicThinking `json:"thinking,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
// Metadata 会被原样透传给上游。OAuth/Claude-Code 路径依赖 metadata.user_id
// 参与上游的"是否为官方 Claude Code 请求"判定;如果经由本结构体重新序列化
// 时丢弃该字段,网关侧后续的 metadata 重写(ensureClaudeOAuthMetadataUserID/
// RewriteUserIDWithMasking) 在 body 里拿不到起点,就无法重建一个合法的
// user_id,进而导致请求被归类为第三方 app。
Metadata json.RawMessage `json:"metadata,omitempty"`
OutputConfig *AnthropicOutputConfig `json:"output_config,omitempty"`
}
// AnthropicOutputConfig controls output generation parameters.
type AnthropicOutputConfig struct {
Effort string `json:"effort,omitempty"` // "low" | "medium" | "high" | "max"
}
// AnthropicThinking configures extended thinking in the Anthropic API.
type AnthropicThinking struct {
Type string `json:"type"` // "enabled" | "adaptive" | "disabled"
BudgetTokens int `json:"budget_tokens,omitempty"` // max thinking tokens
}
// AnthropicMessage is a single message in the Anthropic conversation.
type AnthropicMessage struct {
Role string `json:"role"` // "user" | "assistant"
Content json.RawMessage `json:"content"`
}
// AnthropicContentBlock is one block inside a message's content array.
type AnthropicContentBlock struct {
Type string `json:"type"`
CacheControl *AnthropicCacheControl `json:"cache_control,omitempty"`
// type=text
Text string `json:"text,omitempty"`
// type=thinking
Thinking string `json:"thinking,omitempty"`
// Signature carries provider encrypted reasoning (e.g. xAI encrypted_content)
// so multi-turn Claude clients can round-trip it back on subsequent turns.
Signature string `json:"signature,omitempty"`
// type=image
Source *AnthropicImageSource `json:"source,omitempty"`
// type=tool_use
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
// type=tool_result
ToolUseID string `json:"tool_use_id,omitempty"`
Content json.RawMessage `json:"content,omitempty"` // string or []AnthropicContentBlock
IsError bool `json:"is_error,omitempty"`
}
func (b AnthropicContentBlock) MarshalJSON() ([]byte, error) {
type anthropicContentBlock AnthropicContentBlock
base := struct {
anthropicContentBlock
}{anthropicContentBlock: anthropicContentBlock(b)}
switch b.Type {
case "text":
return json.Marshal(struct {
Text string `json:"text"`
anthropicContentBlock
}{Text: b.Text, anthropicContentBlock: anthropicContentBlock(b)})
case "thinking":
return json.Marshal(struct {
Thinking string `json:"thinking"`
anthropicContentBlock
}{Thinking: b.Thinking, anthropicContentBlock: anthropicContentBlock(b)})
default:
return json.Marshal(base)
}
}
// AnthropicImageSource describes the source data for an image content block.
type AnthropicImageSource struct {
Type string `json:"type"` // "base64"
MediaType string `json:"media_type"`
Data string `json:"data"`
}
// AnthropicTool describes a tool available to the model.
type AnthropicTool struct {
Type string `json:"type,omitempty"` // e.g. "web_search_20250305" for server tools
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"input_schema,omitempty"` // JSON Schema object
CacheControl *AnthropicCacheControl `json:"cache_control,omitempty"`
}
// AnthropicCacheControl 对应 Anthropic API 的 cache_control 字段。
// ttl 默认由调用方决定;本项目策略见 claude.DefaultCacheControlTTL。
type AnthropicCacheControl struct {
Type string `json:"type"` // "ephemeral"
TTL string `json:"ttl,omitempty"` // "5m" / "1h" / 省略=默认 5m(由 Anthropic 判定)
}
// AnthropicResponse is the non-streaming response from POST /v1/messages.
//
// StopReason is a pointer so streaming message_start can emit JSON null
// (official Anthropic wire format). A plain string zero-value would marshal as
// "" which strict clients treat as invalid mid-stream state.
type AnthropicResponse struct {
ID string `json:"id"`
Type string `json:"type"` // "message"
Role string `json:"role"` // "assistant"
Content []AnthropicContentBlock `json:"content"`
Model string `json:"model"`
StopReason *string `json:"stop_reason"`
StopSequence *string `json:"stop_sequence,omitempty"`
Usage AnthropicUsage `json:"usage"`
}
// AnthropicStopReasonPtr returns a non-nil pointer to s for final stop reasons.
func AnthropicStopReasonPtr(s string) *string {
return &s
}
// AnthropicStopReasonString returns the stop reason value, or "" when unset/null.
func AnthropicStopReasonString(p *string) string {
if p == nil {
return ""
}
return *p
}
// AnthropicUsage holds token counts in Anthropic format.
type AnthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
}
// ---------------------------------------------------------------------------
// Anthropic SSE event types
// ---------------------------------------------------------------------------
// AnthropicStreamEvent is a single SSE event in the Anthropic streaming protocol.
type AnthropicStreamEvent struct {
Type string `json:"type"`
// message_start
Message *AnthropicResponse `json:"message,omitempty"`
// content_block_start
Index *int `json:"index,omitempty"`
ContentBlock *AnthropicContentBlock `json:"content_block,omitempty"`
// content_block_delta
Delta *AnthropicDelta `json:"delta,omitempty"`
// message_delta
Usage *AnthropicUsage `json:"usage,omitempty"`
}
// AnthropicDelta carries incremental content in streaming events.
type AnthropicDelta struct {
Type string `json:"type,omitempty"` // "text_delta" | "input_json_delta" | "thinking_delta" | "signature_delta"
// text_delta
Text string `json:"text,omitempty"`
// input_json_delta
PartialJSON string `json:"partial_json,omitempty"`
// thinking_delta
Thinking string `json:"thinking,omitempty"`
// signature_delta
Signature string `json:"signature,omitempty"`
// message_delta fields
StopReason string `json:"stop_reason,omitempty"`
StopSequence *string `json:"stop_sequence,omitempty"`
}
// ---------------------------------------------------------------------------
// OpenAI Responses API types
// ---------------------------------------------------------------------------
// ResponsesRequest is the request body for POST /v1/responses.
type ResponsesRequest struct {
Model string `json:"model"`
Instructions string `json:"instructions,omitempty"`
Input json.RawMessage `json:"input"` // string or []ResponsesInputItem
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []ResponsesTool `json:"tools,omitempty"`
Include []string `json:"include,omitempty"`
Store *bool `json:"store,omitempty"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
Reasoning *ResponsesReasoning `json:"reasoning,omitempty"`
Text *ResponsesText `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
}
// ResponsesReasoning configures reasoning effort in the Responses API.
type ResponsesReasoning struct {
Effort string `json:"effort"` // "low" | "medium" | "high" | "xhigh"
Summary string `json:"summary,omitempty"` // "auto" | "concise" | "detailed"
}
// ResponsesText configures text output options in the Responses API.
type ResponsesText struct {
Format json.RawMessage `json:"format,omitempty"`
Verbosity string `json:"verbosity,omitempty"` // "low" | "medium" | "high"
}
// ResponsesInputItem is one item in the Responses API input array.
// The Type field determines which other fields are populated.
type ResponsesInputItem struct {
// Common
Type string `json:"type,omitempty"` // "" for role-based messages
// Role-based messages (developer/system/user/assistant)
Role string `json:"role,omitempty"`
Content json.RawMessage `json:"content,omitempty"` // string or []ResponsesContentPart
// type=reasoning (multi-turn replay of encrypted reasoning)
EncryptedContent string `json:"encrypted_content,omitempty"`
// type=function_call
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
ID string `json:"id,omitempty"`
// type=function_call_output
Output string `json:"output,omitempty"`
outputRaw json.RawMessage
}
func (i *ResponsesInputItem) UnmarshalJSON(data []byte) error {
type alias ResponsesInputItem
var wire struct {
*alias
Output json.RawMessage `json:"output"`
}
*i = ResponsesInputItem{}
wire.alias = (*alias)(i)
if err := json.Unmarshal(data, &wire); err != nil {
return err
}
output := bytes.TrimSpace(wire.Output)
if len(output) == 0 || bytes.Equal(output, []byte("null")) {
return nil
}
if err := json.Unmarshal(output, &i.Output); err == nil {
return nil
}
i.outputRaw = append(i.outputRaw[:0], output...)
i.Output = string(output)
return nil
}
// ResponsesContentPart is a typed content part in a Responses message.
type ResponsesContentPart struct {
Type string `json:"type"` // "input_text" | "output_text" | "input_image"
Text string `json:"text,omitempty"`
ImageURL string `json:"image_url,omitempty"` // data URI for input_image
}
// ResponsesTool describes a tool in the Responses API.
type ResponsesTool struct {
Type string `json:"type"` // "function" | "custom" | "web_search" | "x_search" | "local_shell" etc.
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
Strict *bool `json:"strict,omitempty"`
// type=namespace 的子工具列表(tools 与 children 二选一,语义相同)。
Tools []ResponsesTool `json:"tools,omitempty"`
Children []ResponsesTool `json:"children,omitempty"`
// type=x_search
AllowedXHandles []string `json:"allowed_x_handles,omitempty"`
ExcludedXHandles []string `json:"excluded_x_handles,omitempty"`
FromDate string `json:"from_date,omitempty"`
ToDate string `json:"to_date,omitempty"`
EnableImageUnderstanding *bool `json:"enable_image_understanding,omitempty"`
EnableVideoUnderstanding *bool `json:"enable_video_understanding,omitempty"`
}
// UnmarshalJSON 容忍字符串形式的工具声明:codex 会以 "name" 简写声明 custom 工具,
func (t *ResponsesTool) UnmarshalJSON(data []byte) error {
var name string
if err := json.Unmarshal(data, &name); err == nil {
*t = ResponsesTool{Type: "custom", Name: name}
return nil
}
type alias ResponsesTool
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*t = ResponsesTool(a)
return nil
}
// ResponsesResponse is the non-streaming response from POST /v1/responses.
type ResponsesResponse struct {
ID string `json:"id"`
Object string `json:"object"` // "response"
Model string `json:"model"`
Status string `json:"status"` // "completed" | "incomplete" | "failed"
Output []ResponsesOutput `json:"output"`
Usage *ResponsesUsage `json:"usage,omitempty"`
// incomplete_details is present when status="incomplete"
IncompleteDetails *ResponsesIncompleteDetails `json:"incomplete_details,omitempty"`
// Error is present when status="failed"
Error *ResponsesError `json:"error,omitempty"`
}
// ResponsesError describes an error in a failed response.
type ResponsesError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// ResponsesIncompleteDetails explains why a response is incomplete.
type ResponsesIncompleteDetails struct {
Reason string `json:"reason"` // "max_output_tokens" | "content_filter"
}
// ResponsesOutput is one output item in a Responses API response.
type ResponsesOutput struct {
Type string `json:"type"` // "message" | "reasoning" | "function_call" | "web_search_call"
// type=message
ID string `json:"id,omitempty"`
Role string `json:"role,omitempty"`
Content []ResponsesContentPart `json:"content,omitempty"`
Status string `json:"status,omitempty"`
// type=reasoning
EncryptedContent string `json:"encrypted_content,omitempty"`
Summary []ResponsesSummary `json:"summary,omitempty"`
// type=function_call
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
// 来源为 namespace 子工具时的归属命名空间(codex 按 namespace+name 路由该调用)。
Namespace string `json:"namespace,omitempty"`
// type=custom_tool_callcustom/freeform 工具,input 为自由文本)
Input string `json:"input,omitempty"`
// type=web_search_call
Action *WebSearchAction `json:"action,omitempty"`
}
// MarshalJSON 处理 tool_search_call 项的线上形态(复用 CallID/Arguments 字段):
// execution 固定为 "client"codex 的必填字段,非 client 的调用会被静默忽略),
// arguments 是 JSON 对象而非 function_call 语义下的字符串。其余类型走默认结构体
// 序列化,输出逐字节不变。
func (o ResponsesOutput) MarshalJSON() ([]byte, error) {
type responsesOutputAlias ResponsesOutput
if o.Type != "tool_search_call" {
return json.Marshal(responsesOutputAlias(o))
}
m := map[string]any{
"type": o.Type,
"id": o.ID,
"call_id": o.CallID,
"execution": "client",
"arguments": toolSearchCallArgumentsJSON(o.Arguments),
}
if o.Status != "" {
m["status"] = o.Status
}
return json.Marshal(m)
}
// UnmarshalJSON accepts both the Responses function-call string form and the
// tool_search_call object form for arguments. The bridge stores arguments as a
// string internally, so object arguments are retained as their raw JSON.
func (o *ResponsesOutput) UnmarshalJSON(data []byte) error {
type responsesOutputAlias ResponsesOutput
var kind struct {
Type string `json:"type"`
}
if err := json.Unmarshal(data, &kind); err != nil {
return err
}
if kind.Type != "tool_search_call" {
var decoded responsesOutputAlias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*o = ResponsesOutput(decoded)
return nil
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
arguments, hasArguments := fields["arguments"]
delete(fields, "arguments")
normalized, err := json.Marshal(fields)
if err != nil {
return err
}
var decoded responsesOutputAlias
if err := json.Unmarshal(normalized, &decoded); err != nil {
return err
}
*o = ResponsesOutput(decoded)
if !hasArguments || string(arguments) == "null" {
return nil
}
var argumentString string
if err := json.Unmarshal(arguments, &argumentString); err == nil {
o.Arguments = argumentString
} else {
o.Arguments = string(arguments)
}
return nil
}
// WebSearchAction describes the search action in a web_search_call output item.
type WebSearchAction struct {
Type string `json:"type,omitempty"` // "search"
Query string `json:"query,omitempty"` // primary search query
}
// ResponsesSummary is a summary text block inside a reasoning output.
type ResponsesSummary struct {
Type string `json:"type"` // "summary_text"
Text string `json:"text"`
}
// ResponsesUsage holds token counts in Responses API format.
type ResponsesUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"`
// Optional detailed breakdown
InputTokensDetails *ResponsesInputTokensDetails `json:"input_tokens_details,omitempty"`
OutputTokensDetails *ResponsesOutputTokensDetails `json:"output_tokens_details,omitempty"`
}
func (u *ResponsesUsage) UnmarshalJSON(data []byte) error {
type responsesUsageAlias ResponsesUsage
type cacheTokenPresence struct {
CacheCreationTokens *int `json:"cache_creation_tokens"`
CacheWriteTokens *int `json:"cache_write_tokens"`
}
var aux struct {
responsesUsageAlias
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
CacheCreationTokens int `json:"cache_creation_tokens"`
CacheWriteInputTokens int `json:"cache_write_input_tokens"`
CacheWriteTokens int `json:"cache_write_tokens"`
PromptTokensDetails *ResponsesInputTokensDetails `json:"prompt_tokens_details,omitempty"`
CompletionTokensDetails *ResponsesOutputTokensDetails `json:"completion_tokens_details,omitempty"`
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
var nestedPresence struct {
InputTokensDetails *cacheTokenPresence `json:"input_tokens_details"`
PromptTokensDetails *cacheTokenPresence `json:"prompt_tokens_details"`
}
if err := json.Unmarshal(data, &nestedPresence); err != nil {
return err
}
*u = ResponsesUsage(aux.responsesUsageAlias)
if u.InputTokens == 0 && aux.PromptTokens != 0 {
u.InputTokens = aux.PromptTokens
}
if u.OutputTokens == 0 && aux.CompletionTokens != 0 {
u.OutputTokens = aux.CompletionTokens
}
if u.CacheCreationInputTokens == 0 {
switch {
case aux.CacheWriteInputTokens > 0:
u.CacheCreationInputTokens = aux.CacheWriteInputTokens
case aux.CacheCreationTokens > 0:
u.CacheCreationInputTokens = aux.CacheCreationTokens
case aux.CacheWriteTokens > 0:
u.CacheCreationInputTokens = aux.CacheWriteTokens
}
}
if u.InputTokensDetails == nil && aux.PromptTokensDetails != nil {
u.InputTokensDetails = aux.PromptTokensDetails
}
if u.OutputTokensDetails == nil && aux.CompletionTokensDetails != nil {
u.OutputTokensDetails = aux.CompletionTokensDetails
}
var canonicalCacheCreationTokens *int
switch {
case nestedPresence.InputTokensDetails != nil && nestedPresence.InputTokensDetails.CacheWriteTokens != nil:
canonicalCacheCreationTokens = nestedPresence.InputTokensDetails.CacheWriteTokens
case nestedPresence.PromptTokensDetails != nil && nestedPresence.PromptTokensDetails.CacheWriteTokens != nil:
canonicalCacheCreationTokens = nestedPresence.PromptTokensDetails.CacheWriteTokens
case nestedPresence.InputTokensDetails != nil && nestedPresence.InputTokensDetails.CacheCreationTokens != nil:
canonicalCacheCreationTokens = nestedPresence.InputTokensDetails.CacheCreationTokens
case nestedPresence.PromptTokensDetails != nil && nestedPresence.PromptTokensDetails.CacheCreationTokens != nil:
canonicalCacheCreationTokens = nestedPresence.PromptTokensDetails.CacheCreationTokens
}
if canonicalCacheCreationTokens != nil {
u.CacheCreationInputTokens = max(*canonicalCacheCreationTokens, 0)
}
if u.TotalTokens == 0 && (u.InputTokens != 0 || u.OutputTokens != 0) {
u.TotalTokens = u.InputTokens + u.OutputTokens
}
return nil
}
// ResponsesInputTokensDetails breaks down input token usage.
type ResponsesInputTokensDetails struct {
CachedTokens int `json:"cached_tokens,omitempty"`
AudioTokens int `json:"audio_tokens,omitempty"`
CacheCreationTokens int `json:"cache_creation_tokens,omitempty"`
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
}
// ResponsesOutputTokensDetails breaks down output token usage.
type ResponsesOutputTokensDetails struct {
ReasoningTokens int `json:"reasoning_tokens,omitempty"`
AudioTokens int `json:"audio_tokens,omitempty"`
AcceptedPredictionTokens int `json:"accepted_prediction_tokens,omitempty"`
RejectedPredictionTokens int `json:"rejected_prediction_tokens,omitempty"`
}
// ---------------------------------------------------------------------------
// Responses SSE event types
// ---------------------------------------------------------------------------
// ResponsesStreamEvent is a single SSE event in the Responses streaming protocol.
// The Type field corresponds to the "type" in the JSON payload.
type ResponsesStreamEvent struct {
Type string `json:"type"`
// response.created / response.completed / response.done / response.failed / response.incomplete
Response *ResponsesResponse `json:"response,omitempty"`
// 部分 OpenAI 兼容上游会把 usage 放在终止事件顶层,而不是 response.usage。
Usage *ResponsesUsage `json:"usage,omitempty"`
// response.output_item.added / response.output_item.done
Item *ResponsesOutput `json:"item,omitempty"`
// response.output_text.delta / response.output_text.done
OutputIndex int `json:"output_index,omitempty"`
ContentIndex int `json:"content_index,omitempty"`
Delta string `json:"delta,omitempty"`
Text string `json:"text,omitempty"`
ItemID string `json:"item_id,omitempty"`
// response.function_call_arguments.delta / done
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
// response.custom_tool_call_input.done
Input string `json:"input,omitempty"`
// response.reasoning_summary_text.delta / done
// Reuses Text/Delta fields above, SummaryIndex identifies which summary part
SummaryIndex int `json:"summary_index,omitempty"`
// response.content_part.added / done and
// response.reasoning_summary_part.added / done
Part *ResponsesContentPart `json:"part,omitempty"`
// error event fields
Code string `json:"code,omitempty"`
Param string `json:"param,omitempty"`
// Sequence number for ordering events
SequenceNumber int `json:"sequence_number,omitempty"`
}
// ---------------------------------------------------------------------------
// OpenAI Chat Completions API types
// ---------------------------------------------------------------------------
// ChatCompletionsRequest is the request body for POST /v1/chat/completions.
type ChatCompletionsRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Instructions string `json:"instructions,omitempty"` // OpenAI Responses API compat
MaxTokens *int `json:"max_tokens,omitempty"`
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"`
StreamOptions *ChatStreamOptions `json:"stream_options,omitempty"`
Tools []ChatTool `json:"tools,omitempty"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"` // "low" | "medium" | "high" | "xhigh"
ServiceTier string `json:"service_tier,omitempty"`
Stop json.RawMessage `json:"stop,omitempty"` // string or []string
ResponseFormat json.RawMessage `json:"response_format,omitempty"`
// Legacy function calling (deprecated but still supported)
Functions []ChatFunction `json:"functions,omitempty"`
FunctionCall json.RawMessage `json:"function_call,omitempty"`
}
// ChatStreamOptions configures streaming behavior.
type ChatStreamOptions struct {
IncludeUsage bool `json:"include_usage,omitempty"`
}
// ChatMessage is a single message in the Chat Completions conversation.
type ChatMessage struct {
Role string `json:"role"` // "system" | "user" | "assistant" | "tool" | "function"
Content json.RawMessage `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
Reasoning string `json:"reasoning,omitempty"`
Name string `json:"name,omitempty"`
ToolCalls []ChatToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
// Legacy function calling
FunctionCall *ChatFunctionCall `json:"function_call,omitempty"`
}
// ChatContentPart is a typed content part in a multi-modal message.
type ChatContentPart struct {
Type string `json:"type"` // "text" | "image_url"
Text string `json:"text,omitempty"`
ImageURL *ChatImageURL `json:"image_url,omitempty"`
}
// ChatImageURL contains the URL for an image content part.
type ChatImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"` // "auto" | "low" | "high"
}
// ChatTool describes a tool available to the model.
type ChatTool struct {
Type string `json:"type"` // "function" | "x_search"
Function *ChatFunction `json:"function,omitempty"`
// type=x_search
AllowedXHandles []string `json:"allowed_x_handles,omitempty"`
ExcludedXHandles []string `json:"excluded_x_handles,omitempty"`
FromDate string `json:"from_date,omitempty"`
ToDate string `json:"to_date,omitempty"`
EnableImageUnderstanding *bool `json:"enable_image_understanding,omitempty"`
EnableVideoUnderstanding *bool `json:"enable_video_understanding,omitempty"`
}
// ChatFunction describes a function tool definition.
type ChatFunction struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
Strict *bool `json:"strict,omitempty"`
}
// ChatToolCall represents a tool call made by the assistant.
// Index is only populated in streaming chunks (omitted in non-streaming responses).
type ChatToolCall struct {
Index *int `json:"index,omitempty"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"` // "function"
Function ChatFunctionCall `json:"function"`
}
// ChatFunctionCall contains the function name and arguments.
type ChatFunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// ChatCompletionsResponse is the non-streaming response from POST /v1/chat/completions.
type ChatCompletionsResponse struct {
ID string `json:"id"`
Object string `json:"object"` // "chat.completion"
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChatChoice `json:"choices"`
Usage *ChatUsage `json:"usage,omitempty"`
SystemFingerprint string `json:"system_fingerprint,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
}
// ChatChoice is a single completion choice.
type ChatChoice struct {
Index int `json:"index"`
Message ChatMessage `json:"message"`
FinishReason string `json:"finish_reason"` // "stop" | "length" | "tool_calls" | "content_filter"
}
// ChatUsage holds token counts in Chat Completions format.
type ChatUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptTokensDetails *ChatTokenDetails `json:"prompt_tokens_details,omitempty"`
CompletionTokensDetails *ChatTokenDetails `json:"completion_tokens_details,omitempty"`
}
// ChatTokenDetails provides a breakdown of token usage. The same type is
// reused for both prompt_tokens_details and completion_tokens_details;
// unset fields are omitted so each side only emits the fields that apply.
//
// Field set mirrors OpenAI's official CompletionUsage schema:
// - prompt_tokens_details: cached_tokens, audio_tokens
// - completion_tokens_details: reasoning_tokens, audio_tokens,
// accepted_prediction_tokens, rejected_prediction_tokens
type ChatTokenDetails struct {
CachedTokens int `json:"cached_tokens,omitempty"`
AudioTokens int `json:"audio_tokens,omitempty"`
CacheCreationTokens int `json:"cache_creation_tokens,omitempty"`
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
ReasoningTokens int `json:"reasoning_tokens,omitempty"`
AcceptedPredictionTokens int `json:"accepted_prediction_tokens,omitempty"`
RejectedPredictionTokens int `json:"rejected_prediction_tokens,omitempty"`
}
// ChatCompletionsChunk is a single streaming chunk from POST /v1/chat/completions.
type ChatCompletionsChunk struct {
ID string `json:"id"`
Object string `json:"object"` // "chat.completion.chunk"
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChatChunkChoice `json:"choices"`
Usage *ChatUsage `json:"usage,omitempty"`
SystemFingerprint string `json:"system_fingerprint,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
}
// ChatChunkChoice is a single choice in a streaming chunk.
type ChatChunkChoice struct {
Index int `json:"index"`
Delta ChatDelta `json:"delta"`
FinishReason *string `json:"finish_reason"` // pointer: null when not final
}
// ChatDelta carries incremental content in a streaming chunk.
type ChatDelta struct {
Role string `json:"role,omitempty"`
Content *string `json:"content,omitempty"` // pointer: omit when not present, null vs "" matters
ReasoningContent *string `json:"reasoning_content,omitempty"`
Reasoning *string `json:"reasoning,omitempty"`
ToolCalls []ChatToolCall `json:"tool_calls,omitempty"`
}
func (m ChatMessage) reasoningText() string {
if m.ReasoningContent != "" {
return m.ReasoningContent
}
return m.Reasoning
}
func (d ChatDelta) reasoningText() *string {
if d.ReasoningContent != nil {
return d.ReasoningContent
}
return d.Reasoning
}
// ---------------------------------------------------------------------------
// Shared constants
// ---------------------------------------------------------------------------
// minMaxOutputTokens is the floor for max_output_tokens in a Responses request.
// Very small values may cause upstream API errors, so we enforce a minimum.
const minMaxOutputTokens = 128