Merge pull request #5383 from fengshao1227/fix/responses-tool-parameters-null-type

fix(openai): 修正 Responses 工具 Schema 中显式为 null 的 parameters.type
This commit is contained in:
Wesley Liddick
2026-08-07 21:06:10 +08:00
committed by GitHub
3 changed files with 386 additions and 0 deletions
@@ -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,127 @@
package service
import (
"bytes"
"sort"
"github.com/tidwall/gjson"
)
const (
// 工具定义在多轮历史里最多再嵌套一层 tools,留出余量后截断,避免畸形请求体
// 造成无界递归。
openAIResponsesToolSchemaMaxDepth = 4
// JSON Schema 里 type 只能是字符串或字符串数组;显式 null 无论哪个方言都非法,
// 补成 object 与 upstream 对该工具的实际期望一致。
openAIResponsesToolSchemaFallbackType = `"object"`
// 显式 null 在 JSON 里只有这一种字面量形态。
openAIResponsesToolSchemaNullLiteral = "null"
)
// openAIResponsesToolSchemaNullType 记录一处待修正的 null,用原始 body 上的
// 绝对字节偏移表示,便于最后一次性拼接。
type openAIResponsesToolSchemaNullType struct {
offset int
length int
}
// sanitizeOpenAIResponsesToolParameterTypes 修正请求体中显式为 null 的
// tools[].parameters.type。
//
// Codex Desktop 内置的 automation_update 工具会带 parameters.type = null,
// OpenAI 直接回 400 invalid_function_parameters,而网关把该状态归一成可重试的
// 502 upstream_error;该工具定义又会沉进多轮历史,导致之后每一轮继续失败并在
// 账号池里反复重放同一份坏 Schema。
//
// 只修正显式 null:缺失 type 的 Schema 本身合法(等价于不约束),补写会收窄
// 客户端语义,因此保持原样。
//
// 实现上先收集全部命中的绝对偏移,再一次性拼出新 body:逐个 sjson.SetBytes 每次
// 都会重扫并全量拷贝整个文档,命中 N 处就是 N 次全量拷贝,而 /v1/responses 的
// body 上限是 gateway.max_body_size(默认 256MB),构造请求能塞进百万级命中。
func sanitizeOpenAIResponsesToolParameterTypes(body []byte) ([]byte, bool, error) {
if len(body) == 0 {
return body, false, nil
}
hits := make([]openAIResponsesToolSchemaNullType, 0, 2)
collectOpenAIResponsesToolSchemaNullTypes(body, gjson.GetBytes(body, "tools"), 0, &hits)
if input := gjson.GetBytes(body, "input"); input.IsArray() {
input.ForEach(func(_, item gjson.Result) bool {
if item.IsObject() {
collectOpenAIResponsesToolSchemaNullTypes(body, item.Get("tools"), 0, &hits)
}
return true
})
}
if len(hits) == 0 {
return body, false, nil
}
// tools 与 input 在 body 里的先后顺序由客户端决定,收集顺序不保证单调。
sort.Slice(hits, func(i, j int) bool { return hits[i].offset < hits[j].offset })
sanitized := make([]byte, 0, len(body)+len(hits)*len(openAIResponsesToolSchemaFallbackType))
cursor := 0
for _, hit := range hits {
// 收集阶段已逐个校验过区间,这里再挡一次重叠,保证拼接严格单调向前。
if hit.offset < cursor {
continue
}
sanitized = append(sanitized, body[cursor:hit.offset]...)
sanitized = append(sanitized, openAIResponsesToolSchemaFallbackType...)
cursor = hit.offset + hit.length
}
sanitized = append(sanitized, body[cursor:]...)
return sanitized, true, nil
}
// collectOpenAIResponsesToolSchemaNullTypes 收集一个 tools 数组里所有需要修正的
// parameters.type 位置。不按 tool type 过滤:null 的 schema type 在 function、
// custom 以及任何 hosted 工具上都同样非法。
func collectOpenAIResponsesToolSchemaNullTypes(
body []byte, tools gjson.Result, depth int, hits *[]openAIResponsesToolSchemaNullType,
) {
if depth > openAIResponsesToolSchemaMaxDepth || !tools.IsArray() {
return
}
tools.ForEach(func(_, tool gjson.Result) bool {
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
}
// gjson 用 Type==Null 同时表示「显式 null」和「路径不存在」,靠 Raw
// 区分:不存在时 Raw 为空串。
if typ := params.Get("type"); typ.Type == gjson.Null && typ.Raw == openAIResponsesToolSchemaNullLiteral {
appendOpenAIResponsesToolSchemaNullType(body, typ, hits)
}
}
// 历史输入里的工具定义会再嵌套一层 tools(upstream 报错路径形如
// input[234].tools[0].tools[3].parameters)。
collectOpenAIResponsesToolSchemaNullTypes(body, tool.Get("tools"), depth+1, hits)
return true
})
}
// appendOpenAIResponsesToolSchemaNullType 先校验 gjson 给出的偏移确实指向原始
// body 上那段 null,再记录。gjson 对嵌套取值同样返回相对原始文档的绝对偏移,但
// Index 为 0 表示未知;偏移不可用时跳过该处而不是猜位置——少修一个工具只是维持
// 现状,拼错位置会损坏整个请求体。
func appendOpenAIResponsesToolSchemaNullType(
body []byte, typ gjson.Result, hits *[]openAIResponsesToolSchemaNullType,
) {
end := typ.Index + len(typ.Raw)
if typ.Index <= 0 || end > len(body) {
return
}
if !bytes.Equal(body[typ.Index:end], []byte(typ.Raw)) {
return
}
*hits = append(*hits, openAIResponsesToolSchemaNullType{offset: typ.Index, length: len(typ.Raw)})
}
@@ -0,0 +1,249 @@
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"])
}
// 输入 body 是调用方持有的缓冲区(Forward 里 canonicalImageIntentBody 与它同源),
// 净化必须返回新切片,绝不能就地改写。
func TestSanitizeOpenAIResponsesToolParameterTypes_DoesNotMutateInputBody(t *testing.T) {
body := []byte(`{"model":"gpt-5.6-sol","tools":[{"type":"function","name":"a","parameters":{"type":null}}]}`)
original := append([]byte(nil), body...)
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, string(original), string(body), "调用方的 body 不得被就地改写")
require.NotEqual(t, string(original), string(sanitized))
}
func buildToolSchemaNullTypeBody(t *testing.T, hits int) []byte {
t.Helper()
tools := make([]any, 0, hits)
for i := 0; i < hits; i++ {
tools = append(tools, map[string]any{
"type": "function",
"name": "automation_update",
"parameters": map[string]any{"type": nil, "properties": map[string]any{}},
})
}
body, err := json.Marshal(map[string]any{"model": "gpt-5.6-sol", "tools": tools})
require.NoError(t, err)
return body
}
// 复杂度守卫:重写次数必须与命中数无关。
//
// 逐个 sjson.SetBytes 的写法每命中一处就重扫并全量拷贝一次文档,命中 N 处即 N 次
// 全量拷贝;/v1/responses 的 body 上限是 gateway.max_body_size(默认 256MB),
// 构造请求可以塞进百万级命中,会被放大成 TB 级 memcpy。这里用分配次数锁死该行为:
// 命中数放大 500 倍,分配次数不得随之增长。
func TestSanitizeOpenAIResponsesToolParameterTypes_RewriteCountIndependentOfHits(t *testing.T) {
small := buildToolSchemaNullTypeBody(t, 4)
large := buildToolSchemaNullTypeBody(t, 2000)
smallAllocs := testing.AllocsPerRun(2, func() {
_, _, _ = sanitizeOpenAIResponsesToolParameterTypes(small)
})
largeAllocs := testing.AllocsPerRun(2, func() {
_, _, _ = sanitizeOpenAIResponsesToolParameterTypes(large)
})
// 命中切片扩容是对数级,留出充裕余量;线性写法在这里会是 2000 量级。
require.Less(t, largeAllocs, smallAllocs+40,
"分配次数随命中数线性增长,说明退回了逐路径全量重写 (small=%v large=%v)", smallAllocs, largeAllocs)
// 同时确认大 body 的结果确实全部修好了。
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(large)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, 2000, int(gjson.GetBytes(sanitized, "tools.#").Int()))
gjson.GetBytes(sanitized, "tools").ForEach(func(_, tool gjson.Result) bool {
require.Equal(t, "object", tool.Get("parameters.type").String())
return true
})
}