openai_responses_namespace_forward_test.go 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. package service
  2. import (
  3. "context"
  4. "net/http"
  5. "testing"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  7. "github.com/stretchr/testify/require"
  8. "github.com/tidwall/gjson"
  9. )
  10. // codexNamespaceRequestBody 模拟 Codex 多智能体请求:collaboration 命名空间声明 +
  11. // 历史里的命名空间调用项 + 带残留 namespace 的普通消息项。
  12. const codexNamespaceRequestBody = `{
  13. "model":"gpt-5.6-terra",
  14. "stream":false,
  15. "instructions":"test",
  16. "tools":[
  17. {"type":"namespace","name":"collaboration","description":"Tools for spawning and managing sub-agents.","tools":[
  18. {"type":"function","name":"spawn_agent","description":"Call as to=functions.collaboration.spawn_agent","parameters":{"type":"object"}},
  19. {"type":"function","name":"wait_agent","parameters":{"type":"object"}}
  20. ]},
  21. {"type":"function","name":"exec","parameters":{"type":"object"}}
  22. ],
  23. "input":[
  24. {"type":"function_call","namespace":"collaboration","name":"spawn_agent","call_id":"call_1","arguments":"{}"},
  25. {"type":"message","role":"user","namespace":"leftover","content":[{"type":"input_text","text":"hello"}]}
  26. ]
  27. }`
  28. const namespaceForwardOKResponse = `{"id":"resp_ns","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`
  29. // OAuth 出口即 namespace 扩展的定义方:声明必须原样送达,历史调用项必须保留
  30. // namespace(缺字段上游会 400 "Missing namespace for function_call"),而非调用项上的
  31. // 残留 namespace 仍要清掉。回归 issue #4978。
  32. func TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools(t *testing.T) {
  33. body := []byte(codexNamespaceRequestBody)
  34. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  35. newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
  36. }}
  37. c := newOpenAIRejectedFieldTestContext(body)
  38. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  39. context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
  40. )
  41. require.NoError(t, err)
  42. require.NotNil(t, result)
  43. require.Len(t, upstream.bodies, 1)
  44. forwarded := upstream.bodies[0]
  45. namespaceTool := gjson.GetBytes(forwarded, `tools.#(type=="namespace")`)
  46. require.True(t, namespaceTool.Exists(), "namespace 声明必须原样转发")
  47. require.Equal(t, "collaboration", namespaceTool.Get("name").String())
  48. require.Equal(t, "spawn_agent", namespaceTool.Get("tools.0.name").String())
  49. require.Equal(t, "wait_agent", namespaceTool.Get("tools.1.name").String())
  50. // 摊平名一旦出现,模型就无法按工具描述里的 to=functions.collaboration.spawn_agent 寻址。
  51. require.NotContains(t, string(forwarded), "collaboration__spawn_agent")
  52. require.Equal(t, "collaboration", gjson.GetBytes(forwarded, "input.0.namespace").String())
  53. require.Equal(t, "spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
  54. require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
  55. // 未摊平即无需回程还原,不得登记映射。
  56. require.Empty(t, openAIResponsesNamespaceNames(c))
  57. }
  58. // API Key 自定义上游若接受 namespace 工具声明,也要求历史 function_call 原样携带
  59. // namespace。声明仍为命名空间工具却清掉调用项字段,会触发 Missing namespace。
  60. func TestOpenAIGatewayService_APIKeyPreservesDeclaredNamespaceToolCalls(t *testing.T) {
  61. body := []byte(codexNamespaceRequestBody)
  62. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  63. newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
  64. }}
  65. c := newOpenAIRejectedFieldTestContext(body)
  66. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  67. context.Background(), c, newOpenAIRejectedFieldTestAccount(), body,
  68. )
  69. require.NoError(t, err)
  70. require.NotNil(t, result)
  71. require.Len(t, upstream.bodies, 1)
  72. forwarded := upstream.bodies[0]
  73. require.True(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists())
  74. require.Equal(t, "collaboration", gjson.GetBytes(forwarded, "input.0.namespace").String())
  75. require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
  76. }
  77. // compact 端点 schema 更窄:input[].namespace 会 400 Unknown parameter(issue #4761),
  78. // 且没有证据表明它接受 namespace 工具声明。compact 只做历史摘要、不需要模型寻址工具,
  79. // 因此保持既有的摊平 + 全量清理行为,不随默认值翻转扩大风险面。
  80. func TestOpenAIGatewayService_OAuthCompactKeepsFlattening(t *testing.T) {
  81. body := []byte(codexNamespaceRequestBody)
  82. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  83. newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
  84. }}
  85. c := newOpenAIRejectedFieldTestContext(body)
  86. c.Request.URL.Path = "/v1/responses/compact"
  87. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  88. context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
  89. )
  90. require.NoError(t, err)
  91. require.NotNil(t, result)
  92. require.Len(t, upstream.bodies, 1)
  93. forwarded := upstream.bodies[0]
  94. require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists())
  95. require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
  96. require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists())
  97. require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
  98. }
  99. // 账号开关为不认识 namespace 的兼容上游保留退路:打开后恢复 0.1.166 的摊平行为。
  100. func TestOpenAIGatewayService_OAuthFlattenFlagRestoresLegacyBehavior(t *testing.T) {
  101. body := []byte(codexNamespaceRequestBody)
  102. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  103. newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
  104. }}
  105. c := newOpenAIRejectedFieldTestContext(body)
  106. account := newOpenAIOAuthNamespaceTestAccount()
  107. account.Extra = map[string]any{"openai_responses_flatten_namespaces": true}
  108. result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  109. context.Background(), c, account, body,
  110. )
  111. require.NoError(t, err)
  112. require.NotNil(t, result)
  113. require.Len(t, upstream.bodies, 1)
  114. forwarded := upstream.bodies[0]
  115. require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists())
  116. require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__spawn_agent")`).Exists())
  117. require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__wait_agent")`).Exists())
  118. // 摊平后调用项已改写成平名,不得再带 namespace。
  119. require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
  120. require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists())
  121. require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
  122. names := openAIResponsesNamespaceNames(c)
  123. require.Equal(t,
  124. apicompat.ResponsesNamespaceName{Namespace: "collaboration", Name: "spawn_agent"},
  125. names["collaboration__spawn_agent"],
  126. )
  127. }
  128. // handler 的 failover 在同一个 *gin.Context 上重试下一个账号;保留 namespace 的账号
  129. // 不得沿用上一个账号登记的摊平名映射做回程还原。
  130. func TestOpenAIGatewayService_ForwardClearsStaleNamespaceNames(t *testing.T) {
  131. body := []byte(codexNamespaceRequestBody)
  132. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  133. newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
  134. }}
  135. c := newOpenAIRejectedFieldTestContext(body)
  136. setOpenAIResponsesNamespaceNames(c, map[string]apicompat.ResponsesNamespaceName{
  137. "stale__tool": {Namespace: "stale", Name: "tool"},
  138. })
  139. _, err := newOpenAIRejectedFieldTestService(upstream).Forward(
  140. context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
  141. )
  142. require.NoError(t, err)
  143. require.Empty(t, openAIResponsesNamespaceNames(c))
  144. }