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

2001 lines
67 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package apicompat
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
)
const (
toolOutputMediaMarker = "[Tool output media moved to the following user message]"
toolOutputMediaAttribution = "[Tool output media for call %s]"
)
type toolOutputMediaByCallID map[string][]ChatContentPart
// ResponsesToChatOptions carries optional hooks for
// ResponsesToChatCompletionsRequestWithOptions. All fields are optional; a nil
// *ResponsesToChatOptions behaves exactly like ResponsesToChatCompletionsRequest.
type ResponsesToChatOptions struct {
// ReasoningContentByID looks up the cached reasoning text for a reasoning
// item id. Codex histories may carry reasoning items with no plaintext
// summary (empty summary + opaque encrypted_content, e.g. after remote
// compaction); DeepSeek's thinking mode rejects such histories with 400
// "The `reasoning_content` in the thinking mode must be passed back to the
// API". The gateway caches the reasoning text it streamed under the item
// id, so the lookup restores the reasoning_content the client can no
// longer provide. Return "" on a miss. A nil lookup keeps the original
// behavior.
ReasoningContentByID func(itemID string) string
}
// ResponsesToChatCompletionsRequest converts a Responses API request into a
// Chat Completions request for upstreams that only implement
// /v1/chat/completions.
func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsRequest, error) {
return ResponsesToChatCompletionsRequestWithOptions(req, nil)
}
// ResponsesToChatCompletionsRequestWithOptions is ResponsesToChatCompletionsRequest
// with optional hooks (see ResponsesToChatOptions).
func ResponsesToChatCompletionsRequestWithOptions(req *ResponsesRequest, opts *ResponsesToChatOptions) (*ChatCompletionsRequest, error) {
if req == nil {
return nil, fmt.Errorf("responses request is nil")
}
messages, err := responsesInputToChatMessagesWithOptions(req.Instructions, req.Input, opts)
if err != nil {
return nil, err
}
out := &ChatCompletionsRequest{
Model: req.Model,
Messages: messages,
MaxCompletionTokens: req.MaxOutputTokens,
Temperature: req.Temperature,
TopP: req.TopP,
Stream: req.Stream,
ServiceTier: req.ServiceTier,
ParallelToolCalls: req.ParallelToolCalls,
}
if req.Reasoning != nil {
out.ReasoningEffort = req.Reasoning.Effort
}
effectiveTools, err := EffectiveResponsesTools(req)
if err != nil {
return nil, err
}
if len(effectiveTools) > 0 {
tools, err := responsesToolsToChatTools(effectiveTools)
if err != nil {
return nil, err
}
out.Tools = tools
}
// tools 全部被丢弃(如仅含 web_search/image_generation 等服务端工具)时不再转发
// tool_choice:上游会拒绝 "'tool_choice' is only allowed when 'tools' are specified"。
// 指向被丢弃工具的选择项同理(见 responsesToolChoiceToChatToolChoice)。
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
}
if strings.EqualFold(strings.TrimSpace(tool.Type), "x_search") {
declared["x_search"] = true
}
}
if tc := responsesToolChoiceToChatToolChoice(req.ToolChoice, declared); len(tc) > 0 {
out.ToolChoice = tc
}
}
if req.Text != nil {
out.ResponseFormat = responsesTextFormatToChatResponseFormat(req.Text.Format)
}
return out, nil
}
// EffectiveResponsesTools returns every client-executable tool declared by a
// Responses request. Newer Codex clients place their runtime tools in an
// input item shaped as {"type":"additional_tools","tools":[...]} instead of
// the top-level tools field. Chat-only upstreams must receive both forms.
func EffectiveResponsesTools(req *ResponsesRequest) ([]ResponsesTool, error) {
if req == nil {
return nil, nil
}
tools := append([]ResponsesTool(nil), req.Tools...)
inputRaw := bytesTrimSpace(req.Input)
if len(inputRaw) == 0 || string(inputRaw) == "null" || inputRaw[0] != '[' {
return tools, nil
}
var items []json.RawMessage
if err := json.Unmarshal(inputRaw, &items); err != nil {
return nil, fmt.Errorf("parse responses input for additional tools: %w", err)
}
for _, raw := range items {
raw = bytesTrimSpace(raw)
if len(raw) == 0 || raw[0] != '{' {
continue
}
var discriminator struct {
Type string `json:"type"`
}
if err := json.Unmarshal(raw, &discriminator); err != nil {
return nil, fmt.Errorf("parse responses additional tools item: %w", err)
}
if discriminator.Type != "additional_tools" {
continue
}
var item struct {
Tools []ResponsesTool `json:"tools"`
}
if err := json.Unmarshal(raw, &item); err != nil {
return nil, fmt.Errorf("parse responses additional tools item: %w", err)
}
tools = append(tools, item.Tools...)
}
return tools, nil
}
// CustomToolNames 收集 Responses 请求中 custom/freeform 工具的名字。chat 桥回程时
// 需要据此把模型对这些工具的调用还原为 custom_tool_call 项(codex 只按该类型路由)。
func CustomToolNames(tools []ResponsesTool) map[string]bool {
var out map[string]bool
for _, tool := range tools {
if tool.Type == "custom" && tool.Name != "" {
if out == nil {
out = make(map[string]bool)
}
out[tool.Name] = true
}
}
return out
}
// NamespacedToolName 记录 namespace 子工具的原始归属(命名空间 + 裸子工具名)。
type NamespacedToolName struct {
Namespace string
Name string
}
// NamespaceToolNames 收集 Responses 请求中 namespace 子工具的摊平名 →(namespace,
// 子工具名)映射。chat 桥回程时需据此把模型对摊平工具的调用还原为带 namespace 字段
// 的 function_call 项:codex 按 namespace+name 路由,平铺名会被判为 unsupported
// call;摊平名超长时带截断哈希(见 flattenNamespaceToolName),无法按字符串切分还原。
// 摊平名撞名的请求已在转换阶段被显式拒绝(见 namespaceChildrenToChatTools),
// 此处映射不存在歧义。
func NamespaceToolNames(tools []ResponsesTool) map[string]NamespacedToolName {
var out map[string]NamespacedToolName
for _, tool := range tools {
if tool.Type != "namespace" || tool.Name == "" {
continue
}
children := tool.Tools
if len(children) == 0 {
children = tool.Children
}
for _, child := range children {
if child.Type != "function" || child.Name == "" {
continue
}
if out == nil {
out = make(map[string]NamespacedToolName)
}
out[flattenNamespaceToolName(tool.Name, child.Name)] = NamespacedToolName{
Namespace: tool.Name,
Name: child.Name,
}
}
}
return out
}
// HasToolSearchTool 判断 Responses 请求是否声明了 tool_search 服务端工具。chat 桥
// 回程时需据此把模型对代理工具的调用还原为 tool_search_call 项:codex 只在该项类型
// 且 execution=client 时执行 tool search,同名 function_call 会因 payload 不匹配
// 触发 fatal 中止整个 turn。
func HasToolSearchTool(tools []ResponsesTool) bool {
for _, tool := range tools {
if tool.Type == "tool_search" {
return true
}
}
return false
}
// responsesInputToChatMessages converts a Responses request's instructions +
// input[] into Chat Completions messages. It is a three-stage pipeline:
//
// parse — instructions become a system message; input[] is split into items
// build — buildChatMessagesFromItems walks items, attaching reasoning to the
// assistant message that produced a tool call, merging parallel tool
// calls into one assistant message, and skipping item types that have
// no Chat equivalent
// normalize — normalizeChatMessages enforces the invariants DeepSeek requires
//
// The build + normalize split keeps every protocol rule in one place rather than
// scattered across per-item cases, and makes unknown future codex item types
// fail safe instead of leaking into the upstream request.
func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage) ([]ChatMessage, error) {
return responsesInputToChatMessagesWithOptions(instructions, inputRaw, nil)
}
// responsesInputToChatMessagesWithOptions is responsesInputToChatMessages with
// optional hooks (see ResponsesToChatOptions).
func responsesInputToChatMessagesWithOptions(instructions string, inputRaw json.RawMessage, opts *ResponsesToChatOptions) ([]ChatMessage, error) {
var messages []ChatMessage
if strings.TrimSpace(instructions) != "" {
content, _ := json.Marshal(instructions)
messages = append(messages, ChatMessage{Role: "system", Content: content})
}
inputRaw = bytesTrimSpace(inputRaw)
if len(inputRaw) == 0 || string(inputRaw) == "null" {
return messages, nil
}
// Bare string input is a single user turn.
var inputText string
if err := json.Unmarshal(inputRaw, &inputText); err == nil {
content, _ := json.Marshal(inputText)
messages = append(messages, ChatMessage{Role: "user", Content: content})
return messages, nil
}
var rawItems []json.RawMessage
if err := json.Unmarshal(inputRaw, &rawItems); err != nil {
return nil, fmt.Errorf("parse responses input: %w", err)
}
built, mediaByCallID, err := buildChatMessagesFromItems(messages, rawItems, opts)
if err != nil {
return nil, err
}
return normalizeChatMessagesWithToolOutputMedia(built, mediaByCallID), nil
}
// buildChatMessagesFromItems walks the Responses input items and appends the
// corresponding Chat messages.
func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessage, opts *ResponsesToChatOptions) ([]ChatMessage, toolOutputMediaByCallID, error) {
// pendingReasoning holds the reasoning text from a reasoning item until the
// assistant message it belongs to is emitted. DeepSeek's thinking mode
// requires the reasoning_content that produced a tool call to be passed back
// on that assistant message; dropping it yields a 400. It only survives
// across an assistant message (so a following tool call in the same turn
// still receives it); any other role ends the thinking span.
var pendingReasoning string
// lastTurnReasoning is the most recent reasoning text of the current turn,
// surviving tool outputs. DeepSeek emits reasoning only once per turn, so
// chained tool calls (reasoning → call A → output A → call B) leave call B's
// assistant message without reasoning_content and DeepSeek 400s the history;
// replaying the turn's reasoning on B's message satisfies the contract. Only
// a user-side item ends the turn and clears it.
var lastTurnReasoning string
mediaByCallID := make(toolOutputMediaByCallID)
reasoningForAssistant := func() string {
if pendingReasoning != "" {
return pendingReasoning
}
return lastTurnReasoning
}
for _, raw := range rawItems {
raw = bytesTrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
continue
}
var item map[string]json.RawMessage
if err := json.Unmarshal(raw, &item); err != nil {
var text string
if textErr := json.Unmarshal(raw, &text); textErr == nil {
content, _ := json.Marshal(text)
messages = append(messages, ChatMessage{Role: "user", Content: content})
pendingReasoning = ""
lastTurnReasoning = ""
continue
}
return nil, nil, fmt.Errorf("parse responses input item: %w", err)
}
role := chatCompletionsBridgeRole(rawString(item["role"]))
itemType := rawString(item["type"])
switch itemType {
case "reasoning":
if txt := extractResponsesReasoningText(item); txt != "" {
pendingReasoning = txt
} else if opts != nil && opts.ReasoningContentByID != nil {
// No plaintext summary (encrypted-only reasoning, e.g. after codex
// remote compaction): fall back to the gateway-side cache keyed
// by the reasoning item id, which always round-trips in history.
if id := rawString(item["id"]); id != "" {
if cached := opts.ReasoningContentByID(id); cached != "" {
pendingReasoning = cached
}
}
}
if pendingReasoning != "" {
lastTurnReasoning = pendingReasoning
}
continue
case "function_call":
arguments := rawString(item["arguments"])
if strings.TrimSpace(arguments) == "" {
arguments = "{}"
}
name := rawString(item["name"])
// namespace 子工具的历史调用带 namespace 字段,需与请求方向的摊平
// 命名(namespaceChildrenToChatTools)保持一致。
if ns := rawString(item["namespace"]); ns != "" {
name = flattenNamespaceToolName(ns, name)
}
toolCall := ChatToolCall{
ID: rawString(item["call_id"]),
Type: "function",
Function: ChatFunctionCall{
Name: name,
Arguments: arguments,
},
}
messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant())
pendingReasoning = ""
continue
case "tool_search_call":
// tool_search 调用的 arguments 是 JSON 对象(如 {"query": ...}),
// 原文即为降级 function 调用的 arguments 字符串。
arguments := strings.TrimSpace(string(bytesTrimSpace(item["arguments"])))
if s := rawString(item["arguments"]); s != "" {
arguments = s
}
if arguments == "" || arguments == "null" {
arguments = "{}"
}
toolCall := ChatToolCall{
ID: rawString(item["call_id"]),
Type: "function",
Function: ChatFunctionCall{
Name: toolSearchProxyName,
Arguments: arguments,
},
}
messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant())
pendingReasoning = ""
continue
case "custom_tool_call":
// custom/freeform 工具的历史调用:input 自由文本包进降级 function 工具
// 的 {"input": ...} 参数,与请求方向的工具降级(customToolInputSchema
// 保持一致,模型才能把历史与当前工具定义对上。
arguments, _ := json.Marshal(map[string]string{"input": rawString(item["input"])})
toolCall := ChatToolCall{
ID: rawString(item["call_id"]),
Type: "function",
Function: ChatFunctionCall{
Name: rawString(item["name"]),
Arguments: string(arguments),
},
}
messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant())
pendingReasoning = ""
continue
case "function_call_output", "custom_tool_call_output", "tool_search_output":
outputRaw := bytesTrimSpace(item["output"])
callID := rawString(item["call_id"])
delete(mediaByCallID, callID)
outputText, media, rewritten := extractToolOutputMedia(outputRaw)
if rewritten {
if callID != "" {
mediaByCallID[callID] = media
}
} else {
outputText = rawString(outputRaw)
if outputText == "" && len(outputRaw) > 0 && string(outputRaw) != "null" && string(outputRaw) != `""` {
// 对象/数组形式的输出(如 tool_search 的结果列表)整体字符串化。
outputText = string(outputRaw)
}
}
content, _ := json.Marshal(outputText)
messages = append(messages, ChatMessage{
Role: "tool",
ToolCallID: callID,
Content: content,
})
pendingReasoning = ""
continue
case "input_text", "text":
content, _ := json.Marshal(rawString(item["text"]))
messages = append(messages, ChatMessage{Role: "user", Content: content})
pendingReasoning = ""
lastTurnReasoning = ""
continue
case "input_image":
content, err := chatContentFromSingleResponsesPart(itemType, item)
if err != nil {
return nil, nil, err
}
messages = append(messages, ChatMessage{Role: "user", Content: content})
pendingReasoning = ""
lastTurnReasoning = ""
continue
}
// Only genuine message items become chat messages. Codex emits other
// Responses item types with no Chat equivalent (web_search_call,
// local_shell_call, file_search_call, ...). Converting them via the
// generic path would insert a spurious message between an assistant
// tool_calls message and its tool reply, which DeepSeek rejects
// ("insufficient tool messages following tool_calls message"). Skip them.
if itemType != "" && itemType != "message" {
pendingReasoning = ""
continue
}
content := item["content"]
if len(bytesTrimSpace(content)) == 0 {
if text := rawString(item["text"]); text != "" {
content, _ = json.Marshal(text)
}
}
chatContent, err := responsesContentToChatContent(content, role)
if err != nil {
return nil, nil, err
}
msg := ChatMessage{Role: role, Content: chatContent}
// DeepSeek thinking mode requires the reasoning_content from a prior
// reasoning-only / plain-text assistant turn to be passed back on its
// assistant message; dropping it yields 400 "The `reasoning_content` in
// the thinking mode must be passed back to the API" on the next turn.
// A following function_call in the same turn still receives it because
// appendAssistantToolCall merges into this message and only fills
// ReasoningContent when it is still empty.
if role == "assistant" {
msg.ReasoningContent = reasoningForAssistant()
pendingReasoning = ""
} else {
pendingReasoning = ""
lastTurnReasoning = ""
}
messages = append(messages, msg)
}
return messages, mediaByCallID, nil
}
// extractToolOutputMedia rewrites only recognized image nodes. Media-free
// outputs return rewritten=false so the caller can preserve their original
// bytes and prompt-cache prefix.
func extractToolOutputMedia(outputRaw json.RawMessage) (string, []ChatContentPart, bool) {
outputRaw = bytesTrimSpace(outputRaw)
if len(outputRaw) == 0 || string(outputRaw) == "null" {
return "", nil, false
}
var outputString string
if err := json.Unmarshal(outputRaw, &outputString); err == nil {
if isToolOutputImageDataURL(outputString) {
return toolOutputMediaMarker, []ChatContentPart{toolOutputImagePart(outputString)}, true
}
nested, ok := decodeToolOutputJSON([]byte(outputString))
if !ok {
return "", nil, false
}
rewritten, media, changed := rewriteToolOutputMediaValue(nested)
if !changed {
return "", nil, false
}
encoded, err := json.Marshal(rewritten)
if err != nil {
return "", nil, false
}
return string(encoded), media, true
}
value, ok := decodeToolOutputJSON(outputRaw)
if !ok {
return "", nil, false
}
rewritten, media, changed := rewriteToolOutputMediaValue(value)
if !changed {
return "", nil, false
}
encoded, err := json.Marshal(rewritten)
if err != nil {
return "", nil, false
}
return string(encoded), media, true
}
func decodeToolOutputJSON(raw []byte) (any, bool) {
if !json.Valid(raw) {
return nil, false
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, false
}
return value, true
}
func rewriteToolOutputMediaValue(value any) (any, []ChatContentPart, bool) {
switch typed := value.(type) {
case []any:
var media []ChatContentPart
changed := false
for i, item := range typed {
rewritten, itemMedia, itemChanged := rewriteToolOutputMediaValue(item)
if !itemChanged {
continue
}
typed[i] = rewritten
media = append(media, itemMedia...)
changed = true
}
return typed, media, changed
case map[string]any:
if imageURL, ok := recognizedToolOutputImageURL(typed); ok {
return map[string]any{
"type": "input_text",
"text": toolOutputMediaMarker,
}, []ChatContentPart{toolOutputImagePart(imageURL)}, true
}
content, ok := typed["content"]
if !ok {
return typed, nil, false
}
rewritten, media, changed := rewriteToolOutputMediaValue(content)
if !changed {
return typed, nil, false
}
typed["content"] = rewritten
return typed, media, true
default:
return value, nil, false
}
}
func recognizedToolOutputImageURL(value map[string]any) (string, bool) {
partType, _ := value["type"].(string)
if partType != "input_image" && partType != "image_url" {
return "", false
}
switch imageURL := value["image_url"].(type) {
case string:
return imageURL, strings.TrimSpace(imageURL) != ""
case map[string]any:
url, _ := imageURL["url"].(string)
return url, strings.TrimSpace(url) != ""
default:
return "", false
}
}
func isToolOutputImageDataURL(value string) bool {
const prefix = "data:image/"
const separator = ";base64,"
if !strings.HasPrefix(value, prefix) {
return false
}
separatorIndex := strings.Index(value[len(prefix):], separator)
if separatorIndex <= 0 {
return false
}
payloadIndex := len(prefix) + separatorIndex + len(separator)
return payloadIndex < len(value)
}
func toolOutputImagePart(imageURL string) ChatContentPart {
return ChatContentPart{
Type: "image_url",
ImageURL: &ChatImageURL{URL: imageURL},
}
}
// appendAssistantToolCall merges a tool call into the chat message list.
// Parallel tool calls arrive as consecutive *_call items and must share one
// assistant message; the matching tool replies then follow it. Merge into the
// immediately preceding assistant message.
func appendAssistantToolCall(messages []ChatMessage, toolCall ChatToolCall, pendingReasoning string) []ChatMessage {
if n := len(messages); n > 0 && messages[n-1].Role == "assistant" {
messages[n-1].ToolCalls = append(messages[n-1].ToolCalls, toolCall)
if messages[n-1].ReasoningContent == "" {
messages[n-1].ReasoningContent = pendingReasoning
}
return messages
}
return append(messages, ChatMessage{
Role: "assistant",
ToolCalls: []ChatToolCall{toolCall},
ReasoningContent: pendingReasoning,
})
}
// normalizeChatMessages is the single place that enforces the tool-call
// invariant the DeepSeek / OpenAI Chat Completions schema requires: an assistant
// message with tool_calls must be immediately followed by one tool message per
// tool_call_id, in order, with nothing in between.
//
// Codex histories violate this in several ways that the builder alone can't fix:
// - a non-tool message lands between an assistant tool_calls message and its
// tool replies (e.g. an "Approved command prefix saved" system notice codex
// injects mid tool-execution);
// - a parallel tool_call's sibling output never arrives, or a call is left
// dangling by a mid-execution reconnect (unanswered tool_call);
// - a tool reply has no announcing assistant tool_call (orphan).
//
// It rebuilds the sequence so each assistant's answered tool_calls are followed
// directly by their replies (in call order); unanswered tool_calls are dropped
// (and an assistant left with neither tool_calls nor content is dropped); orphan
// tool replies and intervening messages are emitted in their natural position
// but never between an assistant tool_calls message and its replies.
func normalizeChatMessages(messages []ChatMessage) []ChatMessage {
return normalizeChatMessagesWithToolOutputMedia(messages, nil)
}
func normalizeChatMessagesWithToolOutputMedia(messages []ChatMessage, mediaByCallID toolOutputMediaByCallID) []ChatMessage {
// Index every tool reply by its tool_call_id (last wins on duplicates).
replies := make(map[string]ChatMessage)
for _, m := range messages {
if m.Role == "tool" && m.ToolCallID != "" {
replies[m.ToolCallID] = m
}
}
out := make([]ChatMessage, 0, len(messages))
for _, m := range messages {
switch {
case m.Role == "tool":
// A bare tool message with no tool_call_id is a direct Chat
// Completions passthrough; keep it in place. A tool reply whose id is
// announced by an assistant is emitted right after that assistant
// (skip the standalone occurrence). Any other tool reply is an orphan
// and is dropped.
if m.ToolCallID == "" {
out = append(out, m)
}
continue
case len(m.ToolCalls) > 0:
kept := make([]ChatToolCall, 0, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
if tc.ID == "" {
continue
}
if _, ok := replies[tc.ID]; ok {
kept = append(kept, tc)
}
}
if len(kept) == 0 {
// No answered tool_calls left: keep as a plain message if it has
// content, otherwise drop it entirely.
if isBlankChatContent(m.Content) {
continue
}
m.ToolCalls = nil
out = append(out, m)
continue
}
m.ToolCalls = kept
out = append(out, m)
for _, tc := range kept {
out = append(out, replies[tc.ID])
}
var mediaParts []ChatContentPart
for _, tc := range kept {
media := mediaByCallID[tc.ID]
if len(media) == 0 {
continue
}
mediaParts = append(mediaParts, ChatContentPart{
Type: "text",
Text: fmt.Sprintf(toolOutputMediaAttribution, tc.ID),
})
mediaParts = append(mediaParts, media...)
}
if len(mediaParts) > 0 {
content, _ := json.Marshal(mediaParts)
out = append(out, ChatMessage{Role: "user", Content: content})
}
default:
out = append(out, m)
}
}
return out
}
// isBlankChatContent reports whether a chat message content holds no usable text.
func isBlankChatContent(raw json.RawMessage) bool {
raw = bytesTrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" || string(raw) == `""` {
return true
}
return chatMessageContentText(raw) == ""
}
// extractResponsesReasoningText pulls the reasoning text out of a Responses
// reasoning item. The Chat→Responses bridge writes the upstream reasoning_content
// verbatim into the summary_text parts (see closeChatReasoningItem), so codex
// round-trips it there; prefer summary[].text and fall back to content.
func extractResponsesReasoningText(item map[string]json.RawMessage) string {
var parts []string
collect := func(raw json.RawMessage) {
raw = bytesTrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
return
}
var arr []map[string]json.RawMessage
if err := json.Unmarshal(raw, &arr); err == nil {
for _, p := range arr {
if t := rawString(p["text"]); t != "" {
parts = append(parts, t)
}
}
return
}
if t := rawString(raw); t != "" {
parts = append(parts, t)
}
}
collect(item["summary"])
if len(parts) == 0 {
collect(item["content"])
}
return strings.Join(parts, "\n")
}
// ExtractResponsesReasoningItem parses a raw Responses input item and, when it
// is a reasoning item, returns its id and extractable plaintext (summary
// preferred, content fallback). ok is false for non-reasoning items. It exists
// for the gateway-side reasoning cache: items with plaintext get (re)cached so
// later encrypted-only replicas of the same item id can be restored.
func ExtractResponsesReasoningItem(raw json.RawMessage) (id string, text string, ok bool) {
raw = bytesTrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
return "", "", false
}
var item map[string]json.RawMessage
if err := json.Unmarshal(raw, &item); err != nil {
return "", "", false
}
if rawString(item["type"]) != "reasoning" {
return "", "", false
}
return rawString(item["id"]), extractResponsesReasoningText(item), true
}
func chatCompletionsBridgeRole(role string) string {
trimmed := strings.TrimSpace(role)
if trimmed == "" {
return "user"
}
if strings.EqualFold(trimmed, "developer") {
return "system"
}
return role
}
func responsesContentToChatContent(raw json.RawMessage, role string) (json.RawMessage, error) {
raw = bytesTrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
empty, _ := json.Marshal("")
return empty, nil
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return raw, nil
}
var rawParts []json.RawMessage
if err := json.Unmarshal(raw, &rawParts); err == nil {
return responsesContentPartsToChatContent(rawParts, role)
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err == nil {
return chatContentFromSingleResponsesPart(rawString(obj["type"]), obj)
}
return raw, nil
}
func responsesContentPartsToChatContent(rawParts []json.RawMessage, role string) (json.RawMessage, error) {
var textParts []string
var chatParts []ChatContentPart
hasNonText := false
for _, rawPart := range rawParts {
var part map[string]json.RawMessage
if err := json.Unmarshal(rawPart, &part); err != nil {
continue
}
partType := rawString(part["type"])
switch partType {
case "input_text", "output_text", "text", "":
text := rawString(part["text"])
if text == "" {
continue
}
textParts = append(textParts, text)
chatParts = append(chatParts, ChatContentPart{Type: "text", Text: text})
case "input_image", "image_url":
imageURL := rawString(part["image_url"])
if imageURL == "" {
imageURL = rawNestedString(part["image_url"], "url")
}
if imageURL == "" {
continue
}
hasNonText = true
chatParts = append(chatParts, ChatContentPart{
Type: "image_url",
ImageURL: &ChatImageURL{URL: imageURL},
})
}
}
if !hasNonText {
joined, _ := json.Marshal(strings.Join(textParts, "\n\n"))
return joined, nil
}
if role != "user" {
joined, _ := json.Marshal(strings.Join(textParts, "\n\n"))
return joined, nil
}
if len(chatParts) == 0 {
empty, _ := json.Marshal("")
return empty, nil
}
return json.Marshal(chatParts)
}
func chatContentFromSingleResponsesPart(partType string, part map[string]json.RawMessage) (json.RawMessage, error) {
switch partType {
case "input_image", "image_url":
imageURL := rawString(part["image_url"])
if imageURL == "" {
imageURL = rawNestedString(part["image_url"], "url")
}
return json.Marshal([]ChatContentPart{{
Type: "image_url",
ImageURL: &ChatImageURL{URL: imageURL},
}})
default:
return json.Marshal(rawString(part["text"]))
}
}
// customToolInputSchema 是 custom/freeform 工具降级为 function 工具时的参数 schema。
// chat 协议无法表达 custom 工具的自由文本输入(及其 grammar 约束),退化为单一
// input 字符串参数;回程时再从 arguments 的 input 字段还原(见
// extractCustomToolCallInput)。
const customToolInputSchema = `{"type":"object","properties":{"input":{"type":"string","description":"The raw input for this tool, passed through verbatim."}},"required":["input"]}`
func responsesToolsToChatTools(tools []ResponsesTool) ([]ChatTool, error) {
// 顶层 function/custom 工具名集合:namespace 子工具摊平后与其撞名时,chat
// 上游无法按 namespace 区分调用归属。这类请求在原生 Responses 上游是合法的
// (按 namespace+name 路由),歧义由摊平转换制造且无法消除,必须显式拒绝,
// 不能静默降级(重复声明发给上游、回程还原到错误工具)。
topLevel := make(map[string]bool)
for _, tool := range tools {
if (tool.Type == "function" || tool.Type == "custom") && tool.Name != "" {
topLevel[tool.Name] = true
}
}
flatOwner := make(map[string]NamespacedToolName)
toolSearchDeclared := false
out := make([]ChatTool, 0, len(tools))
for _, tool := range tools {
switch tool.Type {
case "function":
out = append(out, ChatTool{
Type: "function",
Function: &ChatFunction{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters,
Strict: tool.Strict,
},
})
case "custom":
// codex 0.14x 的核心执行工具 exec 即为 custom 类型;丢弃它会让模型
// 无法执行任何命令,必须降级为 function 工具透传。
out = append(out, ChatTool{
Type: "function",
Function: &ChatFunction{
Name: tool.Name,
Description: tool.Description,
Parameters: json.RawMessage(customToolInputSchema),
},
})
case "tool_search":
// 代理不能改名(codex 的模型侧按 tool_search 这个名字调用),与客户端
// 声明的同名工具无法区分——回程会把普通工具的调用劫持成 tool_search_call
// 必须显式拒绝;重复声明 type=tool_search 去重即可。
if topLevel[toolSearchProxyName] {
return nil, fmt.Errorf("built-in tool_search conflicts with a declared tool named %q; this upstream cannot disambiguate them, rename the tool", toolSearchProxyName)
}
if toolSearchDeclared {
continue
}
toolSearchDeclared = true
out = append(out, toolSearchProxyChatTool())
case "namespace":
flattened, err := namespaceChildrenToChatTools(tool, topLevel, flatOwner)
if err != nil {
return nil, err
}
out = append(out, flattened...)
case "x_search":
out = append(out, ChatTool{
Type: "x_search",
AllowedXHandles: tool.AllowedXHandles,
ExcludedXHandles: tool.ExcludedXHandles,
FromDate: tool.FromDate,
ToDate: tool.ToDate,
EnableImageUnderstanding: tool.EnableImageUnderstanding,
EnableVideoUnderstanding: tool.EnableVideoUnderstanding,
})
}
// 其余类型(web_search、image_generation 等服务端工具)在 chat 上游没有
// 对应能力,维持丢弃。
}
return out, nil
}
// toolSearchProxyName 是 tool_search 服务端工具降级后的 function 工具名。模型对
// 它的调用以同名 function_call 原样回传,由 codex 端路由。
const toolSearchProxyName = "tool_search"
const toolSearchProxySchema = `{"type":"object","properties":{"query":{"type":"string","description":"Search query for tools or connectors to load."},"limit":{"type":"integer","description":"Maximum number of tool groups to return."}},"required":["query"]}`
func toolSearchProxyChatTool() ChatTool {
return ChatTool{
Type: "function",
Function: &ChatFunction{
Name: toolSearchProxyName,
Description: "Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task.",
Parameters: json.RawMessage(toolSearchProxySchema),
},
}
}
// namespaceChildrenToChatTools 将 namespace 工具的子 function 工具摊平为顶层
// function 工具,名字加 "<namespace>__" 前缀。摊平名与顶层工具或其他 namespace
// 撞名时返回错误(歧义不可消除,显式拒绝);同一 (namespace, 子工具) 的重复声明
// 去重后不算冲突。
func namespaceChildrenToChatTools(tool ResponsesTool, topLevel map[string]bool, flatOwner map[string]NamespacedToolName) ([]ChatTool, error) {
if tool.Name == "" {
return nil, nil
}
children := tool.Tools
if len(children) == 0 {
children = tool.Children
}
var out []ChatTool
for _, child := range children {
if child.Type != "function" || child.Name == "" {
continue
}
flat := flattenNamespaceToolName(tool.Name, child.Name)
entry := NamespacedToolName{Namespace: tool.Name, Name: child.Name}
if topLevel[flat] {
return nil, 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", tool.Name, child.Name, flat)
}
if prev, ok := flatOwner[flat]; ok {
if prev == entry {
continue
}
return nil, 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, tool.Name, child.Name, flat)
}
flatOwner[flat] = entry
out = append(out, ChatTool{
Type: "function",
Function: &ChatFunction{
Name: flat,
Description: child.Description,
Parameters: child.Parameters,
Strict: child.Strict,
},
})
}
return out, nil
}
// chatToolNameMaxLen 是 Chat Completions function 工具名的通用长度上限。
const chatToolNameMaxLen = 64
// flattenNamespaceToolName 生成 namespace 子工具的摊平名;超长时截断并追加
// sha256 短哈希保证唯一性。
func flattenNamespaceToolName(namespace, name string) string {
full := namespace + "__" + name
if len(full) <= chatToolNameMaxLen {
return full
}
sum := sha256.Sum256([]byte(full))
suffix := "__" + hex.EncodeToString(sum[:4])
prefixLen := chatToolNameMaxLen - len(suffix)
var prefix strings.Builder
for _, ch := range full {
if prefix.Len()+len(string(ch)) > prefixLen {
break
}
_, _ = prefix.WriteRune(ch)
}
return prefix.String() + suffix
}
// responsesToolChoiceToChatToolChoice 把 Responses 的 tool_choice 转为 chat 形态。
// declared 是转换后实际声明的 chat 工具名集合:具名选择项仅在目标工具幸存时转发,
// 服务端工具(web_search 等)的选择项随工具本身丢弃——指向未声明工具的 tool_choice
// 会被 chat 上游 400 拒绝。返回 nil 表示丢弃 tool_choice。
func responsesToolChoiceToChatToolChoice(raw json.RawMessage, declared map[string]bool) json.RawMessage {
var choice map[string]json.RawMessage
if err := json.Unmarshal(raw, &choice); err != nil {
// "auto"/"none"/"required" 等字符串形式原样转发。
return raw
}
var name string
switch rawString(choice["type"]) {
case "x_search":
if !declared["x_search"] {
return nil
}
out, err := json.Marshal(map[string]any{"type": "x_search"})
if err != nil {
return raw
}
return out
case "tool_search":
// tool_search 未被丢弃而是降级为同名 function 代理(见
// responsesToolsToChatTools),强制选择它同样降级为 function 选择,
// 静默丢弃会把强制搜索退化为自动选择。
name = toolSearchProxyName
case "function", "custom":
// custom 工具已降级为 function 工具,指向它的 tool_choice 同样按 function 转换。
name = rawString(choice["name"])
if name == "" {
name = rawNestedString(choice["function"], "name")
}
if name == "" {
return raw
}
default:
return nil
}
if !declared[name] {
return nil
}
out, err := json.Marshal(map[string]any{
"type": "function",
"function": map[string]string{
"name": name,
},
})
if err != nil {
return raw
}
return out
}
// extractCustomToolCallInput 从降级 function 调用的 arguments 中还原 custom 工具的
// 自由文本输入:优先取 {"input": "..."} 的 input 字段;模型未按 schema 输出时原样
// 回传,交由客户端校验、模型重试。
func extractCustomToolCallInput(arguments string) string {
trimmed := strings.TrimSpace(arguments)
if trimmed == "" {
return ""
}
var obj map[string]json.RawMessage
if err := json.Unmarshal([]byte(trimmed), &obj); err != nil {
return trimmed
}
if raw, ok := obj["input"]; ok {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
return trimmed
}
if len(obj) == 0 {
return ""
}
return trimmed
}
// ChatCompletionsResponseToResponses converts a non-streaming Chat Completions
// response into a Responses API response. customTools 是客户端请求中 custom 工具
// 的名字集合(见 CustomToolNames),命中的调用会还原为 custom_tool_call 项;
// toolSearch 表示客户端声明了 tool_search 工具(见 HasToolSearchTool),代理工具
// 的调用会还原为 tool_search_call 项;namespaceTools 是 namespace 子工具的摊平名
// 映射(见 NamespaceToolNames),命中的调用还原为带 namespace 字段的 function_call 项。
func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) *ResponsesResponse {
id := ""
if resp != nil {
id = resp.ID
}
if id == "" {
id = generateResponsesID()
}
out := &ResponsesResponse{
ID: id,
Object: "response",
Model: model,
Status: "completed",
}
if resp == nil {
out.Output = []ResponsesOutput{emptyResponsesMessageOutput()}
return out
}
if out.Model == "" {
out.Model = resp.Model
}
if len(resp.Choices) > 0 {
choice := resp.Choices[0]
out.Output = chatMessageToResponsesOutput(choice.Message, customTools, toolSearch, namespaceTools)
if choice.FinishReason == "length" {
out.Status = "incomplete"
out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"}
}
}
if len(out.Output) == 0 {
out.Output = []ResponsesOutput{emptyResponsesMessageOutput()}
}
if resp.Usage != nil {
out.Usage = ChatUsageToResponsesUsage(resp.Usage)
}
return out
}
func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput {
var outputs []ResponsesOutput
reasoning := message.reasoningText()
if reasoning != "" {
outputs = append(outputs, ResponsesOutput{
Type: "reasoning",
ID: generateItemID(),
Summary: []ResponsesSummary{{
Type: "summary_text",
Text: reasoning,
}},
})
}
text := chatMessageContentText(message.Content)
if text == "" && strings.TrimSpace(reasoning) != "" && len(message.ToolCalls) == 0 {
text = reasoning
}
if text != "" || len(message.ToolCalls) == 0 {
outputs = append(outputs, ResponsesOutput{
Type: "message",
ID: generateItemID(),
Role: "assistant",
Content: []ResponsesContentPart{{
Type: "output_text",
Text: text,
}},
Status: "completed",
})
}
for _, toolCall := range message.ToolCalls {
arguments := toolCall.Function.Arguments
if strings.TrimSpace(arguments) == "" {
arguments = "{}"
}
if customTools[toolCall.Function.Name] {
outputs = append(outputs, ResponsesOutput{
Type: "custom_tool_call",
ID: generateItemID(),
CallID: toolCall.ID,
Name: toolCall.Function.Name,
Input: extractCustomToolCallInput(arguments),
Status: "completed",
})
continue
}
if toolSearch && toolCall.Function.Name == toolSearchProxyName {
outputs = append(outputs, ResponsesOutput{
Type: "tool_search_call",
ID: generateItemID(),
CallID: toolCall.ID,
Arguments: arguments,
Status: "completed",
})
continue
}
if ns, ok := namespaceTools[toolCall.Function.Name]; ok {
outputs = append(outputs, ResponsesOutput{
Type: "function_call",
ID: generateItemID(),
CallID: toolCall.ID,
Name: ns.Name,
Namespace: ns.Namespace,
Arguments: arguments,
Status: "completed",
})
continue
}
outputs = append(outputs, ResponsesOutput{
Type: "function_call",
ID: generateItemID(),
CallID: toolCall.ID,
Name: toolCall.Function.Name,
Arguments: arguments,
Status: "completed",
})
}
return outputs
}
// toolSearchCallArgumentsJSON 把降级 function 调用累积的 arguments 字符串还原为
// tool_search_call 线上要求的 JSON 对象;模型未按 schema 输出(非法 JSON)时按
// 字符串值兜底,交由 codex 解析报错后让模型重试。
func toolSearchCallArgumentsJSON(arguments string) json.RawMessage {
trimmed := strings.TrimSpace(arguments)
if trimmed == "" {
return json.RawMessage(`{}`)
}
if json.Valid([]byte(trimmed)) {
return json.RawMessage(trimmed)
}
fallback, _ := json.Marshal(arguments)
return fallback
}
func emptyResponsesMessageOutput() ResponsesOutput {
return ResponsesOutput{
Type: "message",
ID: generateItemID(),
Role: "assistant",
Content: []ResponsesContentPart{{Type: "output_text", Text: ""}},
Status: "completed",
}
}
func chatMessageContentText(raw json.RawMessage) string {
raw = bytesTrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return text
}
var parts []ChatContentPart
if err := json.Unmarshal(raw, &parts); err == nil {
var texts []string
for _, part := range parts {
if part.Type == "text" && part.Text != "" {
texts = append(texts, part.Text)
}
}
return strings.Join(texts, "\n\n")
}
return ""
}
// ChatUsageToResponsesUsage converts Chat Completions token usage to Responses
// usage shape.
func ChatUsageToResponsesUsage(usage *ChatUsage) *ResponsesUsage {
if usage == nil {
return nil
}
out := &ResponsesUsage{
InputTokens: usage.PromptTokens,
OutputTokens: usage.CompletionTokens,
TotalTokens: usage.TotalTokens,
}
if out.TotalTokens == 0 {
out.TotalTokens = out.InputTokens + out.OutputTokens
}
if usage.PromptTokensDetails != nil && (usage.PromptTokensDetails.CachedTokens > 0 ||
usage.PromptTokensDetails.CacheCreationTokens > 0 || usage.PromptTokensDetails.CacheWriteTokens > 0) {
out.InputTokensDetails = &ResponsesInputTokensDetails{
CachedTokens: usage.PromptTokensDetails.CachedTokens,
CacheCreationTokens: usage.PromptTokensDetails.CacheCreationTokens,
CacheWriteTokens: usage.PromptTokensDetails.CacheWriteTokens,
}
if usage.PromptTokensDetails.CacheWriteTokens > 0 {
out.CacheCreationInputTokens = usage.PromptTokensDetails.CacheWriteTokens
} else {
out.CacheCreationInputTokens = usage.PromptTokensDetails.CacheCreationTokens
}
}
return out
}
// ChatCompletionsToResponsesStreamState tracks state while converting Chat
// Completions SSE chunks into Responses SSE events.
type ChatCompletionsToResponsesStreamState struct {
ResponseID string
Model string
Created int64
SequenceNumber int
CreatedSent bool
CompletedSent bool
// nextOutputIndex assigns sequential output_index values to items as they
// are opened (reasoning, message, tool calls), so the streamed indices match
// the order of items in the final response.output array.
nextOutputIndex int
// Reasoning item lifecycle. DeepSeek-style upstreams stream all
// reasoning_content before any content, so reasoning is modeled as its own
// "reasoning" output item that must be opened (output_item.added) before any
// reasoning delta and closed before the message/tool items open.
ReasoningItemID string
ReasoningIndex int
ReasoningOpen bool
ReasoningDone bool
// Message item + output_text content-part lifecycle.
MessageItemID string
MessageIndex int
TextPartOpen bool
Text strings.Builder
Reasoning strings.Builder
// Tool-call lifecycle, keyed by the upstream tool_call index.
ToolCalls map[int]*ChatToolCall
ToolItemIDs map[int]string
ToolOutputIndex map[int]int
// CustomTools 是客户端请求中 custom/freeform 工具的名字集合(见
// CustomToolNames)。命中的调用按 custom_tool_call 生命周期下发,codex 才能
// 路由回它注册的 custom 工具。
CustomTools map[string]bool
// ToolSearchDeclared 表示客户端请求声明了 tool_search 工具(见
// HasToolSearchTool)。命中的代理调用按 tool_search_call 项还原,codex 只按
// 该项类型(且 execution=client)执行 tool search。
ToolSearchDeclared bool
// NamespaceTools 是 namespace 子工具的摊平名 → 原始归属映射(见
// NamespaceToolNames)。命中的调用还原为带 namespace 字段的 function_call 项,
// codex 按 namespace+name 路由。
NamespaceTools map[string]NamespacedToolName
// toolIsCustom 记录每个工具调用宣告时的类型判定,保证 added/done 事件的
// 项类型一致。
toolIsCustom map[int]bool
// toolIsToolSearch 记录工具调用是否判定为 tool_search 代理调用。
toolIsToolSearch map[int]bool
// toolNamespace 记录工具调用宣告时命中的 namespace 归属(见 NamespaceTools)。
toolNamespace map[int]NamespacedToolName
// toolAnnounced 记录 output_item.added 是否已发出。存在 custom 工具且名字
// 尚未到达时延迟宣告,待名字可判定类型后再补发(见 announceChatToolItem)。
toolAnnounced map[int]bool
FinishReason string
Usage *ResponsesUsage
}
// NewChatCompletionsToResponsesStreamState returns an initialized stream state.
func NewChatCompletionsToResponsesStreamState(model string) *ChatCompletionsToResponsesStreamState {
return &ChatCompletionsToResponsesStreamState{
ResponseID: generateResponsesID(),
Model: model,
Created: time.Now().Unix(),
ToolCalls: make(map[int]*ChatToolCall),
ToolItemIDs: make(map[int]string),
ToolOutputIndex: make(map[int]int),
toolIsCustom: make(map[int]bool),
toolIsToolSearch: make(map[int]bool),
toolNamespace: make(map[int]NamespacedToolName),
toolAnnounced: make(map[int]bool),
}
}
func (state *ChatCompletionsToResponsesStreamState) allocOutputIndex() int {
idx := state.nextOutputIndex
state.nextOutputIndex++
return idx
}
// ChatCompletionsChunkToResponsesEvents converts one Chat Completions stream
// chunk into zero or more Responses stream events.
func ChatCompletionsChunkToResponsesEvents(
chunk *ChatCompletionsChunk,
state *ChatCompletionsToResponsesStreamState,
) []ResponsesStreamEvent {
if chunk == nil || state == nil {
return nil
}
if chunk.ID != "" {
state.ResponseID = chunk.ID
}
if state.Model == "" && chunk.Model != "" {
state.Model = chunk.Model
}
if chunk.Usage != nil {
state.Usage = ChatUsageToResponsesUsage(chunk.Usage)
}
var events []ResponsesStreamEvent
events = append(events, ensureChatToResponsesCreated(state)...)
for _, choice := range chunk.Choices {
// Reasoning is emitted as its own output item and must be opened
// (output_item.added + reasoning_summary_part.added) before the first
// delta, otherwise a strict client discards the delta. The leading
// empty-string reasoning delta upstreams send is filtered out.
reasoning := choice.Delta.reasoningText()
if reasoning != nil && *reasoning != "" {
events = append(events, ensureChatReasoningItem(state)...)
_, _ = state.Reasoning.WriteString(*reasoning)
events = append(events, chatToResponsesEvent(state, "response.reasoning_summary_text.delta", &ResponsesStreamEvent{
OutputIndex: state.ReasoningIndex,
SummaryIndex: 0,
Delta: *reasoning,
ItemID: state.ReasoningItemID,
}))
}
if choice.Delta.Content != nil && *choice.Delta.Content != "" {
// First real content closes the reasoning item, then opens the
// message item and its output_text content part.
events = append(events, closeChatReasoningItem(state)...)
events = append(events, ensureChatToResponsesMessageItem(state)...)
events = append(events, ensureChatToResponsesTextPart(state)...)
_, _ = state.Text.WriteString(*choice.Delta.Content)
events = append(events, chatToResponsesEvent(state, "response.output_text.delta", &ResponsesStreamEvent{
OutputIndex: state.MessageIndex,
ContentIndex: 0,
Delta: *choice.Delta.Content,
ItemID: state.MessageItemID,
}))
}
for _, toolCall := range choice.Delta.ToolCalls {
idx := 0
if toolCall.Index != nil {
idx = *toolCall.Index
}
stored, ok := state.ToolCalls[idx]
if !ok {
// A tool call closes any open reasoning item first.
events = append(events, closeChatReasoningItem(state)...)
copyCall := toolCall
if copyCall.ID == "" {
copyCall.ID = generateItemID()
}
copyCall.Type = "function"
// Arguments are accumulated by the shared block below so the
// emitted delta and the stored value stay in sync. Some upstreams
// (e.g. GLM/Zhipu) pack id+name+arguments into the first tool_call
// chunk; without this reset the first chunk's arguments would be
// counted twice (once from this copy, once from the += below),
// producing a doubled, invalid JSON like {"a":1}{"a":1}.
copyCall.Function.Arguments = ""
state.ToolCalls[idx] = &copyCall
stored = &copyCall
state.ToolItemIDs[idx] = generateItemID()
state.ToolOutputIndex[idx] = state.allocOutputIndex()
} else {
if toolCall.ID != "" {
stored.ID = toolCall.ID
}
if toolCall.Function.Name != "" {
stored.Function.Name = toolCall.Function.Name
}
}
events = append(events, announceChatToolItem(state, idx, stored, false)...)
if toolCall.Function.Arguments != "" {
stored.Function.Arguments += toolCall.Function.Arguments
// 未宣告(名字未到)时仅累积,宣告时统一补发;custom 调用的
// arguments 是包裹 input 的 JSON 片段,无法增量还原为自由文本
// 输入,缓冲整份 arguments 收尾时一次性下发(见 closeChatToolItems);
// tool_search 调用同样收尾时随 output_item.done 全量下发。
if state.toolAnnounced[idx] && !state.toolIsCustom[idx] && !state.toolIsToolSearch[idx] {
events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
OutputIndex: state.ToolOutputIndex[idx],
ItemID: state.ToolItemIDs[idx],
Delta: toolCall.Function.Arguments,
CallID: stored.ID,
Name: stored.Function.Name,
}))
}
}
}
if choice.FinishReason != nil && *choice.FinishReason != "" {
state.FinishReason = *choice.FinishReason
}
}
return events
}
// FinalizeChatCompletionsResponsesStream emits terminal Responses events.
func FinalizeChatCompletionsResponsesStream(state *ChatCompletionsToResponsesStreamState) []ResponsesStreamEvent {
if state == nil || state.CompletedSent {
return nil
}
var events []ResponsesStreamEvent
events = append(events, ensureChatToResponsesCreated(state)...)
// Close a reasoning item that never transitioned to content (reasoning-only
// or empty completion).
events = append(events, closeChatReasoningItem(state)...)
events = append(events, synthesizeChatReasoningFallbackMessage(state)...)
if state.MessageItemID != "" {
if state.TextPartOpen {
events = append(events, chatToResponsesEvent(state, "response.output_text.done", &ResponsesStreamEvent{
OutputIndex: state.MessageIndex,
ContentIndex: 0,
Text: state.Text.String(),
ItemID: state.MessageItemID,
}))
events = append(events, chatToResponsesEvent(state, "response.content_part.done", &ResponsesStreamEvent{
OutputIndex: state.MessageIndex,
ContentIndex: 0,
ItemID: state.MessageItemID,
Part: &ResponsesContentPart{Type: "output_text", Text: state.Text.String()},
}))
}
events = append(events, chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{
OutputIndex: state.MessageIndex,
Item: &ResponsesOutput{
Type: "message",
ID: state.MessageItemID,
Role: "assistant",
Content: []ResponsesContentPart{{Type: "output_text", Text: state.Text.String()}},
Status: "completed",
},
}))
}
// Close every function_call item opened during the stream. Codex finalizes a
// tool call only after function_call_arguments.done + output_item.done for
// that item; without them the call never completes and the session wedges.
// Mirrors cc-switch's finalize_tools.
events = append(events, closeChatToolItems(state)...)
status := "completed"
var incompleteDetails *ResponsesIncompleteDetails
if state.FinishReason == "length" {
status = "incomplete"
incompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"}
}
state.CompletedSent = true
events = append(events, chatToResponsesEvent(state, "response.completed", &ResponsesStreamEvent{
Response: &ResponsesResponse{
ID: state.ResponseID,
Object: "response",
Model: state.Model,
Status: status,
Output: state.chatOutput(),
Usage: state.Usage,
IncompleteDetails: incompleteDetails,
},
}))
return events
}
func ensureChatToResponsesCreated(state *ChatCompletionsToResponsesStreamState) []ResponsesStreamEvent {
if state.CreatedSent {
return nil
}
state.CreatedSent = true
return []ResponsesStreamEvent{chatToResponsesEvent(state, "response.created", &ResponsesStreamEvent{
Response: &ResponsesResponse{
ID: state.ResponseID,
Object: "response",
Model: state.Model,
Status: "in_progress",
Output: []ResponsesOutput{},
},
})}
}
// ensureChatReasoningItem opens the reasoning output item (output_item.added +
// reasoning_summary_part.added) before the first reasoning delta. Codex renders
// streaming reasoning only when this summary-part lifecycle is present.
func ensureChatReasoningItem(state *ChatCompletionsToResponsesStreamState) []ResponsesStreamEvent {
if state.ReasoningOpen || state.ReasoningDone {
return nil
}
state.ReasoningOpen = true
state.ReasoningItemID = generateItemID()
state.ReasoningIndex = state.allocOutputIndex()
return []ResponsesStreamEvent{
chatToResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
OutputIndex: state.ReasoningIndex,
Item: &ResponsesOutput{Type: "reasoning", ID: state.ReasoningItemID, Status: "in_progress"},
}),
chatToResponsesEvent(state, "response.reasoning_summary_part.added", &ResponsesStreamEvent{
OutputIndex: state.ReasoningIndex,
SummaryIndex: 0,
ItemID: state.ReasoningItemID,
Part: &ResponsesContentPart{Type: "summary_text"},
}),
}
}
// closeChatReasoningItem emits the reasoning item's terminal events
// (reasoning_summary_text.done + reasoning_summary_part.done + output_item.done).
func closeChatReasoningItem(state *ChatCompletionsToResponsesStreamState) []ResponsesStreamEvent {
if !state.ReasoningOpen {
return nil
}
state.ReasoningOpen = false
state.ReasoningDone = true
reasoning := state.Reasoning.String()
return []ResponsesStreamEvent{
chatToResponsesEvent(state, "response.reasoning_summary_text.done", &ResponsesStreamEvent{
OutputIndex: state.ReasoningIndex,
SummaryIndex: 0,
Text: reasoning,
ItemID: state.ReasoningItemID,
}),
chatToResponsesEvent(state, "response.reasoning_summary_part.done", &ResponsesStreamEvent{
OutputIndex: state.ReasoningIndex,
SummaryIndex: 0,
ItemID: state.ReasoningItemID,
Part: &ResponsesContentPart{Type: "summary_text", Text: reasoning},
}),
chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{
OutputIndex: state.ReasoningIndex,
Item: &ResponsesOutput{
Type: "reasoning",
ID: state.ReasoningItemID,
Status: "completed",
Summary: []ResponsesSummary{{Type: "summary_text", Text: reasoning}},
},
}),
}
}
func synthesizeChatReasoningFallbackMessage(state *ChatCompletionsToResponsesStreamState) []ResponsesStreamEvent {
if state == nil ||
state.MessageItemID != "" ||
state.Text.Len() > 0 ||
state.Reasoning.Len() == 0 ||
len(state.ToolCalls) > 0 {
return nil
}
text := state.Reasoning.String()
if strings.TrimSpace(text) == "" {
return nil
}
var events []ResponsesStreamEvent
events = append(events, ensureChatToResponsesMessageItem(state)...)
events = append(events, ensureChatToResponsesTextPart(state)...)
_, _ = state.Text.WriteString(text)
events = append(events, chatToResponsesEvent(state, "response.output_text.delta", &ResponsesStreamEvent{
OutputIndex: state.MessageIndex,
ContentIndex: 0,
Delta: text,
ItemID: state.MessageItemID,
}))
return events
}
func ensureChatToResponsesMessageItem(state *ChatCompletionsToResponsesStreamState) []ResponsesStreamEvent {
if state.MessageItemID != "" {
return nil
}
state.MessageItemID = generateItemID()
state.MessageIndex = state.allocOutputIndex()
return []ResponsesStreamEvent{chatToResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
OutputIndex: state.MessageIndex,
Item: &ResponsesOutput{
Type: "message",
ID: state.MessageItemID,
Role: "assistant",
Status: "in_progress",
Content: []ResponsesContentPart{{Type: "output_text"}},
},
})}
}
func ensureChatToResponsesTextPart(state *ChatCompletionsToResponsesStreamState) []ResponsesStreamEvent {
if state.TextPartOpen {
return nil
}
state.TextPartOpen = true
return []ResponsesStreamEvent{chatToResponsesEvent(state, "response.content_part.added", &ResponsesStreamEvent{
OutputIndex: state.MessageIndex,
ContentIndex: 0,
ItemID: state.MessageItemID,
Part: &ResponsesContentPart{Type: "output_text", Text: ""},
})}
}
// announceChatToolItem 在类型可判定时发出工具调用的 output_item.added。custom
// 工具的判定依赖名字:名字未到且请求里存在 custom 工具时延迟宣告,避免 added/done
// 的项类型不一致;force 用于流收尾,名字始终未到时按 function_call 兜底。
func announceChatToolItem(
state *ChatCompletionsToResponsesStreamState,
idx int,
stored *ChatToolCall,
force bool,
) []ResponsesStreamEvent {
if state.toolAnnounced[idx] {
return nil
}
if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || state.ToolSearchDeclared || len(state.NamespaceTools) > 0) {
return nil
}
state.toolAnnounced[idx] = true
isCustom := state.CustomTools[stored.Function.Name]
isToolSearch := !isCustom && state.ToolSearchDeclared && stored.Function.Name == toolSearchProxyName
state.toolIsCustom[idx] = isCustom
state.toolIsToolSearch[idx] = isToolSearch
itemType := "function_call"
if isCustom {
itemType = "custom_tool_call"
}
if isToolSearch {
itemType = "tool_search_call"
}
// namespace 子工具的调用仍按 function_call 生命周期下发,但 added/done 项要
// 还原为裸子工具名 + namespace 字段(codex 按 namespace+name 路由)。
itemName, itemNamespace := stored.Function.Name, ""
if ns, ok := state.NamespaceTools[stored.Function.Name]; ok && !isCustom && !isToolSearch {
state.toolNamespace[idx] = ns
itemName, itemNamespace = ns.Name, ns.Namespace
}
events := []ResponsesStreamEvent{chatToResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
OutputIndex: state.ToolOutputIndex[idx],
Item: &ResponsesOutput{
Type: itemType,
ID: state.ToolItemIDs[idx],
CallID: stored.ID,
Name: itemName,
Namespace: itemNamespace,
Status: "in_progress",
},
})}
// 迟到宣告时补发已累积的参数增量(custom/tool_search 的输入收尾统一下发,不补发)。
if !isCustom && !isToolSearch && stored.Function.Arguments != "" {
events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
OutputIndex: state.ToolOutputIndex[idx],
ItemID: state.ToolItemIDs[idx],
Delta: stored.Function.Arguments,
CallID: stored.ID,
Name: stored.Function.Name,
}))
}
return events
}
// closeChatToolItems emits function_call_arguments.done + output_item.done for
// every tool call opened during the stream, carrying the full call_id/name/
// arguments so codex can deserialize and execute the call. Mirrors cc-switch's
// finalize_tools.
func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []ResponsesStreamEvent {
if len(state.ToolCalls) == 0 {
return nil
}
var events []ResponsesStreamEvent
for i := 0; i < len(state.ToolCalls); i++ {
toolCall, ok := state.ToolCalls[i]
if !ok || toolCall == nil {
continue
}
itemID, opened := state.ToolItemIDs[i]
if !opened {
continue
}
// 名字始终未到导致尚未宣告的调用,收尾前按最终名字兜底宣告。
events = append(events, announceChatToolItem(state, i, toolCall, true)...)
arguments := toolCall.Function.Arguments
if strings.TrimSpace(arguments) == "" {
arguments = "{}"
}
outputIndex := state.ToolOutputIndex[i]
if state.toolIsCustom[i] {
// custom 调用按 custom_tool_call 生命周期收尾:input 在此处一次性下发
// (流中不产出增量,见 ChatCompletionsChunkToResponsesEvents)。
input := extractCustomToolCallInput(arguments)
if input != "" {
events = append(events, chatToResponsesEvent(state, "response.custom_tool_call_input.delta", &ResponsesStreamEvent{
OutputIndex: outputIndex,
ItemID: itemID,
Delta: input,
}))
}
events = append(events,
chatToResponsesEvent(state, "response.custom_tool_call_input.done", &ResponsesStreamEvent{
OutputIndex: outputIndex,
ItemID: itemID,
CallID: toolCall.ID,
Name: toolCall.Function.Name,
Input: input,
}),
chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{
OutputIndex: outputIndex,
Item: &ResponsesOutput{
Type: "custom_tool_call",
ID: itemID,
CallID: toolCall.ID,
Name: toolCall.Function.Name,
Input: input,
Status: "completed",
},
}),
)
continue
}
if state.toolIsToolSearch[i] {
// tool_search 调用按 tool_search_call 项收尾:codex 从 output_item.done
// 物化该调用(无参数增量事件),arguments 全量随项下发。
events = append(events, chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{
OutputIndex: outputIndex,
Item: &ResponsesOutput{
Type: "tool_search_call",
ID: itemID,
CallID: toolCall.ID,
Arguments: arguments,
Status: "completed",
},
}))
continue
}
// namespace 子工具调用在宣告时已记录归属,收尾项同样带还原名与 namespace。
name, namespace := toolCall.Function.Name, ""
if ns, ok := state.toolNamespace[i]; ok {
name, namespace = ns.Name, ns.Namespace
}
events = append(events,
chatToResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{
OutputIndex: outputIndex,
ItemID: itemID,
CallID: toolCall.ID,
Name: name,
Arguments: arguments,
}),
chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{
OutputIndex: outputIndex,
Item: &ResponsesOutput{
Type: "function_call",
ID: itemID,
CallID: toolCall.ID,
Name: name,
Namespace: namespace,
Arguments: arguments,
Status: "completed",
},
}),
)
}
return events
}
func (state *ChatCompletionsToResponsesStreamState) chatOutput() []ResponsesOutput {
var outputs []ResponsesOutput
if state.Reasoning.Len() > 0 {
outputs = append(outputs, ResponsesOutput{
Type: "reasoning",
ID: generateItemID(),
Summary: []ResponsesSummary{{
Type: "summary_text",
Text: state.Reasoning.String(),
}},
})
}
if state.MessageItemID != "" || len(state.ToolCalls) == 0 {
outputs = append(outputs, ResponsesOutput{
Type: "message",
ID: nonEmpty(state.MessageItemID, generateItemID()),
Role: "assistant",
Content: []ResponsesContentPart{{
Type: "output_text",
Text: state.Text.String(),
}},
Status: "completed",
})
}
for i := 0; i < len(state.ToolCalls); i++ {
toolCall, ok := state.ToolCalls[i]
if !ok || toolCall == nil {
continue
}
arguments := toolCall.Function.Arguments
if strings.TrimSpace(arguments) == "" {
arguments = "{}"
}
if state.toolIsCustom[i] {
outputs = append(outputs, ResponsesOutput{
Type: "custom_tool_call",
ID: generateItemID(),
CallID: toolCall.ID,
Name: toolCall.Function.Name,
Input: extractCustomToolCallInput(arguments),
Status: "completed",
})
continue
}
if state.toolIsToolSearch[i] {
outputs = append(outputs, ResponsesOutput{
Type: "tool_search_call",
ID: generateItemID(),
CallID: toolCall.ID,
Arguments: arguments,
Status: "completed",
})
continue
}
name, namespace := toolCall.Function.Name, ""
if ns, ok := state.toolNamespace[i]; ok {
name, namespace = ns.Name, ns.Namespace
}
outputs = append(outputs, ResponsesOutput{
Type: "function_call",
ID: generateItemID(),
CallID: toolCall.ID,
Name: name,
Namespace: namespace,
Arguments: arguments,
Status: "completed",
})
}
return outputs
}
func chatToResponsesEvent(
state *ChatCompletionsToResponsesStreamState,
eventType string,
template *ResponsesStreamEvent,
) ResponsesStreamEvent {
seq := state.SequenceNumber
state.SequenceNumber++
evt := *template
evt.Type = eventType
evt.SequenceNumber = seq
return evt
}
func rawString(raw json.RawMessage) string {
raw = bytesTrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
return ""
}
func rawNestedString(raw json.RawMessage, key string) string {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return ""
}
return rawString(obj[key])
}
func bytesTrimSpace(raw json.RawMessage) json.RawMessage {
return json.RawMessage(strings.TrimSpace(string(raw)))
}
func nonEmpty(value, fallback string) string {
if value != "" {
return value
}
return fallback
}