From f3c94d2099e92efaf0e20742fa338b6dbfb483e8 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 7 Aug 2026 18:31:56 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(openai):=20=E4=BF=AE=E6=AD=A3=20Respons?= =?UTF-8?q?es=20=E5=B7=A5=E5=85=B7=20Schema=20=E4=B8=AD=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E4=B8=BA=20null=20=E7=9A=84=20parameters.type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../service/openai_gateway_forward.go | 10 + .../service/openai_responses_tool_schema.go | 92 +++++++++ .../openai_responses_tool_schema_test.go | 188 ++++++++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 backend/internal/service/openai_responses_tool_schema.go create mode 100644 backend/internal/service/openai_responses_tool_schema_test.go diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 0484ea7d3..bb13dd598 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -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 { diff --git a/backend/internal/service/openai_responses_tool_schema.go b/backend/internal/service/openai_responses_tool_schema.go new file mode 100644 index 000000000..af9811e2c --- /dev/null +++ b/backend/internal/service/openai_responses_tool_schema.go @@ -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 + }) +} diff --git a/backend/internal/service/openai_responses_tool_schema_test.go b/backend/internal/service/openai_responses_tool_schema_test.go new file mode 100644 index 000000000..59b22801c --- /dev/null +++ b/backend/internal/service/openai_responses_tool_schema_test.go @@ -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"]) +} From 379db1914158ec4329b2755c32c7c250998025b7 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 7 Aug 2026 20:37:44 +0800 Subject: [PATCH 2/2] =?UTF-8?q?perf(openai):=20=E5=B7=A5=E5=85=B7=20Schema?= =?UTF-8?q?=20=E5=87=80=E5=8C=96=E6=94=B9=E4=B8=BA=E5=8D=95=E6=AC=A1?= =?UTF-8?q?=E6=8B=BC=E6=8E=A5=EF=BC=8C=E5=8E=BB=E6=8E=89=E9=80=90=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=85=A8=E9=87=8F=E9=87=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sjson.SetBytes 在 opts 为 nil 时 optimistic=false、inplace=false,每次调用都要 重扫整个文档并新建缓冲区拷贝,命中 N 处就是 N 次全量拷贝。/v1/responses 的 body 上限是 gateway.max_body_size(默认 256MB),构造请求能塞进百万级命中,会被放大 成 TB 级 memcpy。 改为收集 gjson 给出的绝对字节偏移后一次性拼接:全程一次分配、一次拷贝。 偏移不可用(Index 为 0)或与 body 对不上时跳过该处,而不是猜位置拼出损坏的 JSON。 实测(2000 处命中):旧写法 17740 次分配,新写法 18 次。 --- .../service/openai_responses_tool_schema.go | 85 +++++++++++++------ .../openai_responses_tool_schema_test.go | 61 +++++++++++++ 2 files changed, 121 insertions(+), 25 deletions(-) diff --git a/backend/internal/service/openai_responses_tool_schema.go b/backend/internal/service/openai_responses_tool_schema.go index af9811e2c..8c7e7b8d1 100644 --- a/backend/internal/service/openai_responses_tool_schema.go +++ b/backend/internal/service/openai_responses_tool_schema.go @@ -1,10 +1,10 @@ package service import ( - "fmt" + "bytes" + "sort" "github.com/tidwall/gjson" - "github.com/tidwall/sjson" ) const ( @@ -13,9 +13,18 @@ const ( openAIResponsesToolSchemaMaxDepth = 4 // JSON Schema 里 type 只能是字符串或字符串数组;显式 null 无论哪个方言都非法, // 补成 object 与 upstream 对该工具的实际期望一致。 - openAIResponsesToolSchemaFallbackType = "object" + openAIResponsesToolSchemaFallbackType = `"object"` + // 显式 null 在 JSON 里只有这一种字面量形态。 + openAIResponsesToolSchemaNullLiteral = "null" ) +// openAIResponsesToolSchemaNullType 记录一处待修正的 null,用原始 body 上的 +// 绝对字节偏移表示,便于最后一次性拼接。 +type openAIResponsesToolSchemaNullType struct { + offset int + length int +} + // sanitizeOpenAIResponsesToolParameterTypes 修正请求体中显式为 null 的 // tools[].parameters.type。 // @@ -26,50 +35,57 @@ const ( // // 只修正显式 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 } - paths := make([]string, 0, 2) - collectOpenAIResponsesToolSchemaNullTypePaths(gjson.GetBytes(body, "tools"), "tools", 0, &paths) + hits := make([]openAIResponsesToolSchemaNullType, 0, 2) + collectOpenAIResponsesToolSchemaNullTypes(body, gjson.GetBytes(body, "tools"), 0, &hits) 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) + collectOpenAIResponsesToolSchemaNullTypes(body, item.Get("tools"), 0, &hits) } return true }) } - if len(paths) == 0 { + if len(hits) == 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) + // 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 = next + 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 } -// collectOpenAIResponsesToolSchemaNullTypePaths 收集一个 tools 数组里所有需要修正的 -// parameters.type 路径。不按 tool type 过滤:null 的 schema type 在 function、 +// collectOpenAIResponsesToolSchemaNullTypes 收集一个 tools 数组里所有需要修正的 +// parameters.type 位置。不按 tool type 过滤:null 的 schema type 在 function、 // custom 以及任何 hosted 工具上都同样非法。 -func collectOpenAIResponsesToolSchemaNullTypePaths(tools gjson.Result, basePath string, depth int, paths *[]string) { +func collectOpenAIResponsesToolSchemaNullTypes( + body []byte, tools gjson.Result, depth int, hits *[]openAIResponsesToolSchemaNullType, +) { 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 } @@ -80,13 +96,32 @@ func collectOpenAIResponsesToolSchemaNullTypePaths(tools gjson.Result, basePath if !params.IsObject() { continue } - if typ := params.Get("type"); typ.Exists() && typ.Type == gjson.Null { - *paths = append(*paths, toolPath+"."+suffix+".type") + // 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)。 - collectOpenAIResponsesToolSchemaNullTypePaths(tool.Get("tools"), toolPath+".tools", depth+1, paths) + 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)}) +} diff --git a/backend/internal/service/openai_responses_tool_schema_test.go b/backend/internal/service/openai_responses_tool_schema_test.go index 59b22801c..2dbc7fb60 100644 --- a/backend/internal/service/openai_responses_tool_schema_test.go +++ b/backend/internal/service/openai_responses_tool_schema_test.go @@ -186,3 +186,64 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_OutputStaysValidJSON(t *testi 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 + }) +}