openai_responses_tool_schema_test.go 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
  1. package service
  2. import (
  3. "encoding/json"
  4. "strconv"
  5. "testing"
  6. "github.com/stretchr/testify/require"
  7. "github.com/tidwall/gjson"
  8. )
  9. // issue #5364 的最小复现体:Codex Desktop 内置 automation_update 带
  10. // parameters.type = null,upstream 回 400 invalid_function_parameters。
  11. func TestSanitizeOpenAIResponsesToolParameterTypes_TopLevelFunctionTool(t *testing.T) {
  12. body := []byte(`{
  13. "model": "gpt-5.6-sol",
  14. "input": "Reply with OK.",
  15. "stream": false,
  16. "tools": [
  17. {
  18. "type": "function",
  19. "name": "automation_update",
  20. "description": "Update an automation.",
  21. "parameters": {"type": null, "properties": {}}
  22. }
  23. ]
  24. }`)
  25. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  26. require.NoError(t, err)
  27. require.True(t, changed)
  28. require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.parameters.type").String())
  29. // 只改 type,工具其余定义原样保留。
  30. require.Equal(t, "automation_update", gjson.GetBytes(sanitized, "tools.0.name").String())
  31. require.Equal(t, "Update an automation.", gjson.GetBytes(sanitized, "tools.0.description").String())
  32. require.True(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties").IsObject())
  33. // 请求体其余字段不受影响。
  34. require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(sanitized, "model").String())
  35. require.Equal(t, "Reply with OK.", gjson.GetBytes(sanitized, "input").String())
  36. }
  37. // 合法 Schema 必须原样返回:changed=false 且字节不变,避免无谓重写打散
  38. // prompt cache 前缀。
  39. func TestSanitizeOpenAIResponsesToolParameterTypes_ValidSchemaUntouched(t *testing.T) {
  40. body := []byte(`{"tools":[{"type":"function","name":"ok","parameters":{"type":"object","properties":{}}}]}`)
  41. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  42. require.NoError(t, err)
  43. require.False(t, changed)
  44. require.Equal(t, string(body), string(sanitized))
  45. }
  46. // 缺失 type 的 Schema 本身合法(等价于不约束),不得补写——补写会收窄客户端语义。
  47. func TestSanitizeOpenAIResponsesToolParameterTypes_MissingTypeNotInvented(t *testing.T) {
  48. body := []byte(`{"tools":[{"type":"function","name":"ok","parameters":{"properties":{}}}]}`)
  49. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  50. require.NoError(t, err)
  51. require.False(t, changed)
  52. require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.type").Exists())
  53. }
  54. func TestSanitizeOpenAIResponsesToolParameterTypes_ObjectOnlyRootUnion(t *testing.T) {
  55. body := []byte(`{"tools":[
  56. {"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object","properties":{"id":{"type":"string"}}},{"anyOf":[{"type":"object"},{"type":"object","properties":{}}]}]}},
  57. {"type":"function","name":"codex_app__automation_update","parameters":{"anyOf":[{"type":"object"}]}},
  58. {"type":"function","name":"unrelated_tool","parameters":{"oneOf":[{"type":"object"}]}}
  59. ]}`)
  60. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  61. require.NoError(t, err)
  62. require.True(t, changed)
  63. for i := 0; i < 3; i++ {
  64. require.Equal(t, "object", gjson.GetBytes(sanitized, "tools."+strconv.Itoa(i)+".parameters.type").String())
  65. }
  66. require.Equal(t, "string", gjson.GetBytes(sanitized, "tools.0.parameters.oneOf.0.properties.id.type").String())
  67. }
  68. func TestSanitizeOpenAIResponsesToolParameterTypes_EscapedUnionKeyword(t *testing.T) {
  69. body := []byte(`{"tools":[{"type":"function","name":"other","parameters":{"one\u004ff":[{"type":"object"}]}}]}`)
  70. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  71. require.NoError(t, err)
  72. require.True(t, changed)
  73. require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.parameters.type").String())
  74. }
  75. func TestSanitizeOpenAIResponsesToolParameterTypes_ObjectUnionMissingTypeSafetyBoundary(t *testing.T) {
  76. tests := []struct {
  77. name string
  78. body string
  79. }{
  80. {name: "mixed union", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"},{"type":"string"}]}}]}`},
  81. {name: "unproven branch", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"properties":{}}]}}]}`},
  82. {name: "empty union", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[]}}]}`},
  83. {name: "existing null handled by existing repair", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"type":null,"oneOf":[{"type":"object"}]}}]}`},
  84. }
  85. for _, tt := range tests {
  86. t.Run(tt.name, func(t *testing.T) {
  87. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes([]byte(tt.body))
  88. require.NoError(t, err)
  89. if tt.name == "existing null handled by existing repair" {
  90. require.True(t, changed)
  91. require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.parameters.type").String())
  92. return
  93. }
  94. require.False(t, changed)
  95. require.Equal(t, tt.body, string(sanitized))
  96. })
  97. }
  98. }
  99. // 多轮历史:工具定义沉进 input 后,upstream 报错路径形如
  100. // input[N].tools[i].tools[j].parameters,两层都要修。
  101. func TestSanitizeOpenAIResponsesToolParameterTypes_NestedHistoryTools(t *testing.T) {
  102. body := []byte(`{
  103. "input": [
  104. {"type": "message", "role": "user", "content": "hi"},
  105. {
  106. "type": "additional_tools",
  107. "role": "developer",
  108. "parameters": {"type": null},
  109. "tools": [
  110. {
  111. "type": "namespace",
  112. "name": "codex_app",
  113. "tools": [
  114. {"type": "function", "name": "noop", "parameters": {"type": "object"}},
  115. {"type": "function", "name": "automation_update", "parameters": {"type": null}}
  116. ]
  117. },
  118. {"type": "function", "name": "outer", "parameters": {"type": null}}
  119. ]
  120. }
  121. ]
  122. }`)
  123. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  124. require.NoError(t, err)
  125. require.True(t, changed)
  126. require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.0.tools.1.parameters.type").String())
  127. require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.1.parameters.type").String())
  128. require.Equal(t, "null", gjson.GetBytes(sanitized, "input.1.parameters.type").Raw)
  129. // 原本合法的兄弟条目保持不变。
  130. require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.0.tools.0.parameters.type").String())
  131. require.Equal(t, "hi", gjson.GetBytes(sanitized, "input.0.content").String())
  132. }
  133. // ChatCompletions 形态的工具({type:"function", function:{...}})同样可能出现在
  134. // Responses 请求里,见 normalizeCodexTools。
  135. func TestSanitizeOpenAIResponsesToolParameterTypes_ChatCompletionsShape(t *testing.T) {
  136. body := []byte(`{"tools":[{"type":"function","function":{"name":"legacy","parameters":{"type":null}}}]}`)
  137. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  138. require.NoError(t, err)
  139. require.True(t, changed)
  140. require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.function.parameters.type").String())
  141. require.Equal(t, "legacy", gjson.GetBytes(sanitized, "tools.0.function.name").String())
  142. }
  143. func TestSanitizeOpenAIResponsesToolParameterTypes_InputItemShapes(t *testing.T) {
  144. body := []byte(`{"input":[{"parameters":{"type":null},"name":"direct","type":"function"},{"function":{"name":"wrapped","parameters":{"type":null}},"type":"function"}]}`)
  145. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  146. require.NoError(t, err)
  147. require.True(t, changed)
  148. require.Equal(t, "object", gjson.GetBytes(sanitized, "input.0.parameters.type").String())
  149. require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.function.parameters.type").String())
  150. }
  151. func TestSanitizeOpenAIResponsesToolParameterTypes_DoesNotTreatMessagesAsTools(t *testing.T) {
  152. body := []byte(`{"input":[{"type":"message","role":"user","parameters":{"type":null},"function":{"parameters":{"type":null}},"metadata":{"parameters":{"type":null}}}]}`)
  153. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  154. require.NoError(t, err)
  155. require.False(t, changed)
  156. require.Equal(t, string(body), string(sanitized))
  157. }
  158. func TestSanitizeOpenAIResponsesToolParameterTypes_PreservesUntouchedBytes(t *testing.T) {
  159. body := []byte("{\n \"z\":1e+999, \"integer\":900719925474099312345, \"escaped\":\"\\u003chtml\\u003e\", \"tools\":[{\"parameters\":{\"properties\":{}, \"type\" : null},\"name\":\"x\"}], \"a\":-0.00\n}")
  160. want := []byte("{\n \"z\":1e+999, \"integer\":900719925474099312345, \"escaped\":\"\\u003chtml\\u003e\", \"tools\":[{\"parameters\":{\"properties\":{}, \"type\" : \"object\"},\"name\":\"x\"}], \"a\":-0.00\n}")
  161. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  162. require.NoError(t, err)
  163. require.True(t, changed)
  164. require.Equal(t, string(want), string(sanitized))
  165. }
  166. func TestSanitizeOpenAIResponsesToolParameterTypes_EscapedKey(t *testing.T) {
  167. body := []byte(`{"tools":[{"type":"function","parameters":{"ty\u0070e":null}}]}`)
  168. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  169. require.NoError(t, err)
  170. require.True(t, changed)
  171. require.Equal(t, `{"tools":[{"type":"function","parameters":{"ty\u0070e":"object"}}]}`, string(sanitized))
  172. }
  173. func TestSanitizeOpenAIResponsesToolParameterTypes_DuplicateTypeIsAmbiguousNoOp(t *testing.T) {
  174. for _, body := range [][]byte{
  175. []byte(`{"tools":[{"type":"function","parameters":{"type":null,"type":"string"}}]}`),
  176. []byte(`{"tools":[{"type":"function","parameters":{"type":"string","ty\u0070e":null}}]}`),
  177. []byte(`{"tools":[{"type":"function","parameters":{"type":null,"type":null}}]}`),
  178. } {
  179. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  180. require.NoError(t, err)
  181. require.False(t, changed)
  182. require.Equal(t, string(body), string(sanitized))
  183. }
  184. }
  185. func TestSanitizeOpenAIResponsesToolSchemas_InvalidAndTrailingJSON(t *testing.T) {
  186. for _, tc := range []struct {
  187. name string
  188. body []byte
  189. call func([]byte) ([]byte, bool, error)
  190. }{
  191. {"null_type_trailing", []byte(`{"tools":[{"parameters":{"type":null}}]} trailing`), sanitizeOpenAIResponsesToolParameterTypes},
  192. {"pattern_trailing", []byte(`{"tools":[{"parameters":{"pattern":"(?=x)"}}]} trailing`), sanitizeOpenAIResponsesToolSchemaPatterns},
  193. {"invalid_utf8", append([]byte(`{"tools":[{"parameters":{"pattern":"(?=x)"}}],"bad":"`), 0xff, '"', '}'), sanitizeOpenAIResponsesToolSchemaPatterns},
  194. } {
  195. t.Run(tc.name, func(t *testing.T) {
  196. _, changed, err := tc.call(tc.body)
  197. require.Error(t, err)
  198. require.False(t, changed)
  199. })
  200. }
  201. }
  202. func TestOpenAIResponsesToolSchemaCapabilities_PlatformBoundary(t *testing.T) {
  203. tests := []struct {
  204. platform string
  205. repairNullType bool
  206. removeLookaround bool
  207. }{
  208. {PlatformOpenAI, true, true},
  209. {PlatformAnthropic, true, false},
  210. {PlatformKimi, true, false},
  211. {PlatformZhipu, true, false},
  212. {PlatformDeepseek, true, false},
  213. {PlatformGrok, true, false},
  214. {PlatformGemini, false, false},
  215. {PlatformAntigravity, false, false},
  216. {PlatformComposite, false, false},
  217. {"", false, false},
  218. }
  219. for _, tt := range tests {
  220. t.Run(tt.platform, func(t *testing.T) {
  221. require.Equal(t, tt.repairNullType, shouldRepairOpenAIResponsesNullToolSchemaType(tt.platform))
  222. require.Equal(t, tt.removeLookaround, shouldSanitizeOpenAIResponsesToolSchemaPatterns(tt.platform))
  223. })
  224. }
  225. }
  226. func TestSanitizeOpenAIResponsesToolSchemasForPlatform_ReplayBoundary(t *testing.T) {
  227. body := []byte(`{"tools":[{"type":"function","parameters":{"type":null,"properties":{"query":{"type":"string","pattern":"(?=keep)"}}}}]}`)
  228. // A malformed tool definition may be replayed after account failover. Every
  229. // compatible account must repair it, while non-OpenAI providers retain their
  230. // supported regex semantics.
  231. for _, platform := range []string{PlatformAnthropic, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
  232. t.Run(platform, func(t *testing.T) {
  233. for attempt := 0; attempt < 2; attempt++ {
  234. normalized, changed, err := sanitizeOpenAIResponsesToolSchemasForPlatform(body, platform)
  235. require.NoError(t, err)
  236. require.True(t, changed)
  237. require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String())
  238. require.Equal(t, "(?=keep)", gjson.GetBytes(normalized, "tools.0.parameters.properties.query.pattern").String())
  239. }
  240. })
  241. }
  242. openAI, changed, err := sanitizeOpenAIResponsesToolSchemasForPlatform(body, PlatformOpenAI)
  243. require.NoError(t, err)
  244. require.True(t, changed)
  245. require.Equal(t, "object", gjson.GetBytes(openAI, "tools.0.parameters.type").String())
  246. require.False(t, gjson.GetBytes(openAI, "tools.0.parameters.properties.query.pattern").Exists())
  247. unsupported, changed, err := sanitizeOpenAIResponsesToolSchemasForPlatform(body, PlatformGemini)
  248. require.NoError(t, err)
  249. require.False(t, changed)
  250. require.Equal(t, string(body), string(unsupported))
  251. }
  252. func TestSanitizeOpenAIResponsesToolSchemasForPlatform_GrokObjectOnlyRootUnion(t *testing.T) {
  253. body := []byte(`{"tools":[{"type":"function","name":"codex_app__automation_update","parameters":{"oneOf":[{"type":"object","properties":{"id":{"type":"string"}}},{"type":"object","properties":{}}]}}]}`)
  254. sanitized, changed, err := sanitizeOpenAIResponsesToolSchemasForPlatform(body, PlatformGrok)
  255. require.NoError(t, err)
  256. require.True(t, changed)
  257. require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.parameters.type").String())
  258. require.True(t, gjson.GetBytes(sanitized, "tools.0.parameters.oneOf").Exists())
  259. }
  260. // 索引映射:只有坏条目被改,前后兄弟条目按原下标保持不变。
  261. func TestSanitizeOpenAIResponsesToolParameterTypes_OnlyOffendingIndexRewritten(t *testing.T) {
  262. body := []byte(`{"tools":[
  263. {"type":"function","name":"a","parameters":{"type":"object"}},
  264. {"type":"function","name":"b","parameters":{"type":null}},
  265. {"type":"function","name":"c","parameters":{"type":"object"}}
  266. ]}`)
  267. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  268. require.NoError(t, err)
  269. require.True(t, changed)
  270. require.Equal(t, "a", gjson.GetBytes(sanitized, "tools.0.name").String())
  271. require.Equal(t, "b", gjson.GetBytes(sanitized, "tools.1.name").String())
  272. require.Equal(t, "c", gjson.GetBytes(sanitized, "tools.2.name").String())
  273. require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.1.parameters.type").String())
  274. require.Equal(t, 3, int(gjson.GetBytes(sanitized, "tools.#").Int()))
  275. }
  276. // 畸形/非常规形态不得 panic,且一律按不变处理。
  277. func TestSanitizeOpenAIResponsesToolParameterTypes_MalformedShapesAreNoOps(t *testing.T) {
  278. cases := []struct {
  279. name string
  280. body string
  281. }{
  282. {"empty_body", ``},
  283. {"no_tools", `{"model":"gpt-5.6-sol","input":"hi"}`},
  284. {"tools_null", `{"tools":null}`},
  285. {"tools_object", `{"tools":{"type":"function"}}`},
  286. {"tool_is_string", `{"tools":["freeform"]}`},
  287. {"parameters_is_string", `{"tools":[{"type":"function","parameters":"nope"}]}`},
  288. {"parameters_null", `{"tools":[{"type":"function","parameters":null}]}`},
  289. {"input_string", `{"input":"hi","tools":[]}`},
  290. {"input_item_not_object", `{"input":["hi"]}`},
  291. {"type_already_array", `{"tools":[{"type":"function","parameters":{"type":["object","null"]}}]}`},
  292. }
  293. for _, tc := range cases {
  294. t.Run(tc.name, func(t *testing.T) {
  295. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes([]byte(tc.body))
  296. require.NoError(t, err)
  297. require.False(t, changed)
  298. require.Equal(t, tc.body, string(sanitized))
  299. })
  300. }
  301. }
  302. // 递归深度守卫:超深嵌套只做截断,不递归到栈溢出,也不报错。
  303. func TestSanitizeOpenAIResponsesToolParameterTypes_DepthGuard(t *testing.T) {
  304. tool := map[string]any{"type": "function", "name": "deep", "parameters": map[string]any{"type": nil}}
  305. for i := 0; i < 12; i++ {
  306. tool = map[string]any{"type": "namespace", "tools": []any{tool}}
  307. }
  308. body, err := json.Marshal(map[string]any{"tools": []any{tool}})
  309. require.NoError(t, err)
  310. require.NotPanics(t, func() {
  311. _, _, sanitizeErr := sanitizeOpenAIResponsesToolParameterTypes(body)
  312. require.NoError(t, sanitizeErr)
  313. })
  314. }
  315. // 输出必须是合法 JSON,且除目标字段外与输入等价。
  316. func TestSanitizeOpenAIResponsesToolParameterTypes_OutputStaysValidJSON(t *testing.T) {
  317. body := []byte(`{"model":"gpt-5.5","tool_choice":"none","store":false,"tools":[{"type":"function","name":"automation_update","parameters":{"type":null,"properties":{}}}]}`)
  318. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  319. require.NoError(t, err)
  320. require.True(t, changed)
  321. var decoded map[string]any
  322. require.NoError(t, json.Unmarshal(sanitized, &decoded))
  323. require.Equal(t, "gpt-5.5", decoded["model"])
  324. require.Equal(t, "none", decoded["tool_choice"])
  325. require.Equal(t, false, decoded["store"])
  326. }
  327. // 输入 body 是调用方持有的缓冲区(Forward 里 canonicalImageIntentBody 与它同源),
  328. // 净化必须返回新切片,绝不能就地改写。
  329. func TestSanitizeOpenAIResponsesToolParameterTypes_DoesNotMutateInputBody(t *testing.T) {
  330. body := []byte(`{"model":"gpt-5.6-sol","tools":[{"type":"function","name":"a","parameters":{"type":null}}]}`)
  331. original := append([]byte(nil), body...)
  332. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  333. require.NoError(t, err)
  334. require.True(t, changed)
  335. require.Equal(t, string(original), string(body), "调用方的 body 不得被就地改写")
  336. require.NotEqual(t, string(original), string(sanitized))
  337. }
  338. func TestSanitizeOpenAIResponsesToolSchemaPatterns_RemovesLookaroundOnly(t *testing.T) {
  339. body := []byte(`{"tools":[{"type":"function","name":"search","parameters":{"type":"object","properties":{"q":{"type":"string","pattern":"^(?=.*foo)[a-z]+$"},"id":{"type":"string","pattern":"^[a-z]+$"},"z":{"type":"string","pattern":"(?<!bad)ok"}}}}]}`)
  340. sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
  341. require.NoError(t, err)
  342. require.True(t, changed)
  343. require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties.q.pattern").Exists())
  344. require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties.z.pattern").Exists())
  345. require.Equal(t, "^[a-z]+$", gjson.GetBytes(sanitized, "tools.0.parameters.properties.id.pattern").String())
  346. }
  347. func TestSanitizeOpenAIResponsesToolSchemaPatterns_PreservesRawDocumentRepresentation(t *testing.T) {
  348. body := []byte("{\n \"number\":1e+999,\n \"tools\":[{\"parameters\":{\"pattern\":\"(?=drop)\", \"description\":\"\\u003ctag\\u003e\", \"type\":\"string\"}}],\n \"tail\":-0.00\n}")
  349. want := []byte("{\n \"number\":1e+999,\n \"tools\":[{\"parameters\":{ \"description\":\"\\u003ctag\\u003e\", \"type\":\"string\"}}],\n \"tail\":-0.00\n}")
  350. sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
  351. require.NoError(t, err)
  352. require.True(t, changed)
  353. require.Equal(t, string(want), string(sanitized))
  354. }
  355. func TestSanitizeOpenAIResponsesToolSchemaPatterns_AllMemberPositions(t *testing.T) {
  356. body := []byte(`{"tools":[{"parameters":{"properties":{"first":{"pattern":"(?=a)","type":"string"},"middle":{"title":"x","pattern":"(?!b)","type":"string"},"last":{"type":"string","pattern":"(?<=c)"},"only":{"pattern":"(?<!d)"}}}}]}`)
  357. sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
  358. require.NoError(t, err)
  359. require.True(t, changed)
  360. for _, name := range []string{"first", "middle", "last", "only"} {
  361. require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties."+name+".pattern").Exists(), name)
  362. require.True(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties."+name).IsObject(), name)
  363. }
  364. }
  365. func TestSanitizeOpenAIResponsesToolSchemaPatterns_TraversesConservativeSchemaKeywords(t *testing.T) {
  366. body := []byte(`{"input":[{"type":"function","name":"direct","parameters":{"type":"object","properties":{"p":{"anyOf":[{"pattern":"(?=any)"}],"items":{"pattern":"(?!item)"}}},"$defs":{"d":{"pattern":"(?<=def)"}},"additionalProperties":{"pattern":"(?<!additional)"}}},{"type":"function","function":{"name":"wrapped","parameters":{"oneOf":[{"pattern":"(?=wrapped)"}]}}}]}`)
  367. sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
  368. require.NoError(t, err)
  369. require.True(t, changed)
  370. for _, path := range []string{
  371. "input.0.parameters.properties.p.anyOf.0.pattern",
  372. "input.0.parameters.properties.p.items.pattern",
  373. "input.0.parameters.$defs.d.pattern",
  374. "input.0.parameters.additionalProperties.pattern",
  375. "input.1.function.parameters.oneOf.0.pattern",
  376. } {
  377. require.False(t, gjson.GetBytes(sanitized, path).Exists(), path)
  378. }
  379. }
  380. func TestSanitizeOpenAIResponsesToolSchemaPatterns_DoesNotTraverseMessageMetadata(t *testing.T) {
  381. body := []byte(`{"input":[{"parameters":{"pattern":"(?=missing-type)"}},{"type":"message","parameters":{"pattern":"(?=parameters)"},"function":{"parameters":{"pattern":"(?=function)"}},"metadata":{"tools":[{"type":"function","parameters":{"pattern":"(?=metadata)"}}]}}]}`)
  382. sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
  383. require.NoError(t, err)
  384. require.False(t, changed)
  385. require.Equal(t, string(body), string(sanitized))
  386. }
  387. func TestSanitizeOpenAIResponsesToolSchemaPatterns_DecodesEscapedLookaround(t *testing.T) {
  388. for _, pattern := range []string{`\u0028?\u003dfoo`, `(\u003f\u003dfoo)`} {
  389. body := []byte(`{"tools":[{"parameters":{"type":"string","pattern":"` + pattern + `"}}]}`)
  390. sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
  391. require.NoError(t, err)
  392. require.True(t, changed)
  393. require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.pattern").Exists())
  394. }
  395. }
  396. func TestSanitizeOpenAIResponsesToolSchemaPatterns_EscapedKey(t *testing.T) {
  397. body := []byte(`{"to\u006fls":[{"par\u0061meters":{"type":"string","pat\u0074ern":"(?=drop)"}}]}`)
  398. sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
  399. require.NoError(t, err)
  400. require.True(t, changed)
  401. require.Equal(t, `{"to\u006fls":[{"par\u0061meters":{"type":"string"}}]}`, string(sanitized))
  402. }
  403. func TestSanitizeOpenAIResponsesToolSchemaPatterns_DuplicateKeysStayValid(t *testing.T) {
  404. for _, tc := range []struct {
  405. body string
  406. want string
  407. }{
  408. {
  409. `{"tools":[{"parameters":{"pattern":"(?=drop)","pattern":"^[a-z]+$","type":"string"}}]}`,
  410. `{"tools":[{"parameters":{"pattern":"^[a-z]+$","type":"string"}}]}`,
  411. },
  412. {
  413. `{"tools":[{"parameters":{"type":"string","pattern":"(?=first)","pattern":"(?!second)"}}]}`,
  414. `{"tools":[{"parameters":{"type":"string"}}]}`,
  415. },
  416. } {
  417. sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns([]byte(tc.body))
  418. require.NoError(t, err)
  419. require.True(t, changed)
  420. require.True(t, json.Valid(sanitized))
  421. require.Equal(t, tc.want, string(sanitized))
  422. }
  423. }
  424. func TestOpenAIResponsesToolSchemaPlatformGate_APIKeyAndOAuth(t *testing.T) {
  425. body := []byte(`{"tools":[{"type":"function","parameters":{"type":null,"pattern":"(?=drop)"}}]}`)
  426. for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
  427. t.Run(accountType, func(t *testing.T) {
  428. normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
  429. Platform: PlatformOpenAI,
  430. Type: accountType,
  431. }, false)
  432. require.NoError(t, err)
  433. require.True(t, changed)
  434. require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String())
  435. require.False(t, gjson.GetBytes(normalized, "tools.0.parameters.pattern").Exists())
  436. })
  437. }
  438. normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
  439. Platform: PlatformGrok,
  440. Type: AccountTypeAPIKey,
  441. }, false)
  442. require.NoError(t, err)
  443. require.False(t, changed)
  444. require.Equal(t, string(body), string(normalized))
  445. }
  446. func TestSanitizeOpenAIResponsesToolSchemas_DepthLimitReturnsOriginal(t *testing.T) {
  447. body := []byte(`{"tools":[{"parameters":`)
  448. for i := 0; i < openAIResponsesToolSchemaMaxDepth+2; i++ {
  449. body = append(body, []byte(`{"allOf":[`)...)
  450. }
  451. body = append(body, []byte(`{"pattern":"(?=too-deep)"}`)...)
  452. for i := 0; i < openAIResponsesToolSchemaMaxDepth+2; i++ {
  453. body = append(body, []byte(`]}`)...)
  454. }
  455. body = append(body, []byte(`}]}`)...)
  456. require.True(t, json.Valid(body))
  457. sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
  458. require.NoError(t, err)
  459. require.False(t, changed)
  460. require.Equal(t, string(body), string(sanitized))
  461. }
  462. func TestSanitizeOpenAIResponsesToolSchemaPatterns_DoesNotTouchUserInputPattern(t *testing.T) {
  463. body := []byte(`{"input":{"pattern":"(?=keep)"},"metadata":{"pattern":"(?!keep)"}}`)
  464. sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
  465. require.NoError(t, err)
  466. require.False(t, changed)
  467. require.Equal(t, string(body), string(sanitized))
  468. }
  469. func TestSanitizeOpenAIResponsesToolSchemaPatterns_DoesNotTraverseInstanceData(t *testing.T) {
  470. body := []byte(`{"tools":[{"type":"function","parameters":{"type":"object","properties":{"config":{"type":"object","pattern":"(?=remove)","default":{"pattern":"(?=keep-default)"},"examples":[{"pattern":"(?=keep-example)"}],"const":{"pattern":"(?=keep-const)"},"enum":[{"pattern":"(?=keep-enum)"}]}}}}]}`)
  471. sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
  472. require.NoError(t, err)
  473. require.True(t, changed)
  474. require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties.config.pattern").Exists())
  475. require.Equal(t, "(?=keep-default)", gjson.GetBytes(sanitized, "tools.0.parameters.properties.config.default.pattern").String())
  476. require.Equal(t, "(?=keep-example)", gjson.GetBytes(sanitized, "tools.0.parameters.properties.config.examples.0.pattern").String())
  477. require.Equal(t, "(?=keep-const)", gjson.GetBytes(sanitized, "tools.0.parameters.properties.config.const.pattern").String())
  478. require.Equal(t, "(?=keep-enum)", gjson.GetBytes(sanitized, "tools.0.parameters.properties.config.enum.0.pattern").String())
  479. }
  480. func buildToolSchemaNullTypeBody(t *testing.T, hits int) []byte {
  481. t.Helper()
  482. tools := make([]any, 0, hits)
  483. for i := 0; i < hits; i++ {
  484. tools = append(tools, map[string]any{
  485. "type": "function",
  486. "name": "automation_update",
  487. "parameters": map[string]any{"type": nil, "properties": map[string]any{}},
  488. })
  489. }
  490. body, err := json.Marshal(map[string]any{"model": "gpt-5.6-sol", "tools": tools})
  491. require.NoError(t, err)
  492. return body
  493. }
  494. // 复杂度守卫:重写次数必须与命中数无关。
  495. //
  496. // 逐个 sjson.SetBytes 的写法每命中一处就重扫并全量拷贝一次文档,命中 N 处即 N 次
  497. // 全量拷贝;/v1/responses 的 body 上限是 gateway.max_body_size(默认 256MB),
  498. // 构造请求可以塞进百万级命中,会被放大成 TB 级 memcpy。这里用分配次数锁死该行为:
  499. // 命中数放大 500 倍,分配次数不得随之增长。
  500. func TestSanitizeOpenAIResponsesToolParameterTypes_RewriteCountIndependentOfHits(t *testing.T) {
  501. small := buildToolSchemaNullTypeBody(t, 4)
  502. large := buildToolSchemaNullTypeBody(t, 2000)
  503. smallAllocs := testing.AllocsPerRun(2, func() {
  504. _, _, _ = sanitizeOpenAIResponsesToolParameterTypes(small)
  505. })
  506. largeAllocs := testing.AllocsPerRun(2, func() {
  507. _, _, _ = sanitizeOpenAIResponsesToolParameterTypes(large)
  508. })
  509. // 命中切片扩容是对数级,留出充裕余量;线性写法在这里会是 2000 量级。
  510. // 干净环境实测 large 约 17 allocs,200 是 10 倍余量,同时容忍 CI 慢 pod 上
  511. // 包内后台 goroutine(日志/ticker)对进程级 Mallocs 的噪声污染。
  512. require.Less(t, largeAllocs, 200.0,
  513. "分配次数随命中数线性增长,说明退回了逐路径全量重写 (small=%v large=%v)", smallAllocs, largeAllocs)
  514. // 同时确认大 body 的结果确实全部修好了。
  515. sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(large)
  516. require.NoError(t, err)
  517. require.True(t, changed)
  518. require.Equal(t, 2000, int(gjson.GetBytes(sanitized, "tools.#").Int()))
  519. gjson.GetBytes(sanitized, "tools").ForEach(func(_, tool gjson.Result) bool {
  520. require.Equal(t, "object", tool.Get("parameters.type").String())
  521. return true
  522. })
  523. }
  524. func BenchmarkSanitizeOpenAIResponsesToolSchemaPatterns_ByteSpanPatch(b *testing.B) {
  525. var tools []any
  526. for i := 0; i < 2000; i++ {
  527. tools = append(tools, map[string]any{
  528. "type": "function",
  529. "name": "search",
  530. "parameters": map[string]any{
  531. "type": "object",
  532. "properties": map[string]any{"query": map[string]any{"type": "string", "pattern": "(?=foo).*"}},
  533. },
  534. })
  535. }
  536. body, _ := json.Marshal(map[string]any{"model": "gpt-5.6", "tools": tools})
  537. b.ReportAllocs()
  538. b.SetBytes(int64(len(body)))
  539. b.ResetTimer()
  540. for i := 0; i < b.N; i++ {
  541. _, _, _ = sanitizeOpenAIResponsesToolSchemaPatterns(body)
  542. }
  543. }
  544. func BenchmarkSanitizeOpenAIResponsesToolParameterTypes_ByteSpanPatch(b *testing.B) {
  545. body := make([]byte, 0, 256*2000)
  546. body = append(body, []byte(`{"tools":[`)...)
  547. for i := 0; i < 2000; i++ {
  548. if i > 0 {
  549. body = append(body, ',')
  550. }
  551. body = append(body, []byte(`{"type":"function","name":"automation_update","parameters":{"properties":{},"type":null}}`)...)
  552. }
  553. body = append(body, []byte(`]}`)...)
  554. b.ReportAllocs()
  555. b.SetBytes(int64(len(body)))
  556. b.ResetTimer()
  557. for i := 0; i < b.N; i++ {
  558. _, _, _ = sanitizeOpenAIResponsesToolParameterTypes(body)
  559. }
  560. }