openai_responses_namespace_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. package service
  2. import (
  3. "strconv"
  4. "testing"
  5. "github.com/stretchr/testify/require"
  6. "github.com/tidwall/gjson"
  7. )
  8. func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) {
  9. oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  10. apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  11. grokOAuth := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}
  12. // 账号级兼容开关:为不认识 namespace 的兼容上游恢复旧的摊平行为。
  13. flattenOAuth := &Account{
  14. Platform: PlatformOpenAI,
  15. Type: AccountTypeOAuth,
  16. Extra: map[string]any{"openai_responses_flatten_namespaces": true},
  17. }
  18. flattenAPIKey := &Account{
  19. Platform: PlatformOpenAI,
  20. Type: AccountTypeAPIKey,
  21. Extra: map[string]any{"openai_responses_flatten_namespaces": true},
  22. }
  23. tests := []struct {
  24. name string
  25. account *Account
  26. transport OpenAIUpstreamTransport
  27. passthroughEnabled bool
  28. compactPath bool
  29. want bool
  30. }{
  31. // 默认保留:OAuth 出口是 namespace 扩展的定义方,摊平会让模型无法按
  32. // `to=functions.<namespace>.<tool>` 寻址(issue #4978)。
  33. {name: "oauth_http_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
  34. {name: "oauth_http_passthrough_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: false},
  35. {name: "oauth_wsv2_default_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
  36. // compact 端点 schema 更窄且无实测证据,保持既有摊平行为。
  37. {name: "oauth_compact_flattens", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: true},
  38. {name: "oauth_compact_wsv2_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false},
  39. {name: "apikey_compact", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false},
  40. {name: "oauth_flatten_enabled_http", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
  41. {name: "oauth_flatten_enabled_http_passthrough", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true},
  42. // WSv2 出口原样转发上游事件、不做回程还原,摊平会让客户端收到无法匹配的平名。
  43. {name: "oauth_flatten_enabled_wsv2", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
  44. // 透传账号先于 WSv2 分支经 HTTP 转发返回,开关打开时仍需摊平。
  45. {name: "oauth_flatten_enabled_wsv2_passthrough", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
  46. // 开关仅对 OAuth 生效:API Key 走 chat completions 回退桥时由桥自行摊平。
  47. {name: "apikey_flatten_enabled_http", account: flattenAPIKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
  48. {name: "apikey_http", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
  49. {name: "grok_oauth_http", account: grokOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
  50. {name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
  51. }
  52. for _, tt := range tests {
  53. t.Run(tt.name, func(t *testing.T) {
  54. require.Equal(t, tt.want, shouldFlattenOpenAIResponsesNamespaces(
  55. tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath,
  56. ))
  57. })
  58. }
  59. }
  60. func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) {
  61. oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  62. apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  63. setupToken := &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}
  64. flattenOAuth := &Account{
  65. Platform: PlatformOpenAI,
  66. Type: AccountTypeOAuth,
  67. Extra: map[string]any{"openai_responses_flatten_namespaces": true},
  68. }
  69. tests := []struct {
  70. name string
  71. account *Account
  72. transport OpenAIUpstreamTransport
  73. passthroughEnabled bool
  74. compactPath bool
  75. body []byte
  76. want bool
  77. }{
  78. // 上游按 namespace 解析历史调用,缺字段会 400 "Missing namespace for function_call"。
  79. {name: "oauth_http_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
  80. {name: "oauth_http_passthrough_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true},
  81. // compact 端点 schema 不含该字段,携带即 400 "Unknown parameter: input[N].namespace"。
  82. {name: "oauth_compact_strips", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false},
  83. // 摊平后调用项已是平名,残留 namespace 指向的声明不存在。
  84. {name: "oauth_flatten_enabled_strips", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
  85. // WSv2 实际由 shouldStrip 提前短路,此处只钉住策略本身的取值。
  86. {name: "oauth_wsv2_keeps", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: true},
  87. // WSv2 + compact 是唯一「不摊平但仍必须清理」的组合,钉住 compact 判定本身,
  88. // 使其不会被误当成可由 shouldFlatten 推导出的冗余分支。
  89. {name: "oauth_compact_wsv2_strips", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false},
  90. // API Key 默认按标准 Responses API 清理;请求显式声明 namespace 工具时,
  91. // 自定义上游需要原样接收对应的历史调用。
  92. {name: "apikey_without_namespace_tool_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
  93. {name: "apikey_with_namespace_tool_keeps", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, body: []byte(`{"tools":[{"type":"namespace","name":"mcp__codex_app","tools":[]}]}`), want: true},
  94. {name: "apikey_with_mixed_case_namespace_tool_keeps", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, body: []byte(`{"tools":[{"type":" Namespace ","name":"mcp__codex_app","tools":[]}]}`), want: true},
  95. {name: "apikey_function_tool_with_namespace_field_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, body: []byte(`{"tools":[{"type":"function","name":"automation_update","namespace":"mcp__codex_app"}]}`), want: false},
  96. {name: "apikey_compact_with_namespace_tool_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, body: []byte(`{"tools":[{"type":"namespace","name":"mcp__codex_app","tools":[]}]}`), want: false},
  97. {name: "setup_token_keeps", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
  98. {name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
  99. }
  100. for _, tt := range tests {
  101. t.Run(tt.name, func(t *testing.T) {
  102. require.Equal(t, tt.want, shouldKeepOpenAIResponsesToolCallNamespaces(
  103. tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath, tt.body,
  104. ))
  105. })
  106. }
  107. }
  108. func TestShouldStripOpenAIResponsesInputNamespaces(t *testing.T) {
  109. oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  110. apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  111. setupToken := &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}
  112. grokOAuth := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}
  113. tests := []struct {
  114. name string
  115. account *Account
  116. transport OpenAIUpstreamTransport
  117. passthroughEnabled bool
  118. want bool
  119. }{
  120. {name: "oauth_http", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
  121. {name: "apikey_http", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
  122. {name: "oauth_wsv2", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
  123. {name: "apikey_wsv2", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
  124. {name: "oauth_wsv2_passthrough", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
  125. {name: "apikey_wsv2_passthrough", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
  126. {name: "setup_token_http", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
  127. {name: "grok_oauth_http", account: grokOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
  128. {name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
  129. }
  130. for _, tt := range tests {
  131. t.Run(tt.name, func(t *testing.T) {
  132. require.Equal(t, tt.want, shouldStripOpenAIResponsesInputNamespaces(tt.account, tt.transport, tt.passthroughEnabled))
  133. })
  134. }
  135. }
  136. func TestStripOpenAIResponsesInputNamespaces(t *testing.T) {
  137. body := []byte(`{
  138. "meta":9007199254740993,
  139. "scientific":1.25e+42,
  140. "escaped":"line\\n\\u003ctag\\u003e",
  141. "tools":[{"type":"function","name":"keep","namespace":"tool-namespace"}],
  142. "input":[
  143. {"type":"function_call","namespace":"n0","name":"one","content":{"namespace":"nested"},"large":9007199254740993},
  144. {"type":"message","namespace":"n1","content":[{"type":"input_text","text":"hello","namespace":"nested-content"}]},
  145. {"type":"custom_tool_call","namespace":"n2","input":"{}"},
  146. {"type":"function_call_output","namespace":"n3","output":"ok"},
  147. {"type":"item","namespace":"n4"},
  148. {"type":"item","namespace":"n5"},
  149. {"type":"item","namespace":"n6"},
  150. {"type":"item","namespace":"n7"}
  151. ]
  152. }`)
  153. stripped, err := stripOpenAIResponsesInputNamespaces(body, false)
  154. require.NoError(t, err)
  155. for index := 0; index < 8; index++ {
  156. require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists())
  157. }
  158. require.Equal(t, "nested", gjson.GetBytes(stripped, "input.0.content.namespace").String())
  159. require.Equal(t, "nested-content", gjson.GetBytes(stripped, "input.1.content.0.namespace").String())
  160. require.Equal(t, "tool-namespace", gjson.GetBytes(stripped, "tools.0.namespace").String())
  161. require.Equal(t, gjson.GetBytes(body, "meta").Raw, gjson.GetBytes(stripped, "meta").Raw)
  162. require.Equal(t, gjson.GetBytes(body, "scientific").Raw, gjson.GetBytes(stripped, "scientific").Raw)
  163. require.Equal(t, gjson.GetBytes(body, "escaped").Raw, gjson.GetBytes(stripped, "escaped").Raw)
  164. require.Equal(t, gjson.GetBytes(body, "input.0.large").Raw, gjson.GetBytes(stripped, "input.0.large").Raw)
  165. }
  166. func TestStripOpenAIResponsesInputNamespacesLeavesOtherShapesByteExact(t *testing.T) {
  167. tests := [][]byte{
  168. []byte(`{"input":"text","namespace":"top-level"}`),
  169. []byte(`{"input":{"namespace":"single-object"}}`),
  170. []byte(`{"input":[{"content":{"namespace":"nested-only"}}],"tools":[{"namespace":"keep"}]}`),
  171. }
  172. for _, body := range tests {
  173. for _, keepToolCallNamespaces := range []bool{false, true} {
  174. stripped, err := stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
  175. require.NoError(t, err)
  176. require.Equal(t, body, stripped)
  177. }
  178. }
  179. }
  180. // 保留模式下只有工具调用项留住 namespace:上游按 namespace 解析历史调用,
  181. // 而 message / reasoning / 输出项带该字段会被 schema 拒绝。
  182. func TestStripOpenAIResponsesInputNamespacesKeepsToolCallNamespaces(t *testing.T) {
  183. body := []byte(`{
  184. "meta":9007199254740993,
  185. "input":[
  186. {"type":"function_call","namespace":"collaboration","name":"spawn_agent","arguments":"{}","large":9007199254740993},
  187. {"type":"custom_tool_call","namespace":"codex_app","name":"exec","input":"{}"},
  188. {"type":"tool_call","namespace":"mcp__codex_apps__gmail","name":"send"},
  189. {"type":"mcp_tool_call","namespace":"mcp__codex_apps__gmail","name":"list"},
  190. {"type":"message","namespace":"leftover","role":"assistant","content":[{"type":"output_text","text":"hi"}]},
  191. {"type":"function_call_output","namespace":"leftover","output":"ok"},
  192. {"type":"reasoning","namespace":"leftover"},
  193. {"type":"item","namespace":"leftover"}
  194. ]
  195. }`)
  196. stripped, err := stripOpenAIResponsesInputNamespaces(body, true)
  197. require.NoError(t, err)
  198. require.Equal(t, "collaboration", gjson.GetBytes(stripped, "input.0.namespace").String())
  199. require.Equal(t, "codex_app", gjson.GetBytes(stripped, "input.1.namespace").String())
  200. require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.2.namespace").String())
  201. require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.3.namespace").String())
  202. for index := 4; index < 8; index++ {
  203. require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists())
  204. }
  205. // 大整数不得经 float64 往返。
  206. require.Equal(t, gjson.GetBytes(body, "meta").Raw, gjson.GetBytes(stripped, "meta").Raw)
  207. require.Equal(t, gjson.GetBytes(body, "input.0.large").Raw, gjson.GetBytes(stripped, "input.0.large").Raw)
  208. // 类型比对不区分大小写与首尾空白。
  209. mixedCase := []byte(`{"input":[{"type":" Function_Call ","namespace":"collaboration","name":"spawn_agent"}]}`)
  210. keptMixedCase, err := stripOpenAIResponsesInputNamespaces(mixedCase, true)
  211. require.NoError(t, err)
  212. require.Equal(t, mixedCase, keptMixedCase)
  213. // 全部为调用项时无改动,应原样返回。
  214. callsOnly := []byte(`{"input":[{"type":"function_call","namespace":"collaboration","name":"spawn_agent"}]}`)
  215. unchanged, err := stripOpenAIResponsesInputNamespaces(callsOnly, true)
  216. require.NoError(t, err)
  217. require.Equal(t, callsOnly, unchanged)
  218. // 关闭保留时回到全量清理。
  219. strippedAll, err := stripOpenAIResponsesInputNamespaces(body, false)
  220. require.NoError(t, err)
  221. for index := 0; index < 8; index++ {
  222. require.False(t, gjson.GetBytes(strippedAll, "input."+strconv.Itoa(index)+".namespace").Exists())
  223. }
  224. }