openai_codex_tool_names_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. package service
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/gin-gonic/gin"
  6. "github.com/stretchr/testify/require"
  7. "github.com/tidwall/gjson"
  8. )
  9. func TestAliasOpenAIOAuthReservedToolNames_RewritesDeclarationsAndReferences(t *testing.T) {
  10. reqBody := map[string]any{
  11. "tools": []any{
  12. map[string]any{"type": "function", "name": "python"},
  13. map[string]any{"type": "namespace", "name": "code", "tools": []any{
  14. map[string]any{"type": "function", "name": "shell"},
  15. }},
  16. },
  17. "tool_choice": map[string]any{"type": "function", "name": "python"},
  18. "input": []any{
  19. map[string]any{"type": "function_call", "name": "python", "call_id": "fc_1"},
  20. map[string]any{"type": "additional_tools", "tools": []any{
  21. map[string]any{"type": "function", "function": map[string]any{"name": "python"}},
  22. }},
  23. },
  24. }
  25. reverse, changed, err := aliasOpenAIOAuthReservedToolNames(reqBody)
  26. require.NoError(t, err)
  27. require.True(t, changed)
  28. require.Equal(t, "python", reverse[codexPythonToolAlias])
  29. tools, ok := reqBody["tools"].([]any)
  30. require.True(t, ok)
  31. require.NotEmpty(t, tools)
  32. firstTool, ok := tools[0].(map[string]any)
  33. require.True(t, ok)
  34. require.Equal(t, codexPythonToolAlias, firstTool["name"])
  35. toolChoice, ok := reqBody["tool_choice"].(map[string]any)
  36. require.True(t, ok)
  37. require.Equal(t, codexPythonToolAlias, toolChoice["name"])
  38. input, ok := reqBody["input"].([]any)
  39. require.True(t, ok)
  40. require.NotEmpty(t, input)
  41. firstInput, ok := input[0].(map[string]any)
  42. require.True(t, ok)
  43. require.Equal(t, codexPythonToolAlias, firstInput["name"])
  44. secondInput, ok := input[1].(map[string]any)
  45. require.True(t, ok)
  46. nestedTools, ok := secondInput["tools"].([]any)
  47. require.True(t, ok)
  48. require.NotEmpty(t, nestedTools)
  49. nestedTool, ok := nestedTools[0].(map[string]any)
  50. require.True(t, ok)
  51. nestedFn, ok := nestedTool["function"].(map[string]any)
  52. require.True(t, ok)
  53. require.Equal(t, codexPythonToolAlias, nestedFn["name"])
  54. }
  55. func TestAliasOpenAIOAuthReservedToolNames_CollisionDoesNotMutate(t *testing.T) {
  56. reqBody := map[string]any{"tools": []any{
  57. map[string]any{"type": "function", "name": "python"},
  58. map[string]any{"type": "function", "name": codexPythonToolAlias},
  59. }}
  60. before, err := json.Marshal(reqBody)
  61. require.NoError(t, err)
  62. reverse, changed, err := aliasOpenAIOAuthReservedToolNames(reqBody)
  63. require.ErrorContains(t, err, `both normalize to "python__sub2api"`)
  64. require.False(t, changed)
  65. require.Nil(t, reverse)
  66. after, marshalErr := json.Marshal(reqBody)
  67. require.NoError(t, marshalErr)
  68. require.JSONEq(t, string(before), string(after))
  69. }
  70. func TestApplyCodexOAuthTransform_ReservedPythonNameIsOAuthOnly(t *testing.T) {
  71. reqBody := map[string]any{
  72. "model": "gpt-5.5",
  73. "tools": []any{map[string]any{"type": "function", "name": "PYTHON"}},
  74. }
  75. result := applyCodexOAuthTransform(reqBody, true, false)
  76. require.NoError(t, result.Error)
  77. require.Equal(t, "PYTHON", result.ToolNameReverse[codexPythonToolAlias])
  78. tools, ok := reqBody["tools"].([]any)
  79. require.True(t, ok)
  80. require.NotEmpty(t, tools)
  81. tool, ok := tools[0].(map[string]any)
  82. require.True(t, ok)
  83. require.Equal(t, codexPythonToolAlias, tool["name"])
  84. apiKeyBody := []byte(`{"type":"response.create","tools":[{"type":"function","name":"python"}]}`)
  85. normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(apiKeyBody, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false)
  86. require.NoError(t, err)
  87. require.False(t, changed)
  88. require.JSONEq(t, string(apiKeyBody), string(normalized))
  89. }
  90. func TestRestoreCodexToolNamesFromContext_HTTPAndWSPayloadShapes(t *testing.T) {
  91. c, _ := gin.CreateTestContext(nil)
  92. setCodexToolNameReverse(c, map[string]string{codexPythonToolAlias: "python"})
  93. streamEvent := restoreCodexToolNamesFromContext(c, []byte(
  94. `{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"},"note":"python__sub2api"}`,
  95. ))
  96. require.Equal(t, "python", gjson.GetBytes(streamEvent, "item.name").String())
  97. require.Equal(t, "python__sub2api", gjson.GetBytes(streamEvent, "note").String())
  98. nonStreaming := restoreCodexToolNamesFromContext(c, []byte(
  99. `{"id":"resp_1","output":[{"type":"function_call","name":"python__sub2api"}]}`,
  100. ))
  101. require.Equal(t, "python", gjson.GetBytes(nonStreaming, "output.0.name").String())
  102. setCodexToolNameReverse(c, nil)
  103. require.JSONEq(t,
  104. `{"type":"response.output_item.added","item":{"name":"python__sub2api"}}`,
  105. string(restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.added","item":{"name":"python__sub2api"}}`))),
  106. )
  107. }
  108. func TestAliasOpenAIOAuthReservedToolNames_SessionUpdateOnlyTouchesFunctionProtocolNodes(t *testing.T) {
  109. body := []byte(`{"type":"session.update","session":{"tools":[{"type":"function","name":"python"},{"type":"image_generation","name":"python"},{"type":"namespace","name":"python","tools":[{"type":"function","name":"shell"}]}]},"metadata":{"name":"python"},"sequence":900719925474099312345}`)
  110. aliased, reverse, changed, err := aliasOpenAIOAuthReservedToolNamesBody(body)
  111. require.NoError(t, err)
  112. require.True(t, changed)
  113. require.Equal(t, "python", reverse[codexPythonToolAlias])
  114. require.Equal(t, codexPythonToolAlias, gjson.GetBytes(aliased, "session.tools.0.name").String())
  115. require.Equal(t, "python", gjson.GetBytes(aliased, "session.tools.1.name").String())
  116. require.Equal(t, "python", gjson.GetBytes(aliased, "session.tools.2.name").String())
  117. require.Equal(t, "shell", gjson.GetBytes(aliased, "session.tools.2.tools.0.name").String())
  118. require.Equal(t, "python", gjson.GetBytes(aliased, "metadata.name").String())
  119. require.Equal(t, "900719925474099312345", gjson.GetBytes(aliased, "sequence").Raw)
  120. }
  121. func TestRestoreCodexToolNamesInJSON_OnlyTouchesResponseToolCallNodesAndPreservesNumbers(t *testing.T) {
  122. reverse := map[string]string{codexPythonToolAlias: "python"}
  123. body := []byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","name":"python__sub2api"},{"type":"message","name":"python__sub2api","content":[]}]},"metadata":{"name":"python__sub2api"},"sequence":900719925474099312345}`)
  124. restored := restoreCodexToolNamesInJSON(body, reverse)
  125. require.Equal(t, "python", gjson.GetBytes(restored, "response.output.0.name").String())
  126. require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "response.output.1.name").String())
  127. require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "metadata.name").String())
  128. require.Equal(t, "900719925474099312345", gjson.GetBytes(restored, "sequence").Raw)
  129. }
  130. func TestRestoreCodexToolNamesInJSON_ExplicitHTTPAndSSEToolCallProtocols(t *testing.T) {
  131. reverse := map[string]string{codexPythonToolAlias: "python"}
  132. tests := []struct {
  133. name string
  134. body string
  135. path string
  136. }{
  137. {
  138. name: "chat http",
  139. body: `{"choices":[{"message":{"tool_calls":[{"type":"function","function":{"name":"python__sub2api"}}]}}],"metadata":{"name":"python__sub2api"}}`,
  140. path: "choices.0.message.tool_calls.0.function.name",
  141. },
  142. {
  143. name: "chat sse",
  144. body: `{"choices":[{"delta":{"tool_calls":[{"type":"function","function":{"name":"python__sub2api"}}]}}],"metadata":{"name":"python__sub2api"}}`,
  145. path: "choices.0.delta.tool_calls.0.function.name",
  146. },
  147. {
  148. name: "messages tool use",
  149. body: `{"type":"content_block_start","content":[{"type":"tool_use","name":"python__sub2api"}],"metadata":{"name":"python__sub2api"}}`,
  150. path: "content.0.name",
  151. },
  152. }
  153. for _, tt := range tests {
  154. t.Run(tt.name, func(t *testing.T) {
  155. restored := restoreCodexToolNamesInJSON([]byte(tt.body), reverse)
  156. require.Equal(t, "python", gjson.GetBytes(restored, tt.path).String())
  157. require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "metadata.name").String())
  158. })
  159. }
  160. }
  161. func TestAliasOpenAIOAuthReservedToolNames_PromptCompatibilityRunsFirst(t *testing.T) {
  162. body := []byte(`{"model":"gpt-5.5","prompt":[{"type":"function_call","name":"python","call_id":"fc_1"}],"functions":[{"name":"python"}],"function_call":{"name":"python"},"sequence":900719925474099312345}`)
  163. reqBody, err := getOpenAIRequestBodyMap(nil, body)
  164. require.NoError(t, err)
  165. result := applyCodexOAuthTransform(reqBody, true, false)
  166. require.NoError(t, result.Error)
  167. input, ok := reqBody["input"].([]any)
  168. require.True(t, ok)
  169. require.NotEmpty(t, input)
  170. firstInput, ok := input[0].(map[string]any)
  171. require.True(t, ok)
  172. require.Equal(t, codexPythonToolAlias, firstInput["name"])
  173. tools, ok := reqBody["tools"].([]any)
  174. require.True(t, ok)
  175. require.NotEmpty(t, tools)
  176. tool, ok := tools[0].(map[string]any)
  177. require.True(t, ok)
  178. require.Equal(t, codexPythonToolAlias, tool["name"])
  179. toolChoice, ok := reqBody["tool_choice"].(map[string]any)
  180. require.True(t, ok)
  181. require.Equal(t, codexPythonToolAlias, toolChoice["name"])
  182. encoded, err := json.Marshal(reqBody)
  183. require.NoError(t, err)
  184. require.Equal(t, "900719925474099312345", gjson.GetBytes(encoded, "sequence").Raw)
  185. }
  186. func TestCodexToolNameReverse_WSSessionReplacementDoesNotChangeActiveTurn(t *testing.T) {
  187. c, _ := gin.CreateTestContext(nil)
  188. setCodexToolNameReverse(c, nil)
  189. first := []byte(`{"type":"response.create","tools":[{"type":"function","name":"python"}]}`)
  190. updateCodexToolNameReverseForWSFrame(c, first, map[string]string{codexPythonToolAlias: "python"})
  191. update := []byte(`{"type":"session.update","session":{"tools":[{"type":"function","name":"python__sub2api"}]}}`)
  192. updateCodexToolNameReverseForWSFrame(c, update, nil)
  193. currentOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`))
  194. require.Equal(t, "python", gjson.GetBytes(currentOutput, "item.name").String())
  195. sessionEcho := restoreCodexToolNamesFromContext(c, []byte(`{"type":"session.updated","session":{"tools":[{"type":"function","name":"python__sub2api"}]}}`))
  196. require.Equal(t, codexPythonToolAlias, gjson.GetBytes(sessionEcho, "session.tools.0.name").String())
  197. next := []byte(`{"type":"response.create","input":"next"}`)
  198. updateCodexToolNameReverseForWSFrame(c, next, nil)
  199. nextOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`))
  200. require.Equal(t, codexPythonToolAlias, gjson.GetBytes(nextOutput, "item.name").String())
  201. sessionPython := []byte(`{"type":"session.update","session":{"tools":[{"type":"function","name":"python"}]}}`)
  202. updateCodexToolNameReverseForWSFrame(c, sessionPython, map[string]string{codexPythonToolAlias: "python"})
  203. explicitLiteral := []byte(`{"type":"response.create","input":[{"type":"additional_tools","tools":[{"type":"function","name":"python__sub2api"}]}]}`)
  204. updateCodexToolNameReverseForWSFrame(c, explicitLiteral, nil)
  205. literalOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`))
  206. require.Equal(t, codexPythonToolAlias, gjson.GetBytes(literalOutput, "item.name").String())
  207. updateCodexToolNameReverseForWSFrame(c, next, nil)
  208. inheritedOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`))
  209. require.Equal(t, "python", gjson.GetBytes(inheritedOutput, "item.name").String())
  210. }
  211. func TestDecodeOpenAIJSONUseNumberRejectsTrailingDocument(t *testing.T) {
  212. var decoded map[string]any
  213. require.Error(t, decodeOpenAIJSONUseNumber([]byte(`{"name":"python"}{"extra":true}`), &decoded))
  214. }
  215. func TestRestoreCodexToolNamesFromSSEContextUsesEventLineTypeWithoutAddingType(t *testing.T) {
  216. c, _ := gin.CreateTestContext(nil)
  217. setCodexToolNameReverse(c, map[string]string{codexPythonToolAlias: codexReservedPythonToolName})
  218. payload := []byte(`{"item":{"type":"function_call","name":"python__sub2api"},"metadata":{"name":"python__sub2api"}}`)
  219. restored := restoreCodexToolNamesFromSSEContext(c, payload, "response.output_item.done")
  220. require.Equal(t, codexReservedPythonToolName, gjson.GetBytes(restored, "item.name").String())
  221. require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "metadata.name").String())
  222. require.False(t, gjson.GetBytes(restored, "type").Exists())
  223. }