Merge pull request #4082 from zuelu/agent/fix-codex-additional-tools-chat-bridge

fix(apicompat): 支持 Codex additional_tools 到 Chat Completions 桥接
This commit is contained in:
Wesley Liddick
2026-07-13 14:42:16 +08:00
committed by GitHub
3 changed files with 97 additions and 5 deletions
@@ -35,8 +35,12 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR
if req.Reasoning != nil {
out.ReasoningEffort = req.Reasoning.Effort
}
if len(req.Tools) > 0 {
tools, err := responsesToolsToChatTools(req.Tools)
effectiveTools, err := EffectiveResponsesTools(req)
if err != nil {
return nil, err
}
if len(effectiveTools) > 0 {
tools, err := responsesToolsToChatTools(effectiveTools)
if err != nil {
return nil, err
}
@@ -63,6 +67,44 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR
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 item struct {
Type string `json:"type"`
Tools []ResponsesTool `json:"tools"`
}
if err := json.Unmarshal(raw, &item); err != nil {
return nil, fmt.Errorf("parse responses additional tools item: %w", err)
}
if item.Type == "additional_tools" {
tools = append(tools, item.Tools...)
}
}
return tools, nil
}
// CustomToolNames 收集 Responses 请求中 custom/freeform 工具的名字。chat 桥回程时
// 需要据此把模型对这些工具的调用还原为 custom_tool_call 项(codex 只按该类型路由)。
func CustomToolNames(tools []ResponsesTool) map[string]bool {
@@ -34,6 +34,51 @@ func TestResponsesToChatCompletionsRequest_CustomToolBecomesFunctionTool(t *test
assert.Equal(t, "wait", out.Tools[1].Function.Name)
}
func TestResponsesToChatCompletionsRequest_AdditionalToolsItem(t *testing.T) {
req := &ResponsesRequest{
Model: "gpt-test",
Input: json.RawMessage(`[
{"type":"additional_tools","role":"developer","tools":[
{"type":"custom","name":"exec","description":"Run PowerShell","format":{"type":"text"}},
{"type":"function","name":"wait","parameters":{"type":"object","properties":{}}},
{"type":"namespace","name":"collaboration","tools":[
{"type":"function","name":"send_message","parameters":{"type":"object","properties":{}}}
]}
]},
{"type":"message","role":"user","content":[{"type":"input_text","text":"run Get-Location"}]}
]`),
ToolChoice: json.RawMessage(`"auto"`),
}
effective, err := EffectiveResponsesTools(req)
require.NoError(t, err)
require.Len(t, effective, 3)
assert.True(t, CustomToolNames(effective)["exec"])
assert.Equal(t, NamespacedToolName{Namespace: "collaboration", Name: "send_message"}, NamespaceToolNames(effective)["collaboration__send_message"])
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, out.Tools, 3)
assert.Equal(t, "exec", out.Tools[0].Function.Name)
assert.Equal(t, "wait", out.Tools[1].Function.Name)
assert.Equal(t, "collaboration__send_message", out.Tools[2].Function.Name)
assert.JSONEq(t, `"auto"`, string(out.ToolChoice))
require.Len(t, out.Messages, 1, "additional_tools must not become a chat message")
assert.Equal(t, "user", out.Messages[0].Role)
}
func TestEffectiveResponsesTools_SkipsStringInputItems(t *testing.T) {
req := &ResponsesRequest{
Input: json.RawMessage(`["plain input",{"type":"additional_tools","tools":[{"type":"custom","name":"exec"}]}]`),
}
tools, err := EffectiveResponsesTools(req)
require.NoError(t, err)
require.Len(t, tools, 1)
assert.Equal(t, "exec", tools[0].Name)
}
func TestResponsesToChatCompletionsRequest_DropsToolChoiceWhenNoConvertibleTools(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.2",
@@ -43,9 +43,14 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
// 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)
effectiveTools, err := apicompat.EffectiveResponsesTools(&responsesReq)
if err != nil {
writeOpenAIResponsesFallbackError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return nil, fmt.Errorf("resolve responses tools: %w", err)
}
customTools := apicompat.CustomToolNames(effectiveTools)
toolSearch := apicompat.HasToolSearchTool(effectiveTools)
namespaceTools := apicompat.NamespaceToolNames(effectiveTools)
chatReq, err := apicompat.ResponsesToChatCompletionsRequest(&responsesReq)
if err != nil {