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
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:
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 = ¶llelToolCalls
|
||||
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 = ¶llelToolCalls
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// anthropicToChatMessages converts the Anthropic system field + message list
|
||||
// into Chat Completions messages. It mirrors convertAnthropicToResponsesInput +
|
||||
// responsesInputToChatMessages but produces ChatMessage directly.
|
||||
func anthropicToChatMessages(system json.RawMessage, msgs []AnthropicMessage) ([]ChatMessage, error) {
|
||||
var messages []ChatMessage
|
||||
|
||||
// System prompt → system message. parseAnthropicSystemContentParts handles
|
||||
// both string and []block forms and filters the billing header.
|
||||
if len(system) > 0 {
|
||||
sysParts, err := parseAnthropicSystemContentParts(system)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sysParts) > 0 {
|
||||
text := joinResponsesContentPartText(sysParts)
|
||||
if text != "" {
|
||||
content, _ := json.Marshal(text)
|
||||
messages = append(messages, ChatMessage{Role: "system", Content: content})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range msgs {
|
||||
converted, err := anthropicMsgToChatMessages(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = append(messages, converted...)
|
||||
}
|
||||
|
||||
return normalizeChatMessages(messages), nil
|
||||
}
|
||||
|
||||
// anthropicMsgToChatMessages converts one Anthropic message into one or more
|
||||
// Chat messages. tool_result blocks become standalone "tool" role messages
|
||||
// (the Chat Completions convention); text/image blocks stay in a user message;
|
||||
// assistant tool_use blocks become tool_calls on the assistant message.
|
||||
func anthropicMsgToChatMessages(m AnthropicMessage) ([]ChatMessage, error) {
|
||||
switch m.Role {
|
||||
case "assistant":
|
||||
return anthropicAssistantToChatMessages(m.Content)
|
||||
default: // "user" and any unknown role
|
||||
return anthropicUserToChatMessages(m.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// anthropicUserToChatMessages handles an Anthropic user message. Content may be
|
||||
// a plain string or an array of blocks. tool_result blocks are extracted into
|
||||
// standalone "tool" role messages; images inside tool_results are lifted into a
|
||||
// follow-up user message as image_url parts (the Responses bridge does the same
|
||||
// — function_call_output only accepts strings, so images must travel separately).
|
||||
func anthropicUserToChatMessages(raw json.RawMessage) ([]ChatMessage, error) {
|
||||
// Plain string → single user message.
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
content, _ := json.Marshal(s)
|
||||
return []ChatMessage{{Role: "user", Content: content}}, nil
|
||||
}
|
||||
|
||||
var blocks []AnthropicContentBlock
|
||||
if err := json.Unmarshal(raw, &blocks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []ChatMessage
|
||||
var toolResultImageParts []ChatContentPart
|
||||
|
||||
// tool_result → "tool" role messages, text extracted; images deferred.
|
||||
for _, b := range blocks {
|
||||
if b.Type != "tool_result" {
|
||||
continue
|
||||
}
|
||||
text, imageParts := convertToolResultOutput(b)
|
||||
content, _ := json.Marshal(text)
|
||||
out = append(out, ChatMessage{
|
||||
Role: "tool",
|
||||
Content: content,
|
||||
ToolCallID: b.ToolUseID,
|
||||
})
|
||||
for _, ip := range imageParts {
|
||||
toolResultImageParts = append(toolResultImageParts, ChatContentPart{
|
||||
Type: "image_url",
|
||||
ImageURL: &ChatImageURL{URL: ip.ImageURL},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining text + image blocks → user message. 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.MarshalJSON,namespace 必须落到线上 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: ¶llel,
|
||||
}
|
||||
|
||||
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_content(DeepSeek 契约)。
|
||||
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_call(custom/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 的 reasoning:encrypted_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_call(custom/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_call(custom/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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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_call(custom/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
|
||||
Reference in New Issue
Block a user