openai_responses_namespace.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. package service
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "fmt"
  6. "strings"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  8. "github.com/gin-gonic/gin"
  9. "github.com/tidwall/gjson"
  10. "github.com/tidwall/sjson"
  11. )
  12. const openAIResponsesNamespaceNamesContextKey = "openai_responses_namespace_names"
  13. // shouldFlattenOpenAIResponsesNamespaces 判定原生 Responses 转发前是否摊平
  14. // Codex namespace 工具。
  15. //
  16. // 默认不摊平:OAuth 账号的 HTTP 出口恒为 chatgpt.com/backend-api/codex/responses
  17. // (buildUpstreamRequest 只在 API Key 分支读 base_url),也就是 namespace 扩展的
  18. // 定义方本身;Codex 客户端对 WS 与 HTTP 两条传输发送同一份 tools(codex-rs
  19. // client.rs build_responses_request 无传输分支,WS 失败后会 session 级回落 HTTP
  20. // 继续发同样的声明)。摊平只改写工具名,改不掉客户端在 tools 描述与 developer
  21. // 消息里写死的 `to=functions.<namespace>.<tool>` 寻址约定,模型据此寻址必然落空
  22. // (issue #4978);命名空间名还可由 features.multi_agent_v2.tool_namespace 自定义、
  23. // 或由 MCP/connector 动态生成(mcp__codex_apps__gmail),保留名单枚举不完。
  24. //
  25. // compact 端点例外:已知它的 schema 比 /responses 窄(连 input[].namespace 都会报
  26. // Unknown parameter,见 issue #4761),而是否接受 namespace 工具声明没有任何实测
  27. // 证据;compact 只做历史摘要、不需要模型寻址工具,回程也没有工具调用可还原,因此
  28. // 保持 0.1.166 起就在跑的摊平行为,不随本次默认值翻转扩大风险面。
  29. //
  30. // 账号开关 openai_responses_flatten_namespaces 为不认识 namespace 的兼容上游保留
  31. // 退路,打开后恢复旧行为:WSv2 上游原生支持 namespace,且 WS 出口
  32. // (openai_ws_forwarder_v2)原样转发上游事件、不经 HTTP 回程还原,摊平后的平名
  33. // 无法还原会破坏客户端工具匹配,因此实际走 WSv2 分支的请求仍保持 namespace 原样;
  34. // 透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。
  35. func shouldFlattenOpenAIResponsesNamespaces(
  36. account *Account,
  37. transport OpenAIUpstreamTransport,
  38. passthroughEnabled bool,
  39. compactPath bool,
  40. ) bool {
  41. if account == nil || !account.IsOpenAIOAuthLike() {
  42. return false
  43. }
  44. if !compactPath && !account.IsOpenAIResponsesFlattenNamespacesEnabled() {
  45. return false
  46. }
  47. if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled {
  48. return false
  49. }
  50. return true
  51. }
  52. // shouldStripOpenAIResponsesInputNamespaces removes residual input item
  53. // namespaces for OpenAI OAuth and API Key HTTP forwarding. Native WSv2 keeps
  54. // namespaces because that protocol supports them and does not restore payloads.
  55. func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool {
  56. if account == nil || (!account.IsOpenAIOAuthLike() && !account.IsOpenAIApiKey()) {
  57. return false
  58. }
  59. if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled {
  60. return false
  61. }
  62. return true
  63. }
  64. // shouldKeepOpenAIResponsesToolCallNamespaces 判定清理 input 残留 namespace 时是否
  65. // 保留工具调用项上的 namespace。
  66. //
  67. // 上游对这个字段有两套互斥要求,判定按「出口 + 端点」而非工具声明内容:
  68. // - /backend-api/codex/responses 会按 namespace 解析历史调用,缺字段直接 400
  69. // `Missing namespace for function_call '...'. Round-trip the model's
  70. // function_call item with its namespace field included.`(issue #4761 回帖),
  71. // 故 OAuth 非 compact 请求必须保留。
  72. // - compact 端点的 schema 不含该字段,携带即 400 `Unknown parameter:
  73. // input[N].namespace`(issue #4761 正文),故 compact 一律清理。
  74. // - API Key 出口默认按标准 Responses API 处理并清理该字段;但当请求本身声明
  75. // namespace 工具时,上游显然使用了 namespace 扩展,此时必须保留调用项上的
  76. // namespace,否则声明与历史调用会失配并触发 Missing namespace。
  77. // - 摊平模式下调用项已被改写成平名,残留 namespace 指向的声明已不存在,一律清理。
  78. func shouldKeepOpenAIResponsesToolCallNamespaces(
  79. account *Account,
  80. transport OpenAIUpstreamTransport,
  81. passthroughEnabled bool,
  82. compactPath bool,
  83. body []byte,
  84. ) bool {
  85. if account == nil {
  86. return false
  87. }
  88. if compactPath {
  89. return false
  90. }
  91. if account.IsOpenAIApiKey() {
  92. return hasOpenAIResponsesNamespaceToolDeclaration(body)
  93. }
  94. if !account.IsOpenAIOAuthLike() {
  95. return false
  96. }
  97. return !shouldFlattenOpenAIResponsesNamespaces(account, transport, passthroughEnabled, compactPath)
  98. }
  99. func hasOpenAIResponsesNamespaceToolDeclaration(body []byte) bool {
  100. tools := gjson.GetBytes(body, "tools")
  101. if !tools.IsArray() {
  102. return false
  103. }
  104. found := false
  105. tools.ForEach(func(_, tool gjson.Result) bool {
  106. if strings.EqualFold(strings.TrimSpace(tool.Get("type").String()), "namespace") {
  107. found = true
  108. return false
  109. }
  110. return true
  111. })
  112. return found
  113. }
  114. // openAIResponsesToolCallItemTypes 是携带 namespace 的调用项类型集合。与
  115. // removeOpenAIResponsesRejectedNamespaceAtIndex 的反应式白名单保持一致;codex-rs
  116. // protocol/src/models.rs 中只有 FunctionCall 与 CustomToolCall 序列化 namespace,
  117. // 其余类型带该字段一定是非 Codex 客户端或历史残留,清掉才安全。
  118. var openAIResponsesToolCallItemTypes = map[string]bool{
  119. "function_call": true,
  120. "tool_call": true,
  121. "custom_tool_call": true,
  122. "mcp_tool_call": true,
  123. }
  124. func isOpenAIResponsesToolCallItemType(itemType string) bool {
  125. return openAIResponsesToolCallItemTypes[strings.ToLower(strings.TrimSpace(itemType))]
  126. }
  127. func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, error) {
  128. if !bytes.Contains(body, []byte(`"namespace"`)) {
  129. return body, nil
  130. }
  131. var requestBody map[string]any
  132. if err := json.Unmarshal(body, &requestBody); err != nil {
  133. return body, fmt.Errorf("decode OpenAI namespace body: %w", err)
  134. }
  135. names, changed, err := apicompat.FlattenResponsesNamespacesExcept(requestBody, map[string]bool{"image_gen": true})
  136. if err != nil {
  137. return body, err
  138. }
  139. if !changed {
  140. return body, nil
  141. }
  142. rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
  143. if err != nil {
  144. return body, fmt.Errorf("encode OpenAI namespace body: %w", err)
  145. }
  146. setOpenAIResponsesNamespaceNames(c, names)
  147. return rebuilt, nil
  148. }
  149. // stripOpenAIResponsesInputNamespaces removes namespace only from direct input
  150. // array items. Namespace declarations and nested namespace fields are left
  151. // untouched. Rebuilding the input array once keeps this linear for long
  152. // histories and avoids decoding JSON numbers through float64.
  153. //
  154. // keepToolCallNamespaces 保留工具调用项(function_call / custom_tool_call 等)上的
  155. // namespace,让 Codex 调用能按上游要求原样回传;判定见
  156. // shouldKeepOpenAIResponsesToolCallNamespaces。
  157. func stripOpenAIResponsesInputNamespaces(body []byte, keepToolCallNamespaces bool) ([]byte, error) {
  158. if !bytes.Contains(body, []byte(`"namespace"`)) {
  159. return body, nil
  160. }
  161. input := gjson.GetBytes(body, "input")
  162. if !input.IsArray() {
  163. return body, nil
  164. }
  165. var rebuilt bytes.Buffer
  166. rebuilt.Grow(len(input.Raw))
  167. _ = rebuilt.WriteByte('[')
  168. changed := false
  169. first := true
  170. var stripErr error
  171. input.ForEach(func(_, item gjson.Result) bool {
  172. if !first {
  173. _ = rebuilt.WriteByte(',')
  174. }
  175. first = false
  176. itemBody := []byte(item.Raw)
  177. // 先判存在再判类型:长历史里绝大多数是 message/reasoning 等不带 namespace
  178. // 的项,这样它们无需再扫一次 type。
  179. if item.IsObject() && item.Get("namespace").Exists() &&
  180. (!keepToolCallNamespaces || !isOpenAIResponsesToolCallItemType(item.Get("type").String())) {
  181. itemBody, stripErr = sjson.DeleteBytes(itemBody, "namespace")
  182. if stripErr != nil {
  183. return false
  184. }
  185. changed = true
  186. }
  187. _, _ = rebuilt.Write(itemBody)
  188. return true
  189. })
  190. _ = rebuilt.WriteByte(']')
  191. if stripErr != nil {
  192. return body, fmt.Errorf("delete OpenAI input namespace: %w", stripErr)
  193. }
  194. if !changed {
  195. return body, nil
  196. }
  197. stripped, err := sjson.SetRawBytes(body, "input", rebuilt.Bytes())
  198. if err != nil {
  199. return body, fmt.Errorf("replace OpenAI input after namespace deletion: %w", err)
  200. }
  201. return stripped, nil
  202. }
  203. func setOpenAIResponsesNamespaceNames(c *gin.Context, names map[string]apicompat.ResponsesNamespaceName) {
  204. if c != nil && len(names) > 0 {
  205. c.Set(openAIResponsesNamespaceNamesContextKey, names)
  206. }
  207. }
  208. // clearOpenAIResponsesNamespaceNames 清除上一次尝试登记的摊平名映射。handler 的
  209. // failover 会在同一个 *gin.Context 上重试下一个账号,映射不清会让保留 namespace 的
  210. // 账号拿着上一个账号的摊平名做回程还原。
  211. func clearOpenAIResponsesNamespaceNames(c *gin.Context) {
  212. if c == nil {
  213. return
  214. }
  215. if _, exists := c.Get(openAIResponsesNamespaceNamesContextKey); exists {
  216. c.Set(openAIResponsesNamespaceNamesContextKey, map[string]apicompat.ResponsesNamespaceName(nil))
  217. }
  218. }
  219. func openAIResponsesNamespaceNames(c *gin.Context) map[string]apicompat.ResponsesNamespaceName {
  220. if c == nil {
  221. return nil
  222. }
  223. value, ok := c.Get(openAIResponsesNamespaceNamesContextKey)
  224. if !ok {
  225. return nil
  226. }
  227. names, _ := value.(map[string]apicompat.ResponsesNamespaceName)
  228. return names
  229. }
  230. func restoreOpenAIResponsesNamespacePayload(c *gin.Context, payload []byte) ([]byte, error) {
  231. names := openAIResponsesNamespaceNames(c)
  232. if len(names) == 0 || !json.Valid(payload) {
  233. return payload, nil
  234. }
  235. restored, changed, err := apicompat.RestoreResponsesNamespaceCalls(payload, names)
  236. if err != nil {
  237. return payload, err
  238. }
  239. if changed {
  240. return restored, nil
  241. }
  242. return payload, nil
  243. }