mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3989 from xlplbo/fix/codex-mcp-tools-bridge
fix(apicompat): 补齐 Codex 0.14x 工具链经 chat 回退桥的转换(custom/tool_search/namespace)
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -34,10 +36,25 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR
|
||||
out.ReasoningEffort = req.Reasoning.Effort
|
||||
}
|
||||
if len(req.Tools) > 0 {
|
||||
out.Tools = responsesToolsToChatTools(req.Tools)
|
||||
tools, err := responsesToolsToChatTools(req.Tools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Tools = tools
|
||||
}
|
||||
if len(req.ToolChoice) > 0 {
|
||||
out.ToolChoice = responsesToolChoiceToChatToolChoice(req.ToolChoice)
|
||||
// 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 tc := responsesToolChoiceToChatToolChoice(req.ToolChoice, declared); len(tc) > 0 {
|
||||
out.ToolChoice = tc
|
||||
}
|
||||
}
|
||||
if req.Text != nil {
|
||||
out.ResponseFormat = responsesTextFormatToChatResponseFormat(req.Text.Format)
|
||||
@@ -46,6 +63,72 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR
|
||||
return out, 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:
|
||||
//
|
||||
@@ -133,33 +216,68 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
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, pendingReasoning)
|
||||
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, pendingReasoning)
|
||||
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: arguments,
|
||||
Arguments: string(arguments),
|
||||
},
|
||||
}
|
||||
// Parallel tool calls arrive as consecutive function_call items and
|
||||
// must share one assistant message; the matching tool replies then
|
||||
// follow it. Merge into the immediately preceding assistant message.
|
||||
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
|
||||
}
|
||||
} else {
|
||||
messages = append(messages, ChatMessage{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ChatToolCall{toolCall},
|
||||
ReasoningContent: pendingReasoning,
|
||||
})
|
||||
}
|
||||
messages = appendAssistantToolCall(messages, toolCall, pendingReasoning)
|
||||
pendingReasoning = ""
|
||||
continue
|
||||
case "function_call_output":
|
||||
content, _ := json.Marshal(rawString(item["output"]))
|
||||
case "function_call_output", "custom_tool_call_output", "tool_search_output":
|
||||
outputRaw := bytesTrimSpace(item["output"])
|
||||
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: rawString(item["call_id"]),
|
||||
@@ -184,9 +302,9 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
|
||||
// Only genuine message items become chat messages. Codex emits other
|
||||
// Responses item types with no Chat equivalent (web_search_call,
|
||||
// local_shell_call, custom tool calls, 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
|
||||
// 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 = ""
|
||||
@@ -213,6 +331,25 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -427,39 +564,187 @@ func chatContentFromSingleResponsesPart(partType string, part map[string]json.Ra
|
||||
}
|
||||
}
|
||||
|
||||
func responsesToolsToChatTools(tools []ResponsesTool) []ChatTool {
|
||||
// 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 {
|
||||
if tool.Type != "function" {
|
||||
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...)
|
||||
}
|
||||
// 其余类型(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: tool.Name,
|
||||
Description: tool.Description,
|
||||
Parameters: tool.Parameters,
|
||||
Strict: tool.Strict,
|
||||
Name: flat,
|
||||
Description: child.Description,
|
||||
Parameters: child.Parameters,
|
||||
Strict: child.Strict,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func responsesToolChoiceToChatToolChoice(raw json.RawMessage) json.RawMessage {
|
||||
// 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
|
||||
}
|
||||
if rawString(choice["type"]) != "function" {
|
||||
return raw
|
||||
var name string
|
||||
switch rawString(choice["type"]) {
|
||||
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
|
||||
}
|
||||
name := rawString(choice["name"])
|
||||
if name == "" {
|
||||
name = rawNestedString(choice["function"], "name")
|
||||
}
|
||||
if name == "" {
|
||||
return raw
|
||||
if !declared[name] {
|
||||
return nil
|
||||
}
|
||||
out, err := json.Marshal(map[string]any{
|
||||
"type": "function",
|
||||
@@ -473,9 +758,38 @@ func responsesToolChoiceToChatToolChoice(raw json.RawMessage) json.RawMessage {
|
||||
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.
|
||||
func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string) *ResponsesResponse {
|
||||
// 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
|
||||
@@ -500,7 +814,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str
|
||||
|
||||
if len(resp.Choices) > 0 {
|
||||
choice := resp.Choices[0]
|
||||
out.Output = chatMessageToResponsesOutput(choice.Message)
|
||||
out.Output = chatMessageToResponsesOutput(choice.Message, customTools, toolSearch, namespaceTools)
|
||||
if choice.FinishReason == "length" {
|
||||
out.Status = "incomplete"
|
||||
out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"}
|
||||
@@ -515,7 +829,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str
|
||||
return out
|
||||
}
|
||||
|
||||
func chatMessageToResponsesOutput(message ChatMessage) []ResponsesOutput {
|
||||
func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput {
|
||||
var outputs []ResponsesOutput
|
||||
if message.ReasoningContent != "" {
|
||||
outputs = append(outputs, ResponsesOutput{
|
||||
@@ -550,6 +864,39 @@ func chatMessageToResponsesOutput(message ChatMessage) []ResponsesOutput {
|
||||
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(),
|
||||
@@ -563,6 +910,21 @@ func chatMessageToResponsesOutput(message ChatMessage) []ResponsesOutput {
|
||||
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",
|
||||
@@ -662,6 +1024,35 @@ type ChatCompletionsToResponsesStreamState struct {
|
||||
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
|
||||
}
|
||||
@@ -669,12 +1060,16 @@ type ChatCompletionsToResponsesStreamState struct {
|
||||
// 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),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,19 +1153,8 @@ func ChatCompletionsChunkToResponsesEvents(
|
||||
copyCall.Function.Arguments = ""
|
||||
state.ToolCalls[idx] = ©Call
|
||||
stored = ©Call
|
||||
itemID := generateItemID()
|
||||
state.ToolItemIDs[idx] = itemID
|
||||
state.ToolItemIDs[idx] = generateItemID()
|
||||
state.ToolOutputIndex[idx] = state.allocOutputIndex()
|
||||
events = append(events, chatToResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
|
||||
OutputIndex: state.ToolOutputIndex[idx],
|
||||
Item: &ResponsesOutput{
|
||||
Type: "function_call",
|
||||
ID: itemID,
|
||||
CallID: stored.ID,
|
||||
Name: stored.Function.Name,
|
||||
Status: "in_progress",
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
if toolCall.ID != "" {
|
||||
stored.ID = toolCall.ID
|
||||
@@ -779,15 +1163,22 @@ func ChatCompletionsChunkToResponsesEvents(
|
||||
stored.Function.Name = toolCall.Function.Name
|
||||
}
|
||||
}
|
||||
events = append(events, announceChatToolItem(state, idx, stored, false)...)
|
||||
if toolCall.Function.Arguments != "" {
|
||||
stored.Function.Arguments += toolCall.Function.Arguments
|
||||
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,
|
||||
}))
|
||||
// 未宣告(名字未到)时仅累积,宣告时统一补发;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 != "" {
|
||||
@@ -998,6 +1389,64 @@ func ensureChatToResponsesTextPart(state *ChatCompletionsToResponsesStreamState)
|
||||
})}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1016,17 +1465,72 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response
|
||||
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: toolCall.Function.Name,
|
||||
Name: name,
|
||||
Arguments: arguments,
|
||||
}),
|
||||
chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{
|
||||
@@ -1035,7 +1539,8 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response
|
||||
Type: "function_call",
|
||||
ID: itemID,
|
||||
CallID: toolCall.ID,
|
||||
Name: toolCall.Function.Name,
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Arguments: arguments,
|
||||
Status: "completed",
|
||||
},
|
||||
@@ -1078,11 +1583,37 @@ func (state *ChatCompletionsToResponsesStreamState) chatOutput() []ResponsesOutp
|
||||
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: toolCall.Function.Name,
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Arguments: arguments,
|
||||
Status: "completed",
|
||||
})
|
||||
|
||||
@@ -0,0 +1,878 @@
|
||||
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_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"}},
|
||||
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 工具应保持原有参数增量事件")
|
||||
}
|
||||
@@ -459,7 +459,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMess
|
||||
}},
|
||||
}
|
||||
|
||||
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner")
|
||||
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil)
|
||||
|
||||
require.Len(t, out.Output, 2)
|
||||
require.Equal(t, "reasoning", out.Output[0].Type)
|
||||
@@ -493,7 +493,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFall
|
||||
}},
|
||||
}
|
||||
|
||||
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner")
|
||||
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil)
|
||||
|
||||
require.Len(t, out.Output, 2)
|
||||
require.Equal(t, "reasoning", out.Output[0].Type)
|
||||
|
||||
@@ -86,6 +86,23 @@ func (e ResponsesStreamEvent) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
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.
|
||||
@@ -167,6 +184,23 @@ func responsesItemWire(item *ResponsesOutput) map[string]any {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -102,6 +102,26 @@ func TestWire_ArgumentsDonePresentEvenEmpty(t *testing.T) {
|
||||
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) {
|
||||
|
||||
@@ -249,11 +249,31 @@ type ResponsesContentPart struct {
|
||||
|
||||
// ResponsesTool describes a tool in the Responses API.
|
||||
type ResponsesTool struct {
|
||||
Type string `json:"type"` // "function" | "web_search" | "local_shell" etc.
|
||||
Type string `json:"type"` // "function" | "custom" | "web_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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -301,11 +321,38 @@ type ResponsesOutput struct {
|
||||
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)
|
||||
}
|
||||
|
||||
// WebSearchAction describes the search action in a web_search_call output item.
|
||||
type WebSearchAction struct {
|
||||
Type string `json:"type,omitempty"` // "search"
|
||||
@@ -444,6 +491,9 @@ type ResponsesStreamEvent struct {
|
||||
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"`
|
||||
|
||||
@@ -140,7 +140,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsAnthropic(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel)
|
||||
responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, nil, false, nil)
|
||||
|
||||
anthropicResp := apicompat.ResponsesToAnthropic(responsesResp, originalModel)
|
||||
|
||||
|
||||
@@ -39,6 +39,13 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
|
||||
|
||||
clientStream := responsesReq.Stream
|
||||
serviceTier := extractOpenAIServiceTierFromBody(body)
|
||||
// custom 工具(如 codex 的 exec)降级为 function 工具转发,回程需按名字还原为
|
||||
// custom_tool_call 项,先记下名字集合;tool_search 工具同理,回程还原为
|
||||
// tool_search_call 项;namespace 子工具(如 MCP 工具)摊平转发,回程按映射还原
|
||||
// 为带 namespace 字段的 function_call 项。
|
||||
customTools := apicompat.CustomToolNames(responsesReq.Tools)
|
||||
toolSearch := apicompat.HasToolSearchTool(responsesReq.Tools)
|
||||
namespaceTools := apicompat.NamespaceToolNames(responsesReq.Tools)
|
||||
|
||||
chatReq, err := apicompat.ResponsesToChatCompletionsRequest(&responsesReq)
|
||||
if err != nil {
|
||||
@@ -100,15 +107,18 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
|
||||
}
|
||||
|
||||
if clientStream {
|
||||
return s.streamChatCompletionsAsResponses(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
|
||||
return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
|
||||
}
|
||||
return s.bufferChatCompletionsAsResponses(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
|
||||
return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses(
|
||||
c *gin.Context,
|
||||
resp *http.Response,
|
||||
originalModel string,
|
||||
customTools map[string]bool,
|
||||
toolSearch bool,
|
||||
namespaceTools map[string]apicompat.NamespacedToolName,
|
||||
billingModel string,
|
||||
upstreamModel string,
|
||||
reasoningEffort *string,
|
||||
@@ -120,7 +130,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel)
|
||||
responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, toolSearch, namespaceTools)
|
||||
|
||||
if s.responseHeaderFilter != nil {
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
@@ -144,6 +154,9 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses(
|
||||
c *gin.Context,
|
||||
resp *http.Response,
|
||||
originalModel string,
|
||||
customTools map[string]bool,
|
||||
toolSearch bool,
|
||||
namespaceTools map[string]apicompat.NamespacedToolName,
|
||||
billingModel string,
|
||||
upstreamModel string,
|
||||
reasoningEffort *string,
|
||||
@@ -154,6 +167,9 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses(
|
||||
writeStreamHeaders := s.newStreamHeaderWriter(c, resp.Header)
|
||||
|
||||
state := apicompat.NewChatCompletionsToResponsesStreamState(originalModel)
|
||||
state.CustomTools = customTools
|
||||
state.ToolSearchDeclared = toolSearch
|
||||
state.NamespaceTools = namespaceTools
|
||||
clientDisconnected := false
|
||||
|
||||
writeEvents := func(events []apicompat.ResponsesStreamEvent) {
|
||||
|
||||
Reference in New Issue
Block a user