chatcompletions_responses_bridge_custom_tools_test.go 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166
  1. package apicompat
  2. // custom/freeform 工具(如 Codex 0.14x 的 exec)在 responses→chat 桥上的双向转换。
  3. // 背景:Codex 的核心命令执行工具 exec 是 type=custom(输入为自由文本),此前被
  4. // responsesToolsToChatTools 丢弃,导致模型工具列表中没有 exec、无法执行任何命令。
  5. import (
  6. "encoding/json"
  7. "testing"
  8. "github.com/stretchr/testify/assert"
  9. "github.com/stretchr/testify/require"
  10. )
  11. func TestResponsesToChatCompletionsRequest_CustomToolBecomesFunctionTool(t *testing.T) {
  12. req := &ResponsesRequest{
  13. Model: "glm-5.2",
  14. Input: json.RawMessage(`"run dir"`),
  15. Tools: []ResponsesTool{
  16. {Type: "custom", Name: "exec", Description: "Run JavaScript code"},
  17. {Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)},
  18. },
  19. }
  20. out, err := ResponsesToChatCompletionsRequest(req)
  21. require.NoError(t, err)
  22. require.Len(t, out.Tools, 2)
  23. assert.Equal(t, "function", out.Tools[0].Type)
  24. assert.Equal(t, "exec", out.Tools[0].Function.Name)
  25. assert.Equal(t, "Run JavaScript code", out.Tools[0].Function.Description)
  26. assert.JSONEq(t, customToolInputSchema, string(out.Tools[0].Function.Parameters))
  27. assert.Equal(t, "wait", out.Tools[1].Function.Name)
  28. }
  29. func TestResponsesChatBridge_MixedCustomAndNamespaceToolNames(t *testing.T) {
  30. req := &ResponsesRequest{
  31. Model: "deepseek-test",
  32. Input: json.RawMessage(`"run pwd"`),
  33. Tools: []ResponsesTool{
  34. {Type: "custom", Name: "exec", Description: "Runs a command"},
  35. {Type: "namespace", Name: "functions", Tools: []ResponsesTool{
  36. {Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object"}`)},
  37. }},
  38. },
  39. }
  40. chatReq, err := ResponsesToChatCompletionsRequest(req)
  41. require.NoError(t, err)
  42. require.Len(t, chatReq.Tools, 2)
  43. assert.Equal(t, "exec", chatReq.Tools[0].Function.Name)
  44. assert.Equal(t, "functions__wait", chatReq.Tools[1].Function.Name)
  45. customTools := CustomToolNames(req.Tools)
  46. namespaceTools := NamespaceToolNames(req.Tools)
  47. resp := &ChatCompletionsResponse{Choices: []ChatChoice{{Message: ChatMessage{ToolCalls: []ChatToolCall{
  48. {ID: "call_exact", Function: ChatFunctionCall{Name: "exec", Arguments: `{"input":"pwd"}`}},
  49. {ID: "call_wait", Function: ChatFunctionCall{Name: "functions__wait", Arguments: `{"cell_id":"1"}`}},
  50. {ID: "call_alias", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `not-json`}},
  51. }}}}}
  52. out := ChatCompletionsResponseToResponses(resp, req.Model, customTools, FunctionToolNames(req.Tools), false, namespaceTools)
  53. require.Len(t, out.Output, 3)
  54. assert.Equal(t, "custom_tool_call", out.Output[0].Type)
  55. assert.Equal(t, "exec", out.Output[0].Name)
  56. assert.Equal(t, "pwd", out.Output[0].Input)
  57. assert.Equal(t, "function_call", out.Output[1].Type)
  58. assert.Equal(t, "functions", out.Output[1].Namespace)
  59. assert.Equal(t, "wait", out.Output[1].Name)
  60. assert.Equal(t, "custom_tool_call", out.Output[2].Type)
  61. assert.Equal(t, "exec", out.Output[2].Name)
  62. assert.Equal(t, "not-json", out.Output[2].Input)
  63. }
  64. func TestChatCompletionsResponseToResponses_ExplicitFunctionOwnsCustomAliasCollision(t *testing.T) {
  65. resp := &ChatCompletionsResponse{Choices: []ChatChoice{{Message: ChatMessage{ToolCalls: []ChatToolCall{{
  66. ID: "call_function", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `{"path":"/tmp"}`},
  67. }}}}}}
  68. out := ChatCompletionsResponseToResponses(resp, "deepseek-test",
  69. map[string]bool{"exec": true}, map[string]bool{"functions__exec": true}, false,
  70. map[string]NamespacedToolName{"functions__wait": {Namespace: "functions", Name: "wait"}})
  71. require.Len(t, out.Output, 1)
  72. assert.Equal(t, "function_call", out.Output[0].Type)
  73. assert.Equal(t, "functions__exec", out.Output[0].Name)
  74. assert.Equal(t, `{"path":"/tmp"}`, out.Output[0].Arguments)
  75. }
  76. func TestResponsesToChatCompletionsRequest_AdditionalToolsItem(t *testing.T) {
  77. req := &ResponsesRequest{
  78. Model: "gpt-test",
  79. Input: json.RawMessage(`[
  80. {"type":"additional_tools","role":"developer","tools":[
  81. {"type":"custom","name":"exec","description":"Run PowerShell","format":{"type":"text"}},
  82. {"type":"function","name":"wait","parameters":{"type":"object","properties":{}}},
  83. {"type":"namespace","name":"collaboration","tools":[
  84. {"type":"function","name":"send_message","parameters":{"type":"object","properties":{}}}
  85. ]}
  86. ]},
  87. {"type":"message","role":"user","content":[{"type":"input_text","text":"run Get-Location"}]}
  88. ]`),
  89. ToolChoice: json.RawMessage(`"auto"`),
  90. }
  91. effective, err := EffectiveResponsesTools(req)
  92. require.NoError(t, err)
  93. require.Len(t, effective, 3)
  94. assert.True(t, CustomToolNames(effective)["exec"])
  95. assert.Equal(t, NamespacedToolName{Namespace: "collaboration", Name: "send_message"}, NamespaceToolNames(effective)["collaboration__send_message"])
  96. out, err := ResponsesToChatCompletionsRequest(req)
  97. require.NoError(t, err)
  98. require.Len(t, out.Tools, 3)
  99. assert.Equal(t, "exec", out.Tools[0].Function.Name)
  100. assert.Equal(t, "wait", out.Tools[1].Function.Name)
  101. assert.Equal(t, "collaboration__send_message", out.Tools[2].Function.Name)
  102. assert.JSONEq(t, `"auto"`, string(out.ToolChoice))
  103. require.Len(t, out.Messages, 1, "additional_tools must not become a chat message")
  104. assert.Equal(t, "user", out.Messages[0].Role)
  105. }
  106. func TestEffectiveResponsesTools_SkipsStringInputItems(t *testing.T) {
  107. req := &ResponsesRequest{
  108. Input: json.RawMessage(`["plain input",{"type":"additional_tools","tools":[{"type":"custom","name":"exec"}]}]`),
  109. }
  110. tools, err := EffectiveResponsesTools(req)
  111. require.NoError(t, err)
  112. require.Len(t, tools, 1)
  113. assert.Equal(t, "exec", tools[0].Name)
  114. }
  115. func TestEffectiveResponsesTools_IgnoresMalformedToolsOnNonAdditionalItem(t *testing.T) {
  116. req := &ResponsesRequest{
  117. Input: json.RawMessage(`[
  118. {"type":"message","role":"user","tools":"not-an-array","content":[{"type":"input_text","text":"hello"}]},
  119. {"type":"additional_tools","tools":[{"type":"custom","name":"exec"}]}
  120. ]`),
  121. }
  122. tools, err := EffectiveResponsesTools(req)
  123. require.NoError(t, err)
  124. require.Len(t, tools, 1)
  125. assert.Equal(t, "exec", tools[0].Name)
  126. }
  127. func TestEffectiveResponsesTools_RejectsMalformedAdditionalTools(t *testing.T) {
  128. req := &ResponsesRequest{
  129. Input: json.RawMessage(`[{"type":"additional_tools","tools":"not-an-array"}]`),
  130. }
  131. tools, err := EffectiveResponsesTools(req)
  132. require.Error(t, err)
  133. assert.Contains(t, err.Error(), "parse responses additional tools item")
  134. assert.Empty(t, tools)
  135. }
  136. func TestResponsesToChatCompletionsRequest_DropsToolChoiceWhenNoConvertibleTools(t *testing.T) {
  137. req := &ResponsesRequest{
  138. Model: "glm-5.2",
  139. Input: json.RawMessage(`"hi"`),
  140. Tools: []ResponsesTool{
  141. {Type: "web_search"},
  142. {Type: "image_generation"},
  143. },
  144. ToolChoice: json.RawMessage(`"auto"`),
  145. }
  146. out, err := ResponsesToChatCompletionsRequest(req)
  147. require.NoError(t, err)
  148. assert.Empty(t, out.Tools)
  149. assert.Empty(t, out.ToolChoice, "tools 为空时转发 tool_choice 会被上游 400 拒绝")
  150. }
  151. func TestResponsesToChatCompletionsRequest_CustomToolChoiceMapsToFunctionChoice(t *testing.T) {
  152. req := &ResponsesRequest{
  153. Model: "glm-5.2",
  154. Input: json.RawMessage(`"run dir"`),
  155. Tools: []ResponsesTool{{Type: "custom", Name: "exec"}},
  156. ToolChoice: json.RawMessage(`{"type":"custom","name":"exec"}`),
  157. }
  158. out, err := ResponsesToChatCompletionsRequest(req)
  159. require.NoError(t, err)
  160. assert.JSONEq(t, `{"type":"function","function":{"name":"exec"}}`, string(out.ToolChoice))
  161. }
  162. func TestResponsesInputToChatMessages_CustomToolCallHistory(t *testing.T) {
  163. input := json.RawMessage(`[
  164. {"role":"user","content":"list files"},
  165. {"type":"custom_tool_call","call_id":"call_1","name":"exec","input":"dir"},
  166. {"type":"custom_tool_call_output","call_id":"call_1","output":"main.go"}
  167. ]`)
  168. messages, err := responsesInputToChatMessages("", input)
  169. require.NoError(t, err)
  170. require.Len(t, messages, 3)
  171. assert.Equal(t, []string{"user", "assistant", "tool"}, chatMessageRoles(messages))
  172. require.Len(t, messages[1].ToolCalls, 1)
  173. toolCall := messages[1].ToolCalls[0]
  174. assert.Equal(t, "call_1", toolCall.ID)
  175. assert.Equal(t, "exec", toolCall.Function.Name)
  176. assert.JSONEq(t, `{"input":"dir"}`, toolCall.Function.Arguments)
  177. assert.Equal(t, "call_1", messages[2].ToolCallID)
  178. assert.JSONEq(t, `"main.go"`, string(messages[2].Content))
  179. }
  180. func TestChatCompletionsResponseToResponses_CustomToolCallOutputItem(t *testing.T) {
  181. resp := &ChatCompletionsResponse{
  182. ID: "cc-1",
  183. Choices: []ChatChoice{{
  184. Message: ChatMessage{
  185. Role: "assistant",
  186. ToolCalls: []ChatToolCall{
  187. {ID: "call_1", Function: ChatFunctionCall{Name: "exec", Arguments: `{"input": "dir"}`}},
  188. {ID: "call_2", Function: ChatFunctionCall{Name: "wait", Arguments: `{"cell_id": 3}`}},
  189. },
  190. },
  191. }},
  192. }
  193. out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, nil, false, nil)
  194. require.Len(t, out.Output, 2)
  195. assert.Equal(t, "custom_tool_call", out.Output[0].Type)
  196. assert.Equal(t, "call_1", out.Output[0].CallID)
  197. assert.Equal(t, "exec", out.Output[0].Name)
  198. assert.Equal(t, "dir", out.Output[0].Input)
  199. assert.Empty(t, out.Output[0].Arguments)
  200. assert.Equal(t, "function_call", out.Output[1].Type)
  201. assert.Equal(t, "wait", out.Output[1].Name)
  202. assert.Equal(t, `{"cell_id": 3}`, out.Output[1].Arguments)
  203. }
  204. func TestExtractCustomToolCallInput_FallsBackToRawArguments(t *testing.T) {
  205. assert.Equal(t, "dir", extractCustomToolCallInput(`{"input": "dir"}`))
  206. assert.Equal(t, "console.log(1)", extractCustomToolCallInput(`console.log(1)`))
  207. assert.Equal(t, `{"other": "x"}`, extractCustomToolCallInput(`{"other": "x"}`))
  208. assert.Equal(t, "", extractCustomToolCallInput(`{}`))
  209. assert.Equal(t, "", extractCustomToolCallInput(""))
  210. }
  211. func TestChatCompletionsChunkToResponsesEvents_CustomToolCallStream(t *testing.T) {
  212. state := NewChatCompletionsToResponsesStreamState("glm-5.2")
  213. state.CustomTools = map[string]bool{"exec": true}
  214. idx := 0
  215. chunk := &ChatCompletionsChunk{
  216. ID: "cc-1",
  217. Choices: []ChatChunkChoice{{
  218. Delta: ChatDelta{
  219. ToolCalls: []ChatToolCall{{
  220. Index: &idx,
  221. ID: "call_1",
  222. Function: ChatFunctionCall{Name: "exec", Arguments: `{"input": "dir"}`},
  223. }},
  224. },
  225. }},
  226. }
  227. events := ChatCompletionsChunkToResponsesEvents(chunk, state)
  228. events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
  229. var added, inputDone, itemDone *ResponsesStreamEvent
  230. for i := range events {
  231. evt := &events[i]
  232. switch evt.Type {
  233. case "response.output_item.added":
  234. if evt.Item != nil && evt.Item.Type != "message" && evt.Item.Type != "reasoning" {
  235. added = evt
  236. }
  237. case "response.custom_tool_call_input.done":
  238. inputDone = evt
  239. case "response.output_item.done":
  240. if evt.Item != nil && evt.Item.Type == "custom_tool_call" {
  241. itemDone = evt
  242. }
  243. case "response.function_call_arguments.delta", "response.function_call_arguments.done":
  244. t.Fatalf("custom 工具调用不应产出 function_call 参数事件: %s", evt.Type)
  245. }
  246. }
  247. require.NotNil(t, added, "缺少 custom_tool_call 的 output_item.added")
  248. assert.Equal(t, "custom_tool_call", added.Item.Type)
  249. assert.Equal(t, "exec", added.Item.Name)
  250. require.NotNil(t, inputDone, "缺少 response.custom_tool_call_input.done")
  251. assert.Equal(t, "dir", inputDone.Input)
  252. assert.Equal(t, "call_1", inputDone.CallID)
  253. require.NotNil(t, itemDone, "缺少 custom_tool_call 的 output_item.done")
  254. assert.Equal(t, "call_1", itemDone.Item.CallID)
  255. assert.Equal(t, "exec", itemDone.Item.Name)
  256. assert.Equal(t, "dir", itemDone.Item.Input)
  257. assert.Empty(t, itemDone.Item.Arguments)
  258. // response.completed 的 output 数组同样携带 custom_tool_call 项。
  259. final := events[len(events)-1]
  260. require.Equal(t, "response.completed", final.Type)
  261. require.NotNil(t, final.Response)
  262. foundCustom := false
  263. for _, item := range final.Response.Output {
  264. if item.Type == "custom_tool_call" {
  265. foundCustom = true
  266. assert.Equal(t, "exec", item.Name)
  267. assert.Equal(t, "dir", item.Input)
  268. }
  269. }
  270. assert.True(t, foundCustom, "response.completed 缺少 custom_tool_call 输出项")
  271. }
  272. func TestChatCompletionsChunkToResponsesEvents_MixedCustomNamespaceAliasStream(t *testing.T) {
  273. state := NewChatCompletionsToResponsesStreamState("deepseek-test")
  274. state.CustomTools = map[string]bool{"exec": true}
  275. state.NamespaceTools = map[string]NamespacedToolName{
  276. "functions__wait": {Namespace: "functions", Name: "wait"},
  277. }
  278. idx := 0
  279. chunk := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ToolCalls: []ChatToolCall{{
  280. Index: &idx, ID: "call_alias", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `not-json`},
  281. }}}}}}
  282. events := ChatCompletionsChunkToResponsesEvents(chunk, state)
  283. events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
  284. for _, evt := range events {
  285. if evt.Type == "response.output_item.added" && evt.Item != nil && evt.Item.Type == "custom_tool_call" {
  286. assert.Equal(t, "exec", evt.Item.Name)
  287. }
  288. if evt.Type == "response.custom_tool_call_input.done" {
  289. assert.Equal(t, "exec", evt.Name)
  290. assert.Equal(t, "not-json", evt.Input)
  291. }
  292. if evt.Type == "response.output_item.done" && evt.Item != nil && evt.Item.Type == "custom_tool_call" {
  293. assert.Equal(t, "exec", evt.Item.Name)
  294. assert.Equal(t, "not-json", evt.Item.Input)
  295. }
  296. }
  297. final := events[len(events)-1]
  298. require.Equal(t, "response.completed", final.Type)
  299. require.Len(t, final.Response.Output, 1)
  300. assert.Equal(t, "custom_tool_call", final.Response.Output[0].Type)
  301. assert.Equal(t, "exec", final.Response.Output[0].Name)
  302. assert.Equal(t, "not-json", final.Response.Output[0].Input)
  303. }
  304. func TestChatCompletionsChunkToResponsesEvents_ExplicitFunctionOwnsCustomAliasCollision(t *testing.T) {
  305. state := NewChatCompletionsToResponsesStreamState("deepseek-test")
  306. state.CustomTools = map[string]bool{"exec": true}
  307. state.FunctionTools = map[string]bool{"functions__exec": true}
  308. state.NamespaceTools = map[string]NamespacedToolName{
  309. "functions__wait": {Namespace: "functions", Name: "wait"},
  310. }
  311. idx := 0
  312. chunk := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ToolCalls: []ChatToolCall{{
  313. Index: &idx, ID: "call_function", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `{"path":"/tmp"}`},
  314. }}}}}}
  315. events := ChatCompletionsChunkToResponsesEvents(chunk, state)
  316. events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
  317. for _, evt := range events {
  318. assert.NotEqual(t, "response.custom_tool_call_input.done", evt.Type)
  319. }
  320. final := events[len(events)-1]
  321. require.Equal(t, "response.completed", final.Type)
  322. require.Len(t, final.Response.Output, 1)
  323. assert.Equal(t, "function_call", final.Response.Output[0].Type)
  324. assert.Equal(t, "functions__exec", final.Response.Output[0].Name)
  325. assert.Equal(t, `{"path":"/tmp"}`, final.Response.Output[0].Arguments)
  326. }
  327. func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t *testing.T) {
  328. req := &ResponsesRequest{
  329. Model: "glm-5.2",
  330. Input: json.RawMessage(`"hi"`),
  331. Tools: []ResponsesTool{{Type: "tool_search"}},
  332. }
  333. out, err := ResponsesToChatCompletionsRequest(req)
  334. require.NoError(t, err)
  335. require.Len(t, out.Tools, 1)
  336. assert.Equal(t, "function", out.Tools[0].Type)
  337. assert.Equal(t, "tool_search", out.Tools[0].Function.Name)
  338. assert.Contains(t, string(out.Tools[0].Function.Parameters), `"query"`)
  339. }
  340. func TestResponsesToChatCompletionsRequest_DropsDeferredFlagWithToolSearch(t *testing.T) {
  341. var req ResponsesRequest
  342. require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":"hi","tools":[{"type":"tool_search"},{"type":"function","name":"shell","defer_loading":true}]}`), &req))
  343. out, err := ResponsesToChatCompletionsRequest(&req)
  344. require.NoError(t, err)
  345. encoded, err := json.Marshal(out)
  346. require.NoError(t, err)
  347. require.NotContains(t, string(encoded), "defer_loading")
  348. require.Contains(t, string(encoded), `"name":"tool_search"`)
  349. }
  350. // codex 只在 ResponseItem 为 tool_search_call 变体且 execution=client 时执行
  351. // tool search;同名 function_call 会命中 ToolSearchHandler 后因 payload 不匹配
  352. // 触发 FunctionCallError::Fatal,直接中止整个 turn,因此回程必须还原项类型。
  353. func TestChatCompletionsResponseToResponses_ToolSearchCallOutputItem(t *testing.T) {
  354. resp := &ChatCompletionsResponse{
  355. ID: "cc-1",
  356. Choices: []ChatChoice{{
  357. Message: ChatMessage{
  358. Role: "assistant",
  359. ToolCalls: []ChatToolCall{
  360. {ID: "call_s", Function: ChatFunctionCall{Name: "tool_search", Arguments: `{"query":"gmail","limit":2}`}},
  361. },
  362. },
  363. }},
  364. }
  365. out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, true, nil)
  366. require.Len(t, out.Output, 1)
  367. item := out.Output[0]
  368. assert.Equal(t, "tool_search_call", item.Type)
  369. assert.Equal(t, "call_s", item.CallID)
  370. // 线上形态:execution 必须为 "client"(codex 的必填字段,非 client 被忽略),
  371. // arguments 必须是 JSON 对象而非字符串(codex 按对象解析 query/limit)。
  372. b, err := json.Marshal(item)
  373. require.NoError(t, err)
  374. var m map[string]any
  375. require.NoError(t, json.Unmarshal(b, &m))
  376. assert.Equal(t, "client", m["execution"])
  377. args, ok := m["arguments"].(map[string]any)
  378. require.True(t, ok, "arguments 必须序列化为 JSON 对象")
  379. assert.Equal(t, "gmail", args["query"])
  380. }
  381. func TestChatCompletionsResponseToResponses_ToolSearchNotDeclaredKeepsFunctionCall(t *testing.T) {
  382. resp := &ChatCompletionsResponse{
  383. Choices: []ChatChoice{{
  384. Message: ChatMessage{
  385. Role: "assistant",
  386. ToolCalls: []ChatToolCall{
  387. {ID: "call_s", Function: ChatFunctionCall{Name: "tool_search", Arguments: `{"query":"gmail"}`}},
  388. },
  389. },
  390. }},
  391. }
  392. // 客户端未声明 type=tool_search 时,同名普通 function 工具不受影响。
  393. out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, false, nil)
  394. require.Len(t, out.Output, 1)
  395. assert.Equal(t, "function_call", out.Output[0].Type)
  396. }
  397. func TestChatCompletionsChunkToResponsesEvents_ToolSearchCallStream(t *testing.T) {
  398. state := NewChatCompletionsToResponsesStreamState("glm-5.2")
  399. state.ToolSearchDeclared = true
  400. idx := 0
  401. chunk := &ChatCompletionsChunk{
  402. ID: "cc-1",
  403. Choices: []ChatChunkChoice{{
  404. Delta: ChatDelta{
  405. ToolCalls: []ChatToolCall{{
  406. Index: &idx,
  407. ID: "call_s",
  408. Function: ChatFunctionCall{Name: "tool_search", Arguments: `{"query":"gmail"}`},
  409. }},
  410. },
  411. }},
  412. }
  413. events := ChatCompletionsChunkToResponsesEvents(chunk, state)
  414. events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
  415. var added, itemDone *ResponsesStreamEvent
  416. for i := range events {
  417. evt := &events[i]
  418. switch evt.Type {
  419. case "response.output_item.added":
  420. if evt.Item != nil && evt.Item.Type != "message" && evt.Item.Type != "reasoning" {
  421. added = evt
  422. }
  423. case "response.output_item.done":
  424. if evt.Item != nil && evt.Item.Type == "tool_search_call" {
  425. itemDone = evt
  426. }
  427. case "response.function_call_arguments.delta", "response.function_call_arguments.done",
  428. "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done":
  429. t.Fatalf("tool_search 调用不应产出 %s", evt.Type)
  430. }
  431. }
  432. require.NotNil(t, added, "缺少 tool_search_call 的 output_item.added")
  433. assert.Equal(t, "tool_search_call", added.Item.Type)
  434. require.NotNil(t, itemDone, "缺少 tool_search_call 的 output_item.done")
  435. assert.Equal(t, "call_s", itemDone.Item.CallID)
  436. // SSE 线上形态经 responsesItemWire 白名单重组,必须单独断言。
  437. sse, err := ResponsesEventToSSE(*itemDone)
  438. require.NoError(t, err)
  439. assert.Contains(t, sse, `"execution":"client"`)
  440. assert.Contains(t, sse, `"arguments":{"query":"gmail"}`)
  441. assert.Contains(t, sse, `"call_id":"call_s"`)
  442. // response.completed 的 output 数组同样携带 tool_search_call 项。
  443. final := events[len(events)-1]
  444. require.Equal(t, "response.completed", final.Type)
  445. require.NotNil(t, final.Response)
  446. found := false
  447. for _, item := range final.Response.Output {
  448. if item.Type == "tool_search_call" {
  449. found = true
  450. assert.Equal(t, "call_s", item.CallID)
  451. }
  452. }
  453. assert.True(t, found, "response.completed 缺少 tool_search_call 输出项")
  454. }
  455. func TestHasToolSearchTool(t *testing.T) {
  456. assert.True(t, HasToolSearchTool([]ResponsesTool{{Type: "tool_search"}}))
  457. assert.False(t, HasToolSearchTool([]ResponsesTool{{Type: "function", Name: "tool_search"}}))
  458. assert.False(t, HasToolSearchTool(nil))
  459. }
  460. func TestResponsesToChatCompletionsRequest_NamespaceToolFlattensChildren(t *testing.T) {
  461. req := &ResponsesRequest{
  462. Model: "glm-5.2",
  463. Input: json.RawMessage(`"hi"`),
  464. Tools: []ResponsesTool{{
  465. Type: "namespace",
  466. Name: "gmail",
  467. Tools: []ResponsesTool{
  468. {Type: "function", Name: "send", Description: "Send mail", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)},
  469. {Type: "custom", Name: "ignored_child"},
  470. },
  471. }},
  472. }
  473. out, err := ResponsesToChatCompletionsRequest(req)
  474. require.NoError(t, err)
  475. require.Len(t, out.Tools, 1, "namespace 子工具中仅 function 类型被摊平")
  476. assert.Equal(t, "gmail__send", out.Tools[0].Function.Name)
  477. assert.Equal(t, "Send mail", out.Tools[0].Function.Description)
  478. }
  479. func TestResponsesToolsParsing_StringToolBecomesCustom(t *testing.T) {
  480. var req ResponsesRequest
  481. require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":"hi","tools":["exec",{"type":"function","name":"wait"}]}`), &req))
  482. require.Len(t, req.Tools, 2)
  483. assert.Equal(t, "custom", req.Tools[0].Type)
  484. assert.Equal(t, "exec", req.Tools[0].Name)
  485. assert.Equal(t, "function", req.Tools[1].Type)
  486. assert.True(t, CustomToolNames(req.Tools)["exec"])
  487. }
  488. func TestFlattenNamespaceToolName_CapsAt64WithHashSuffix(t *testing.T) {
  489. assert.Equal(t, "gmail__send", flattenNamespaceToolName("gmail", "send"))
  490. long := flattenNamespaceToolName("very_long_namespace_prefix_for_testing_purposes", "and_a_rather_long_tool_name_too")
  491. assert.LessOrEqual(t, len(long), 64)
  492. assert.Contains(t, long, "__")
  493. // 同输入结果稳定
  494. assert.Equal(t, long, flattenNamespaceToolName("very_long_namespace_prefix_for_testing_purposes", "and_a_rather_long_tool_name_too"))
  495. }
  496. func TestResponsesInputToChatMessages_ToolSearchCallHistory(t *testing.T) {
  497. input := json.RawMessage(`[
  498. {"role":"user","content":"find tools"},
  499. {"type":"tool_search_call","call_id":"call_s","arguments":{"query":"gmail"}},
  500. {"type":"tool_search_output","call_id":"call_s","output":{"groups":["gmail"]}}
  501. ]`)
  502. messages, err := responsesInputToChatMessages("", input)
  503. require.NoError(t, err)
  504. require.Len(t, messages, 3)
  505. require.Len(t, messages[1].ToolCalls, 1)
  506. assert.Equal(t, "tool_search", messages[1].ToolCalls[0].Function.Name)
  507. assert.JSONEq(t, `{"query":"gmail"}`, messages[1].ToolCalls[0].Function.Arguments)
  508. assert.Equal(t, "tool", messages[2].Role)
  509. assert.Equal(t, "call_s", messages[2].ToolCallID)
  510. assert.JSONEq(t, `"{\"groups\":[\"gmail\"]}"`, string(messages[2].Content))
  511. }
  512. func TestResponsesToChatCompletionsRequest_PromotesCompletedToolSearchDiscoveries(t *testing.T) {
  513. var req ResponsesRequest
  514. require.NoError(t, json.Unmarshal([]byte(`{
  515. "model":"gpt-5","tools":[
  516. {"type":"tool_search"},
  517. {"type":"function","name":"inspect","parameters":{"type":"object"}}
  518. ],"input":[
  519. {"type":"tool_search_call","call_id":"search_1","arguments":{"query":"workspace"}},
  520. {"type":"tool_search_output","call_id":"search_1","status":"completed","execution":"client","tools":[
  521. {"type":"function","name":"inspect","parameters":{"type":"object"}},
  522. {"type":"custom","name":"exec"},
  523. {"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]}
  524. ]}
  525. ]}`), &req))
  526. effective, err := EffectiveResponsesTools(&req)
  527. require.NoError(t, err)
  528. require.Len(t, effective, 4, "the identical static discovery must be deduplicated")
  529. assert.True(t, CustomToolNames(effective)["exec"])
  530. namespaces := NamespaceToolNames(effective)
  531. assert.Equal(t, NamespacedToolName{Namespace: "collaboration", Name: "spawn_agent"}, namespaces["collaboration__spawn_agent"])
  532. chatReq, err := ResponsesToChatCompletionsRequest(&req)
  533. require.NoError(t, err)
  534. require.Len(t, chatReq.Tools, 4)
  535. assert.Equal(t, []string{"tool_search", "inspect", "exec", "collaboration__spawn_agent"}, []string{
  536. chatReq.Tools[0].Function.Name, chatReq.Tools[1].Function.Name,
  537. chatReq.Tools[2].Function.Name, chatReq.Tools[3].Function.Name,
  538. })
  539. require.Len(t, chatReq.Messages, 2)
  540. assert.Equal(t, "tool", chatReq.Messages[1].Role)
  541. var discoveryPayload []map[string]any
  542. var serialized string
  543. require.NoError(t, json.Unmarshal(chatReq.Messages[1].Content, &serialized))
  544. require.NoError(t, json.Unmarshal([]byte(serialized), &discoveryPayload))
  545. require.Len(t, discoveryPayload, 3)
  546. responses := ChatCompletionsResponseToResponses(&ChatCompletionsResponse{Choices: []ChatChoice{{
  547. Message: ChatMessage{Role: "assistant", ToolCalls: []ChatToolCall{
  548. {ID: "call_1", Type: "function", Function: ChatFunctionCall{Name: "collaboration__spawn_agent", Arguments: `{}`}},
  549. {ID: "call_2", Type: "function", Function: ChatFunctionCall{Name: "exec", Arguments: `{"input":"pwd"}`}},
  550. }},
  551. }}}, req.Model, CustomToolNames(effective), FunctionToolNames(effective), HasToolSearchTool(effective), namespaces)
  552. require.Len(t, responses.Output, 2)
  553. assert.Equal(t, "function_call", responses.Output[0].Type)
  554. assert.Equal(t, "spawn_agent", responses.Output[0].Name)
  555. assert.Equal(t, "collaboration", responses.Output[0].Namespace)
  556. assert.Equal(t, "custom_tool_call", responses.Output[1].Type)
  557. assert.Equal(t, "exec", responses.Output[1].Name)
  558. assert.Equal(t, "pwd", responses.Output[1].Input)
  559. }
  560. func TestEffectiveResponsesTools_RejectsDiscoveredConflict(t *testing.T) {
  561. var req ResponsesRequest
  562. require.NoError(t, json.Unmarshal([]byte(`{"tools":[{"type":"tool_search"},{"type":"function","name":"inspect","parameters":{"type":"object"}}],"input":[{"type":"tool_search_output","status":"completed","tools":[{"type":"function","name":"inspect","parameters":{"type":"string"}}]}]}`), &req))
  563. _, err := EffectiveResponsesTools(&req)
  564. require.ErrorContains(t, err, "conflicts")
  565. }
  566. func TestResponsesInputToChatMessages_NamespacedFunctionCallHistory(t *testing.T) {
  567. input := json.RawMessage(`[
  568. {"type":"function_call","call_id":"call_n","name":"send","namespace":"gmail","arguments":"{\"to\":\"a\"}"},
  569. {"type":"function_call_output","call_id":"call_n","output":"ok"}
  570. ]`)
  571. messages, err := responsesInputToChatMessages("", input)
  572. require.NoError(t, err)
  573. require.Len(t, messages, 2)
  574. require.Len(t, messages[0].ToolCalls, 1)
  575. assert.Equal(t, "gmail__send", messages[0].ToolCalls[0].Function.Name)
  576. }
  577. func TestChatCompletionsChunkToResponsesEvents_CustomToolNameArrivesLate(t *testing.T) {
  578. state := NewChatCompletionsToResponsesStreamState("glm-5.2")
  579. state.CustomTools = map[string]bool{"exec": true}
  580. idx := 0
  581. chunk1 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
  582. ToolCalls: []ChatToolCall{{Index: &idx, ID: "call_1", Function: ChatFunctionCall{Arguments: `{"inp`}}},
  583. }}}}
  584. chunk2 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
  585. ToolCalls: []ChatToolCall{{Index: &idx, Function: ChatFunctionCall{Name: "exec", Arguments: `ut": "dir"}`}}},
  586. }}}}
  587. var events []ResponsesStreamEvent
  588. events = append(events, ChatCompletionsChunkToResponsesEvents(chunk1, state)...)
  589. events = append(events, ChatCompletionsChunkToResponsesEvents(chunk2, state)...)
  590. events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
  591. addedCount := 0
  592. for _, evt := range events {
  593. switch evt.Type {
  594. case "response.output_item.added":
  595. if evt.Item != nil && evt.Item.Type != "reasoning" && evt.Item.Type != "message" {
  596. addedCount++
  597. assert.Equal(t, "custom_tool_call", evt.Item.Type, "迟到的名字命中 custom 工具时按 custom_tool_call 宣告")
  598. assert.Equal(t, "exec", evt.Item.Name)
  599. }
  600. case "response.function_call_arguments.delta", "response.function_call_arguments.done":
  601. t.Fatalf("custom 调用不应产出 function 参数事件: %s", evt.Type)
  602. case "response.custom_tool_call_input.done":
  603. assert.Equal(t, "dir", evt.Input)
  604. }
  605. }
  606. assert.Equal(t, 1, addedCount, "工具调用只宣告一次")
  607. }
  608. func TestChatCompletionsChunkToResponsesEvents_FunctionToolNameArrivesLate(t *testing.T) {
  609. state := NewChatCompletionsToResponsesStreamState("glm-5.2")
  610. state.CustomTools = map[string]bool{"exec": true}
  611. idx := 0
  612. chunk1 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
  613. ToolCalls: []ChatToolCall{{Index: &idx, ID: "call_9", Function: ChatFunctionCall{Arguments: `{"cell`}}},
  614. }}}}
  615. chunk2 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
  616. ToolCalls: []ChatToolCall{{Index: &idx, Function: ChatFunctionCall{Name: "wait", Arguments: `_id": 3}`}}},
  617. }}}}
  618. var events []ResponsesStreamEvent
  619. events = append(events, ChatCompletionsChunkToResponsesEvents(chunk1, state)...)
  620. events = append(events, ChatCompletionsChunkToResponsesEvents(chunk2, state)...)
  621. events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
  622. deltas := ""
  623. argsDone := ""
  624. for _, evt := range events {
  625. switch evt.Type {
  626. case "response.function_call_arguments.delta":
  627. deltas += evt.Delta
  628. case "response.function_call_arguments.done":
  629. argsDone = evt.Arguments
  630. case "response.custom_tool_call_input.done":
  631. t.Fatal("function 调用不应产出 custom 事件")
  632. }
  633. }
  634. assert.Equal(t, `{"cell_id": 3}`, deltas, "宣告前累积的参数需在宣告时补发")
  635. assert.Equal(t, `{"cell_id": 3}`, argsDone)
  636. }
  637. // 序列化层(MarshalJSON → responsesItemWire)单独走白名单重组,事件结构体上的字段
  638. // 齐全不代表落到 SSE 线上的 JSON 齐全,必须在 wire 层再断言一次。
  639. func TestResponsesEventToSSE_CustomToolCallItemCarriesAllFields(t *testing.T) {
  640. evt := ResponsesStreamEvent{
  641. Type: "response.output_item.done",
  642. OutputIndex: 1,
  643. Item: &ResponsesOutput{
  644. Type: "custom_tool_call",
  645. ID: "item_1",
  646. CallID: "call_1",
  647. Name: "exec",
  648. Input: "dir",
  649. Status: "completed",
  650. },
  651. }
  652. sse, err := ResponsesEventToSSE(evt)
  653. require.NoError(t, err)
  654. assert.Contains(t, sse, `"call_id":"call_1"`)
  655. assert.Contains(t, sse, `"name":"exec"`)
  656. assert.Contains(t, sse, `"input":"dir"`)
  657. assert.Contains(t, sse, `"type":"custom_tool_call"`)
  658. }
  659. func TestNamespaceToolNames_MapsFlattenedNames(t *testing.T) {
  660. tools := []ResponsesTool{
  661. {Type: "namespace", Name: "gmail", Tools: []ResponsesTool{
  662. {Type: "function", Name: "send"},
  663. {Type: "custom", Name: "skip_me"},
  664. }},
  665. {Type: "namespace", Name: "crm", Children: []ResponsesTool{
  666. {Type: "function", Name: "query"},
  667. }},
  668. {Type: "function", Name: "wait"},
  669. }
  670. m := NamespaceToolNames(tools)
  671. require.Len(t, m, 2)
  672. assert.Equal(t, NamespacedToolName{Namespace: "gmail", Name: "send"}, m["gmail__send"])
  673. assert.Equal(t, NamespacedToolName{Namespace: "crm", Name: "query"}, m["crm__query"])
  674. // 摊平名超长时截断加哈希,无法按字符串切分还原,必须经映射反查。
  675. longNS := "very_long_namespace_prefix_for_testing_purposes"
  676. longChild := "and_a_rather_long_tool_name_too"
  677. m2 := NamespaceToolNames([]ResponsesTool{{
  678. Type: "namespace", Name: longNS,
  679. Tools: []ResponsesTool{{Type: "function", Name: longChild}},
  680. }})
  681. assert.Equal(t, NamespacedToolName{Namespace: longNS, Name: longChild},
  682. m2[flattenNamespaceToolName(longNS, longChild)])
  683. assert.Nil(t, NamespaceToolNames(nil))
  684. }
  685. // 内置 tool_search 降级后的代理 function 与客户端声明的同名工具无法区分:回程会把
  686. // 普通工具的调用劫持成 tool_search_call,必须显式拒绝(代理不能改名,codex 的模型
  687. // 侧按 tool_search 这个名字调用)。
  688. func TestResponsesToChatCompletionsRequest_RejectsToolSearchNameConflict(t *testing.T) {
  689. // 与顶层 function 工具同名。
  690. _, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
  691. Model: "glm-5.2",
  692. Input: json.RawMessage(`"hi"`),
  693. Tools: []ResponsesTool{
  694. {Type: "tool_search"},
  695. {Type: "function", Name: "tool_search"},
  696. },
  697. })
  698. require.Error(t, err, "与内置 tool_search 代理撞名的 function 工具必须拒绝")
  699. assert.Contains(t, err.Error(), "tool_search")
  700. // 与顶层 custom 工具同名。
  701. _, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
  702. Model: "glm-5.2",
  703. Input: json.RawMessage(`"hi"`),
  704. Tools: []ResponsesTool{
  705. {Type: "custom", Name: "tool_search"},
  706. {Type: "tool_search"},
  707. },
  708. })
  709. require.Error(t, err, "与内置 tool_search 代理撞名的 custom 工具必须拒绝")
  710. // 重复声明 type=tool_search 去重后只产出一个代理,不拒绝。
  711. out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
  712. Model: "glm-5.2",
  713. Input: json.RawMessage(`"hi"`),
  714. Tools: []ResponsesTool{{Type: "tool_search"}, {Type: "tool_search"}},
  715. })
  716. require.NoError(t, err)
  717. require.Len(t, out.Tools, 1)
  718. assert.Equal(t, "tool_search", out.Tools[0].Function.Name)
  719. }
  720. func TestResponsesToChatCompletionsRequest_RejectsDuplicateTopLevelExecutableNames(t *testing.T) {
  721. for _, tools := range [][]ResponsesTool{
  722. {{Type: "custom", Name: "exec"}, {Type: "function", Name: "exec"}},
  723. {{Type: "function", Name: "exec"}, {Type: "function", Name: "exec"}},
  724. {{Type: "custom", Name: "exec"}, {Type: "custom", Name: "exec"}},
  725. } {
  726. _, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
  727. Model: "glm-5.2",
  728. Input: json.RawMessage(`"hi"`),
  729. Tools: tools,
  730. })
  731. require.Error(t, err)
  732. assert.Contains(t, err.Error(), "exec")
  733. assert.Contains(t, err.Error(), "cannot disambiguate")
  734. }
  735. }
  736. // tool_choice 指向被转换丢弃的工具(如 web_search)或不存在的名字时不能原样转发,
  737. // chat 上游会因选择项指向未声明工具而 400;字符串形式与指向幸存工具的选择保持转发。
  738. func TestResponsesToChatCompletionsRequest_DropsToolChoiceForDroppedTool(t *testing.T) {
  739. // 强制选择被丢弃的 web_search:工具没了,选择项也必须丢。
  740. out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
  741. Model: "glm-5.2",
  742. Input: json.RawMessage(`"hi"`),
  743. Tools: []ResponsesTool{
  744. {Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)},
  745. {Type: "web_search"},
  746. },
  747. ToolChoice: json.RawMessage(`{"type":"web_search"}`),
  748. })
  749. require.NoError(t, err)
  750. require.Len(t, out.Tools, 1)
  751. assert.Empty(t, out.ToolChoice, "指向被丢弃服务端工具的 tool_choice 必须丢弃")
  752. out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
  753. Model: "glm-5.2",
  754. Input: json.RawMessage(`"hi"`),
  755. Tools: []ResponsesTool{
  756. {Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)},
  757. {Type: "web_search"},
  758. {Type: "x_search"},
  759. },
  760. ToolChoice: json.RawMessage(`{"type":"function","name":"web_search"}`),
  761. })
  762. require.NoError(t, err)
  763. require.Len(t, out.Tools, 2)
  764. assert.Empty(t, out.ToolChoice, "surviving x_search must not keep a function tool_choice named web_search")
  765. // 具名选择指向不存在的工具名。
  766. out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
  767. Model: "glm-5.2",
  768. Input: json.RawMessage(`"hi"`),
  769. Tools: []ResponsesTool{{Type: "function", Name: "wait"}},
  770. ToolChoice: json.RawMessage(`{"type":"function","name":"missing"}`),
  771. })
  772. require.NoError(t, err)
  773. assert.Empty(t, out.ToolChoice, "指向不存在工具名的 tool_choice 必须丢弃")
  774. // 字符串形式与指向幸存工具的选择保持原有转发行为。
  775. out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
  776. Model: "glm-5.2",
  777. Input: json.RawMessage(`"hi"`),
  778. Tools: []ResponsesTool{{Type: "function", Name: "wait"}},
  779. ToolChoice: json.RawMessage(`"auto"`),
  780. })
  781. require.NoError(t, err)
  782. assert.JSONEq(t, `"auto"`, string(out.ToolChoice))
  783. out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
  784. Model: "glm-5.2",
  785. Input: json.RawMessage(`"hi"`),
  786. Tools: []ResponsesTool{{Type: "function", Name: "wait"}},
  787. ToolChoice: json.RawMessage(`{"type":"function","name":"wait"}`),
  788. })
  789. require.NoError(t, err)
  790. assert.JSONEq(t, `{"type":"function","function":{"name":"wait"}}`, string(out.ToolChoice))
  791. }
  792. // tool_search 工具没有被丢弃而是降级为同名 function 代理,强制选择它的 tool_choice
  793. // 必须同步降级为指向代理的 function 选择,不能静默丢弃(丢弃会把强制搜索退化为
  794. // 自动选择,模型可以不执行搜索)。
  795. func TestResponsesToChatCompletionsRequest_ToolSearchToolChoiceMapsToProxy(t *testing.T) {
  796. out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
  797. Model: "glm-5.2",
  798. Input: json.RawMessage(`"hi"`),
  799. Tools: []ResponsesTool{{Type: "tool_search"}},
  800. ToolChoice: json.RawMessage(`{"type":"tool_search"}`),
  801. })
  802. require.NoError(t, err)
  803. assert.JSONEq(t, `{"type":"function","function":{"name":"tool_search"}}`, string(out.ToolChoice))
  804. // 未声明 type=tool_search 时强制选择它没有可指向的代理,丢弃选择项。
  805. out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
  806. Model: "glm-5.2",
  807. Input: json.RawMessage(`"hi"`),
  808. Tools: []ResponsesTool{{Type: "function", Name: "wait"}},
  809. ToolChoice: json.RawMessage(`{"type":"tool_search"}`),
  810. })
  811. require.NoError(t, err)
  812. assert.Empty(t, out.ToolChoice)
  813. }
  814. // 客户端请求在原生 Responses API 上合法(namespace 子工具按 namespace+name 路由),
  815. // 是摊平转换让名字产生歧义;歧义无法消除时必须显式拒绝整个请求(400),而不是
  816. // 静默降级——否则重复声明发给上游、回程还原到错误工具,问题只能靠抓包定位。
  817. func TestResponsesToChatCompletionsRequest_RejectsAmbiguousFlattenedNames(t *testing.T) {
  818. // 摊平名与顶层 function 工具撞名。
  819. _, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
  820. Model: "glm-5.2",
  821. Input: json.RawMessage(`"hi"`),
  822. Tools: []ResponsesTool{
  823. {Type: "function", Name: "gmail__send"},
  824. {Type: "namespace", Name: "gmail", Tools: []ResponsesTool{{Type: "function", Name: "send"}}},
  825. },
  826. })
  827. require.Error(t, err, "与顶层工具撞名的摊平必须拒绝")
  828. assert.Contains(t, err.Error(), "gmail__send")
  829. // 不同 namespace 组合产生相同摊平名。
  830. _, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{
  831. Model: "glm-5.2",
  832. Input: json.RawMessage(`"hi"`),
  833. Tools: []ResponsesTool{
  834. {Type: "namespace", Name: "a", Tools: []ResponsesTool{{Type: "function", Name: "b__c"}}},
  835. {Type: "namespace", Name: "a__b", Tools: []ResponsesTool{{Type: "function", Name: "c"}}},
  836. },
  837. })
  838. require.Error(t, err, "跨 namespace 撞名的摊平必须拒绝")
  839. assert.Contains(t, err.Error(), "a__b__c")
  840. }
  841. // 完全相同的 (namespace, 子工具) 重复声明不构成歧义:去重后正常转换,不拒绝。
  842. func TestResponsesToChatCompletionsRequest_DedupesIdenticalNamespaceChildren(t *testing.T) {
  843. out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{
  844. Model: "glm-5.2",
  845. Input: json.RawMessage(`"hi"`),
  846. Tools: []ResponsesTool{
  847. {Type: "namespace", Name: "gmail", Tools: []ResponsesTool{
  848. {Type: "function", Name: "send"},
  849. {Type: "function", Name: "send"},
  850. }},
  851. },
  852. })
  853. require.NoError(t, err)
  854. require.Len(t, out.Tools, 1, "重复声明的同一子工具只声明一次")
  855. assert.Equal(t, "gmail__send", out.Tools[0].Function.Name)
  856. }
  857. // codex 按 namespace+name 路由 namespace 子工具的调用:回程必须把摊平名还原为
  858. // 裸子工具名并带独立 namespace 字段,平铺名的 function_call 会被 codex 判为
  859. // unsupported call 拒绝执行。
  860. func TestChatCompletionsResponseToResponses_NamespacedToolCallRestored(t *testing.T) {
  861. resp := &ChatCompletionsResponse{
  862. ID: "cc-1",
  863. Choices: []ChatChoice{{
  864. Message: ChatMessage{
  865. Role: "assistant",
  866. ToolCalls: []ChatToolCall{
  867. {ID: "call_n", Function: ChatFunctionCall{Name: "mcp__svc__echo", Arguments: `{"text":"hi"}`}},
  868. {ID: "call_9", Function: ChatFunctionCall{Name: "wait", Arguments: `{"cell_id": 3}`}},
  869. },
  870. },
  871. }},
  872. }
  873. nsTools := map[string]NamespacedToolName{
  874. "mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"},
  875. }
  876. out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, false, nsTools)
  877. require.Len(t, out.Output, 2)
  878. item := out.Output[0]
  879. assert.Equal(t, "function_call", item.Type)
  880. assert.Equal(t, "echo", item.Name)
  881. assert.Equal(t, "mcp__svc", item.Namespace)
  882. assert.Equal(t, "call_n", item.CallID)
  883. assert.Equal(t, `{"text":"hi"}`, item.Arguments)
  884. // 非流式响应体走 ResponsesOutput.MarshalJSON,namespace 必须落到线上 JSON。
  885. b, err := json.Marshal(item)
  886. require.NoError(t, err)
  887. assert.Contains(t, string(b), `"namespace":"mcp__svc"`)
  888. assert.Contains(t, string(b), `"name":"echo"`)
  889. // 未命中映射的普通 function 调用不受影响,且不携带 namespace 字段。
  890. assert.Equal(t, "wait", out.Output[1].Name)
  891. assert.Empty(t, out.Output[1].Namespace)
  892. b2, err := json.Marshal(out.Output[1])
  893. require.NoError(t, err)
  894. assert.NotContains(t, string(b2), `"namespace"`)
  895. }
  896. func TestChatCompletionsChunkToResponsesEvents_NamespacedToolCallStream(t *testing.T) {
  897. state := NewChatCompletionsToResponsesStreamState("glm-5.2")
  898. state.NamespaceTools = map[string]NamespacedToolName{
  899. "mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"},
  900. }
  901. idx := 0
  902. chunk := &ChatCompletionsChunk{
  903. ID: "cc-1",
  904. Choices: []ChatChunkChoice{{
  905. Delta: ChatDelta{
  906. ToolCalls: []ChatToolCall{{
  907. Index: &idx,
  908. ID: "call_n",
  909. Function: ChatFunctionCall{Name: "mcp__svc__echo", Arguments: `{"text":"hi"}`},
  910. }},
  911. },
  912. }},
  913. }
  914. events := ChatCompletionsChunkToResponsesEvents(chunk, state)
  915. events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
  916. var added, itemDone *ResponsesStreamEvent
  917. for i := range events {
  918. evt := &events[i]
  919. switch evt.Type {
  920. case "response.output_item.added":
  921. if evt.Item != nil && evt.Item.Type != "message" && evt.Item.Type != "reasoning" {
  922. added = evt
  923. }
  924. case "response.output_item.done":
  925. if evt.Item != nil && evt.Item.Type == "function_call" {
  926. itemDone = evt
  927. }
  928. case "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done":
  929. t.Fatalf("namespace 子工具调用不应产出 custom 事件: %s", evt.Type)
  930. }
  931. }
  932. require.NotNil(t, added, "缺少 namespace 调用的 output_item.added")
  933. assert.Equal(t, "function_call", added.Item.Type)
  934. assert.Equal(t, "echo", added.Item.Name)
  935. assert.Equal(t, "mcp__svc", added.Item.Namespace)
  936. require.NotNil(t, itemDone, "缺少 namespace 调用的 output_item.done")
  937. assert.Equal(t, "call_n", itemDone.Item.CallID)
  938. assert.Equal(t, "echo", itemDone.Item.Name)
  939. assert.Equal(t, "mcp__svc", itemDone.Item.Namespace)
  940. assert.Equal(t, `{"text":"hi"}`, itemDone.Item.Arguments)
  941. // SSE 线上形态经 responsesItemWire 白名单重组,必须单独断言 namespace 落线。
  942. sse, err := ResponsesEventToSSE(*itemDone)
  943. require.NoError(t, err)
  944. assert.Contains(t, sse, `"namespace":"mcp__svc"`)
  945. assert.Contains(t, sse, `"name":"echo"`)
  946. assert.Contains(t, sse, `"call_id":"call_n"`)
  947. // response.completed 的 output 数组同样携带还原后的 namespace 调用项。
  948. final := events[len(events)-1]
  949. require.Equal(t, "response.completed", final.Type)
  950. require.NotNil(t, final.Response)
  951. found := false
  952. for _, item := range final.Response.Output {
  953. if item.Type == "function_call" {
  954. found = true
  955. assert.Equal(t, "echo", item.Name)
  956. assert.Equal(t, "mcp__svc", item.Namespace)
  957. }
  958. }
  959. assert.True(t, found, "response.completed 缺少还原后的 namespace 调用项")
  960. }
  961. func TestChatCompletionsChunkToResponsesEvents_NamespacedToolNameArrivesLate(t *testing.T) {
  962. state := NewChatCompletionsToResponsesStreamState("glm-5.2")
  963. state.NamespaceTools = map[string]NamespacedToolName{
  964. "mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"},
  965. }
  966. idx := 0
  967. chunk1 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
  968. ToolCalls: []ChatToolCall{{Index: &idx, ID: "call_n", Function: ChatFunctionCall{Arguments: `{"te`}}},
  969. }}}}
  970. chunk2 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
  971. ToolCalls: []ChatToolCall{{Index: &idx, Function: ChatFunctionCall{Name: "mcp__svc__echo", Arguments: `xt":"hi"}`}}},
  972. }}}}
  973. var events []ResponsesStreamEvent
  974. events = append(events, ChatCompletionsChunkToResponsesEvents(chunk1, state)...)
  975. events = append(events, ChatCompletionsChunkToResponsesEvents(chunk2, state)...)
  976. events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
  977. addedCount := 0
  978. deltas := ""
  979. for _, evt := range events {
  980. switch evt.Type {
  981. case "response.output_item.added":
  982. if evt.Item != nil && evt.Item.Type != "reasoning" && evt.Item.Type != "message" {
  983. addedCount++
  984. assert.Equal(t, "echo", evt.Item.Name, "迟到的名字命中 namespace 映射时按还原名宣告")
  985. assert.Equal(t, "mcp__svc", evt.Item.Namespace)
  986. }
  987. case "response.function_call_arguments.delta":
  988. deltas += evt.Delta
  989. }
  990. }
  991. assert.Equal(t, 1, addedCount, "工具调用只宣告一次")
  992. assert.Equal(t, `{"text":"hi"}`, deltas, "宣告前累积的参数需在宣告时补发")
  993. }
  994. func TestChatCompletionsChunkToResponsesEvents_FunctionToolStreamUnaffected(t *testing.T) {
  995. state := NewChatCompletionsToResponsesStreamState("glm-5.2")
  996. state.CustomTools = map[string]bool{"exec": true}
  997. idx := 0
  998. chunk := &ChatCompletionsChunk{
  999. Choices: []ChatChunkChoice{{
  1000. Delta: ChatDelta{
  1001. ToolCalls: []ChatToolCall{{
  1002. Index: &idx,
  1003. ID: "call_9",
  1004. Function: ChatFunctionCall{Name: "wait", Arguments: `{"cell_id": 3}`},
  1005. }},
  1006. },
  1007. }},
  1008. }
  1009. events := ChatCompletionsChunkToResponsesEvents(chunk, state)
  1010. events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
  1011. sawArgsDelta := false
  1012. for _, evt := range events {
  1013. if evt.Type == "response.function_call_arguments.delta" {
  1014. sawArgsDelta = true
  1015. }
  1016. if evt.Type == "response.custom_tool_call_input.done" {
  1017. t.Fatal("function 工具不应产出 custom_tool_call 事件")
  1018. }
  1019. }
  1020. assert.True(t, sawArgsDelta, "function 工具应保持原有参数增量事件")
  1021. }