mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 06:56:58 +00:00
fix(openai): 修正 Responses 工具 Schema 中显式为 null 的 parameters.type
Codex Desktop 内置的 automation_update 工具会带 parameters.type = null, OpenAI 直接回 400 invalid_function_parameters,而网关把该状态归一成可重试的 502 upstream_error。该工具定义还会沉进多轮会话历史,之后每一轮都继续失败, 同一份坏 Schema 在账号池里被反复重放。 在 OpenAIGatewayService.Forward 分流到 passthrough / Codex transform / 原生 ChatCompletions 之前,统一把显式为 null 的 parameters.type 补成 "object",覆盖顶层 tools[] 与多轮历史里 input[].tools[] 的嵌套工具定义。 只修正显式 null:缺失 type 的 Schema 本身合法,补写会收窄客户端语义, 因此保持原样。 Fixes #5364
This commit is contained in:
@@ -46,6 +46,16 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
if normalized {
|
||||
body = normalizedBody
|
||||
}
|
||||
// 在分流到 passthrough / Codex transform / 原生 ChatCompletions 之前统一修正
|
||||
// 显式为 null 的工具 Schema type,否则 upstream 的 400 会被归一成可重试的 502,
|
||||
// 同一份坏定义在账号池里反复重放。
|
||||
sanitizedToolBody, toolSchemaSanitized, toolSchemaErr := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
if toolSchemaErr != nil {
|
||||
return nil, fmt.Errorf("sanitize OpenAI Responses tool parameters: %w", toolSchemaErr)
|
||||
}
|
||||
if toolSchemaSanitized {
|
||||
body = sanitizedToolBody
|
||||
}
|
||||
if account.IsOpenAIOAuth() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) {
|
||||
liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body)
|
||||
if liteErr != nil {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const (
|
||||
// 工具定义在多轮历史里最多再嵌套一层 tools,留出余量后截断,避免畸形请求体
|
||||
// 造成无界递归。
|
||||
openAIResponsesToolSchemaMaxDepth = 4
|
||||
// JSON Schema 里 type 只能是字符串或字符串数组;显式 null 无论哪个方言都非法,
|
||||
// 补成 object 与 upstream 对该工具的实际期望一致。
|
||||
openAIResponsesToolSchemaFallbackType = "object"
|
||||
)
|
||||
|
||||
// sanitizeOpenAIResponsesToolParameterTypes 修正请求体中显式为 null 的
|
||||
// tools[].parameters.type。
|
||||
//
|
||||
// Codex Desktop 内置的 automation_update 工具会带 parameters.type = null,
|
||||
// OpenAI 直接回 400 invalid_function_parameters,而网关把该状态归一成可重试的
|
||||
// 502 upstream_error;该工具定义又会沉进多轮历史,导致之后每一轮继续失败并在
|
||||
// 账号池里反复重放同一份坏 Schema。
|
||||
//
|
||||
// 只修正显式 null:缺失 type 的 Schema 本身合法(等价于不约束),补写会收窄
|
||||
// 客户端语义,因此保持原样。
|
||||
func sanitizeOpenAIResponsesToolParameterTypes(body []byte) ([]byte, bool, error) {
|
||||
if len(body) == 0 {
|
||||
return body, false, nil
|
||||
}
|
||||
|
||||
paths := make([]string, 0, 2)
|
||||
collectOpenAIResponsesToolSchemaNullTypePaths(gjson.GetBytes(body, "tools"), "tools", 0, &paths)
|
||||
if input := gjson.GetBytes(body, "input"); input.IsArray() {
|
||||
index := 0
|
||||
input.ForEach(func(_, item gjson.Result) bool {
|
||||
itemPath := fmt.Sprintf("input.%d.tools", index)
|
||||
index++
|
||||
if item.IsObject() {
|
||||
collectOpenAIResponsesToolSchemaNullTypePaths(item.Get("tools"), itemPath, 0, &paths)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return body, false, nil
|
||||
}
|
||||
|
||||
sanitized := body
|
||||
for _, path := range paths {
|
||||
next, err := sjson.SetBytes(sanitized, path, openAIResponsesToolSchemaFallbackType)
|
||||
if err != nil {
|
||||
return body, false, fmt.Errorf("normalize %s: %w", path, err)
|
||||
}
|
||||
sanitized = next
|
||||
}
|
||||
return sanitized, true, nil
|
||||
}
|
||||
|
||||
// collectOpenAIResponsesToolSchemaNullTypePaths 收集一个 tools 数组里所有需要修正的
|
||||
// parameters.type 路径。不按 tool type 过滤:null 的 schema type 在 function、
|
||||
// custom 以及任何 hosted 工具上都同样非法。
|
||||
func collectOpenAIResponsesToolSchemaNullTypePaths(tools gjson.Result, basePath string, depth int, paths *[]string) {
|
||||
if depth > openAIResponsesToolSchemaMaxDepth || !tools.IsArray() {
|
||||
return
|
||||
}
|
||||
index := 0
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
toolPath := fmt.Sprintf("%s.%d", basePath, index)
|
||||
index++
|
||||
if !tool.IsObject() {
|
||||
return true
|
||||
}
|
||||
// Responses 形态用顶层 parameters,ChatCompletions 形态用 function.parameters,
|
||||
// 两种都可能出现在 Responses 请求里(见 normalizeCodexTools)。
|
||||
for _, suffix := range []string{"parameters", "function.parameters"} {
|
||||
params := tool.Get(suffix)
|
||||
if !params.IsObject() {
|
||||
continue
|
||||
}
|
||||
if typ := params.Get("type"); typ.Exists() && typ.Type == gjson.Null {
|
||||
*paths = append(*paths, toolPath+"."+suffix+".type")
|
||||
}
|
||||
}
|
||||
// 历史输入里的工具定义会再嵌套一层 tools(upstream 报错路径形如
|
||||
// input[234].tools[0].tools[3].parameters)。
|
||||
collectOpenAIResponsesToolSchemaNullTypePaths(tool.Get("tools"), toolPath+".tools", depth+1, paths)
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// issue #5364 的最小复现体:Codex Desktop 内置 automation_update 带
|
||||
// parameters.type = null,upstream 回 400 invalid_function_parameters。
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_TopLevelFunctionTool(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": "Reply with OK.",
|
||||
"stream": false,
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "automation_update",
|
||||
"description": "Update an automation.",
|
||||
"parameters": {"type": null, "properties": {}}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.parameters.type").String())
|
||||
// 只改 type,工具其余定义原样保留。
|
||||
require.Equal(t, "automation_update", gjson.GetBytes(sanitized, "tools.0.name").String())
|
||||
require.Equal(t, "Update an automation.", gjson.GetBytes(sanitized, "tools.0.description").String())
|
||||
require.True(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties").IsObject())
|
||||
// 请求体其余字段不受影响。
|
||||
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(sanitized, "model").String())
|
||||
require.Equal(t, "Reply with OK.", gjson.GetBytes(sanitized, "input").String())
|
||||
}
|
||||
|
||||
// 合法 Schema 必须原样返回:changed=false 且字节不变,避免无谓重写打散
|
||||
// prompt cache 前缀。
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_ValidSchemaUntouched(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"type":"function","name":"ok","parameters":{"type":"object","properties":{}}}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(body), string(sanitized))
|
||||
}
|
||||
|
||||
// 缺失 type 的 Schema 本身合法(等价于不约束),不得补写——补写会收窄客户端语义。
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_MissingTypeNotInvented(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"type":"function","name":"ok","parameters":{"properties":{}}}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.type").Exists())
|
||||
}
|
||||
|
||||
// 多轮历史:工具定义沉进 input 后,upstream 报错路径形如
|
||||
// input[N].tools[i].tools[j].parameters,两层都要修。
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_NestedHistoryTools(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"input": [
|
||||
{"type": "message", "role": "user", "content": "hi"},
|
||||
{
|
||||
"type": "additional_tools",
|
||||
"role": "developer",
|
||||
"tools": [
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "codex_app",
|
||||
"tools": [
|
||||
{"type": "function", "name": "noop", "parameters": {"type": "object"}},
|
||||
{"type": "function", "name": "automation_update", "parameters": {"type": null}}
|
||||
]
|
||||
},
|
||||
{"type": "function", "name": "outer", "parameters": {"type": null}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.0.tools.1.parameters.type").String())
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.1.parameters.type").String())
|
||||
// 原本合法的兄弟条目保持不变。
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.0.tools.0.parameters.type").String())
|
||||
require.Equal(t, "hi", gjson.GetBytes(sanitized, "input.0.content").String())
|
||||
}
|
||||
|
||||
// ChatCompletions 形态的工具({type:"function", function:{...}})同样可能出现在
|
||||
// Responses 请求里,见 normalizeCodexTools。
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_ChatCompletionsShape(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"type":"function","function":{"name":"legacy","parameters":{"type":null}}}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.function.parameters.type").String())
|
||||
require.Equal(t, "legacy", gjson.GetBytes(sanitized, "tools.0.function.name").String())
|
||||
}
|
||||
|
||||
// 索引映射:只有坏条目被改,前后兄弟条目按原下标保持不变。
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_OnlyOffendingIndexRewritten(t *testing.T) {
|
||||
body := []byte(`{"tools":[
|
||||
{"type":"function","name":"a","parameters":{"type":"object"}},
|
||||
{"type":"function","name":"b","parameters":{"type":null}},
|
||||
{"type":"function","name":"c","parameters":{"type":"object"}}
|
||||
]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "a", gjson.GetBytes(sanitized, "tools.0.name").String())
|
||||
require.Equal(t, "b", gjson.GetBytes(sanitized, "tools.1.name").String())
|
||||
require.Equal(t, "c", gjson.GetBytes(sanitized, "tools.2.name").String())
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.1.parameters.type").String())
|
||||
require.Equal(t, 3, int(gjson.GetBytes(sanitized, "tools.#").Int()))
|
||||
}
|
||||
|
||||
// 畸形/非常规形态不得 panic,且一律按不变处理。
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_MalformedShapesAreNoOps(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"empty_body", ``},
|
||||
{"no_tools", `{"model":"gpt-5.6-sol","input":"hi"}`},
|
||||
{"tools_null", `{"tools":null}`},
|
||||
{"tools_object", `{"tools":{"type":"function"}}`},
|
||||
{"tool_is_string", `{"tools":["freeform"]}`},
|
||||
{"parameters_is_string", `{"tools":[{"type":"function","parameters":"nope"}]}`},
|
||||
{"parameters_null", `{"tools":[{"type":"function","parameters":null}]}`},
|
||||
{"input_string", `{"input":"hi","tools":[]}`},
|
||||
{"input_item_not_object", `{"input":["hi"]}`},
|
||||
{"type_already_array", `{"tools":[{"type":"function","parameters":{"type":["object","null"]}}]}`},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes([]byte(tc.body))
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, tc.body, string(sanitized))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 递归深度守卫:超深嵌套只做截断,不递归到栈溢出,也不报错。
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_DepthGuard(t *testing.T) {
|
||||
tool := map[string]any{"type": "function", "name": "deep", "parameters": map[string]any{"type": nil}}
|
||||
for i := 0; i < 12; i++ {
|
||||
tool = map[string]any{"type": "namespace", "tools": []any{tool}}
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"tools": []any{tool}})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
_, _, sanitizeErr := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
require.NoError(t, sanitizeErr)
|
||||
})
|
||||
}
|
||||
|
||||
// 输出必须是合法 JSON,且除目标字段外与输入等价。
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_OutputStaysValidJSON(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.5","tool_choice":"none","store":false,"tools":[{"type":"function","name":"automation_update","parameters":{"type":null,"properties":{}}}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(sanitized, &decoded))
|
||||
require.Equal(t, "gpt-5.5", decoded["model"])
|
||||
require.Equal(t, "none", decoded["tool_choice"])
|
||||
require.Equal(t, false, decoded["store"])
|
||||
}
|
||||
Reference in New Issue
Block a user