openai_gateway_forward.go 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "strings"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  14. "github.com/gin-gonic/gin"
  15. "github.com/tidwall/gjson"
  16. )
  17. // Forward forwards request to OpenAI API
  18. func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
  19. stageMode1Request(c, account, body)
  20. beginUpstreamResponseModelObservation(c)
  21. ClearActualOpenAIUpstreamEndpoint(c)
  22. if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
  23. SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions")
  24. }
  25. filteredBody, filterErr := filterOpenAIResponsesNoneReasoningEffortForAccount(account, body)
  26. if filterErr != nil {
  27. return nil, filterErr
  28. }
  29. body = filteredBody
  30. clearGrokResponsesClientToolMapping(c)
  31. clearOpenAIResponsesClientToolMapping(c)
  32. clearOpenAIResponsesNamespaceNames(c)
  33. setCodexToolNameReverse(c, nil)
  34. if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil {
  35. return nil, err
  36. }
  37. startTime := time.Now()
  38. // 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。
  39. canonicalImageIntentBody := body
  40. restrictionResult := s.detectCodexClientRestriction(c, account, body)
  41. apiKeyID := getAPIKeyIDFromContext(c)
  42. logCodexCLIOnlyDetection(ctx, c, account, apiKeyID, restrictionResult, body)
  43. if restrictionResult.Enabled && !restrictionResult.Matched {
  44. MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
  45. c.JSON(http.StatusForbidden, gin.H{
  46. "error": gin.H{
  47. "type": "forbidden_error",
  48. "message": CodexClientRestrictionMessage(restrictionResult),
  49. },
  50. })
  51. return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed")
  52. }
  53. normalizedBody, normalized, err := normalizeOpenAICodexCompactReasoningEffortForAccount(c, account, body)
  54. if err != nil {
  55. return nil, err
  56. }
  57. if normalized {
  58. body = normalizedBody
  59. }
  60. legacyIngressBody, legacyIngressChanged, legacyIngressErr := normalizeOpenAIResponsesLegacyIngress(body)
  61. if legacyIngressErr != nil {
  62. return nil, legacyIngressErr
  63. }
  64. if legacyIngressChanged {
  65. body = legacyIngressBody
  66. }
  67. // 在分流到 passthrough / Codex transform / 原生 ChatCompletions 之前统一修正
  68. // 显式为 null 的工具 Schema type,否则 upstream 的 400 会被归一成可重试的 502,
  69. // 同一份坏定义在账号池里反复重放。
  70. if sanitizedToolBody, toolSchemaSanitized, toolSchemaErr := sanitizeOpenAIResponsesToolSchemasForPlatform(body, account.Platform); toolSchemaErr != nil {
  71. return nil, toolSchemaErr
  72. } else if toolSchemaSanitized {
  73. body = sanitizedToolBody
  74. }
  75. if account.IsOpenAIOAuthLike() {
  76. reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(body)
  77. if reasoningErr != nil {
  78. return nil, fmt.Errorf("normalize OpenAI Responses reasoning.mode: %w", reasoningErr)
  79. }
  80. if reasoningChanged {
  81. body = reasoningBody
  82. }
  83. }
  84. responsesLite := account.IsOpenAI() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader))
  85. if responsesLite {
  86. liteBody, changed, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(body, account)
  87. if liteErr != nil {
  88. param := "tools"
  89. var validationErr *openAIResponsesLiteValidationError
  90. if errors.As(liteErr, &validationErr) {
  91. param = validationErr.param
  92. }
  93. setOpsUpstreamError(c, http.StatusBadRequest, liteErr.Error(), "")
  94. c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
  95. "type": "invalid_request_error", "message": liteErr.Error(), "param": param,
  96. }})
  97. return nil, liteErr
  98. }
  99. if changed {
  100. body = liteBody
  101. }
  102. }
  103. wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account)
  104. // 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。
  105. wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c))
  106. passthroughEnabled := account.IsOpenAIPassthroughEnabled()
  107. compactPath := isOpenAIResponsesCompactPath(c)
  108. if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled, compactPath) {
  109. body, err = flattenOpenAIResponsesNamespaces(c, body)
  110. if err != nil {
  111. setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
  112. c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
  113. "type": "invalid_request_error", "message": err.Error(), "param": "tools",
  114. }})
  115. return nil, err
  116. }
  117. }
  118. if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) {
  119. keepToolCallNamespaces := shouldKeepOpenAIResponsesToolCallNamespaces(
  120. account, wsDecision.Transport, passthroughEnabled, compactPath, body,
  121. )
  122. body, err = stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
  123. if err != nil {
  124. setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
  125. c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
  126. "type": "invalid_request_error", "message": err.Error(), "param": "input",
  127. }})
  128. return nil, err
  129. }
  130. }
  131. nativeCNResponses := account.UsesNativeCNResponses()
  132. nativeDeepSeekResponses := account.Platform == PlatformDeepseek && nativeCNResponses
  133. if nativeDeepSeekResponses && account.Type == AccountTypeAPIKey && !compactPath &&
  134. needsOpenAIResponsesClientToolAdaptation(body) {
  135. adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body)
  136. if adaptErr != nil {
  137. return nil, fmt.Errorf("adapt DeepSeek Responses client tools: %w", adaptErr)
  138. }
  139. body = adaptedBody
  140. setOpenAIResponsesClientToolMapping(c, mapping)
  141. }
  142. originalBody := body
  143. requestView := newOpenAIRequestView(body)
  144. reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey
  145. originalModel := reqModel
  146. if account.Platform == PlatformGrok {
  147. return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime)
  148. }
  149. // CN 供应商 anthropic 协议账号:/v1/responses 入站是交叉协议组合
  150. // (Responses 客户端 × Anthropic 上游),转成 Anthropic 请求走原生端点。
  151. // 不能落到下面的 raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。
  152. if account.IsAnthropicProtocol() {
  153. return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel)
  154. }
  155. if account.IsOpenAIApiKey() {
  156. if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body, responsesLite); normalizeErr != nil {
  157. return nil, normalizeErr
  158. } else if changed {
  159. body = normalized
  160. originalBody = normalized
  161. }
  162. if normalized, changed, normalizeErr := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, isOpenAIResponsesCompactPath(c)); normalizeErr != nil {
  163. return nil, normalizeErr
  164. } else if changed {
  165. body = normalized
  166. originalBody = normalized
  167. }
  168. requestView = newOpenAIRequestView(body)
  169. reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
  170. originalModel = reqModel
  171. }
  172. if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
  173. return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
  174. }
  175. if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) {
  176. normalizedReasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningContentReplay(body)
  177. if reasoningErr != nil {
  178. return nil, fmt.Errorf("normalize OpenAI Responses reasoning content replay: %w", reasoningErr)
  179. }
  180. if reasoningChanged {
  181. body = normalizedReasoningBody
  182. originalBody = normalizedReasoningBody
  183. requestView = newOpenAIRequestView(normalizedReasoningBody)
  184. reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
  185. originalModel = reqModel
  186. }
  187. sanitizedBody, changed, sanitizeErr := sanitizeOpenAIResponsesInputItemIDs(body)
  188. if sanitizeErr != nil {
  189. return nil, fmt.Errorf("sanitize OpenAI Responses input item IDs: %w", sanitizeErr)
  190. }
  191. if changed {
  192. body = sanitizedBody
  193. originalBody = sanitizedBody
  194. requestView = newOpenAIRequestView(sanitizedBody)
  195. reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
  196. originalModel = reqModel
  197. }
  198. }
  199. compatMessagesBridge := isOpenAICompatMessagesBridgeBody(body)
  200. setOpenAICompatMessagesBridgeContext(c, compatMessagesBridge)
  201. isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI)
  202. codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow
  203. if isCodexCLI {
  204. codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy()
  205. }
  206. if c != nil {
  207. c.Set("openai_ws_transport_decision", string(wsDecision.Transport))
  208. c.Set("openai_ws_transport_reason", wsDecision.Reason)
  209. }
  210. if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 {
  211. logOpenAIWSModeDebug(
  212. "selected account_id=%d account_type=%s transport=%s reason=%s model=%s stream=%v",
  213. account.ID,
  214. account.Type,
  215. normalizeOpenAIWSLogValue(string(wsDecision.Transport)),
  216. normalizeOpenAIWSLogValue(wsDecision.Reason),
  217. reqModel,
  218. reqStream,
  219. )
  220. }
  221. // 当前仅支持 WSv2;WSv1 命中时直接返回错误,避免出现“配置可开但行为不确定”。
  222. if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocket {
  223. if c != nil {
  224. MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
  225. c.JSON(http.StatusBadRequest, gin.H{
  226. "error": gin.H{
  227. "type": "invalid_request_error",
  228. "message": "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.",
  229. },
  230. })
  231. }
  232. return nil, errors.New("openai ws v1 is temporarily unsupported; use ws v2")
  233. }
  234. if passthroughEnabled {
  235. attemptImageIntentInvalidated := false
  236. if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip {
  237. strippedBody, changed, stripErr := stripOpenAIImageGenerationToolsFromRawPayload(body)
  238. if stripErr != nil {
  239. return nil, stripErr
  240. }
  241. if changed {
  242. body = strippedBody
  243. originalBody = strippedBody
  244. attemptImageIntentInvalidated = true
  245. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy")
  246. }
  247. }
  248. // 透传分支只需要轻量提取字段,避免热路径全量 Unmarshal。
  249. mappedModel := account.GetMappedModel(reqModel)
  250. reasoningEffort := extractOpenAIReasoningEffortFromBody(body, mappedModel)
  251. // 国产模型默认 effort 补充:也要用 mappedModel 判定是否是 passback-required 上游。
  252. reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, mappedModel)
  253. return s.forwardOpenAIPassthrough(
  254. ctx,
  255. c,
  256. account,
  257. originalBody,
  258. canonicalImageIntentBody,
  259. reqModel,
  260. attemptImageIntentInvalidated,
  261. reasoningEffort,
  262. reqStream,
  263. startTime,
  264. )
  265. }
  266. bodyModified := false
  267. clientPromptCacheKey := promptCacheKey
  268. var reqBody map[string]any
  269. ensureReqBody := func() (map[string]any, error) {
  270. if requestView.HasPatches() {
  271. patchedBody, patchErr := requestView.ApplyPatches()
  272. if patchErr != nil {
  273. return nil, patchErr
  274. }
  275. body = patchedBody
  276. requestView = newOpenAIRequestView(body)
  277. reqBody = nil
  278. bodyModified = false
  279. }
  280. if reqBody != nil {
  281. return reqBody, nil
  282. }
  283. decoded, decodeErr := requestView.Decode(c)
  284. if decodeErr != nil {
  285. return nil, decodeErr
  286. }
  287. reqBody = decoded
  288. return reqBody, nil
  289. }
  290. markPatchSet := func(path string, value any) {
  291. bodyModified = true
  292. if requestView.patchesDisabled {
  293. if reqBody != nil {
  294. setOpenAIRequestMapPath(reqBody, path, value)
  295. }
  296. return
  297. }
  298. requestView.MarkPatchSet(path, value)
  299. }
  300. markPatchDelete := func(path string) {
  301. bodyModified = true
  302. if requestView.patchesDisabled {
  303. if reqBody != nil {
  304. deleteOpenAIRequestMapPath(reqBody, path)
  305. }
  306. return
  307. }
  308. requestView.MarkPatchDelete(path)
  309. }
  310. disablePatch := func() {
  311. requestView.DisablePatches()
  312. }
  313. markDecodedModified := func() {
  314. bodyModified = true
  315. disablePatch()
  316. }
  317. apiKey := getAPIKeyFromContext(c)
  318. imageGenerationAllowed := GroupAllowsImageGeneration(nil)
  319. if apiKey != nil {
  320. imageGenerationAllowed = GroupAllowsImageGeneration(apiKey.Group)
  321. }
  322. codexImageGenerationBridgeEnabled := isCodexCLI &&
  323. !isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) &&
  324. imageGenerationAllowed &&
  325. codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip &&
  326. s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
  327. var imageIntent bool
  328. canonicalImageIntent := resolveOpenAIImageIntentHint(c, reqModel, canonicalImageIntentBody, IsImageGenerationIntent)
  329. if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip {
  330. decoded, decodeErr := ensureReqBody()
  331. if decodeErr != nil {
  332. return nil, decodeErr
  333. }
  334. if stripOpenAIImageGenerationTools(decoded) {
  335. markDecodedModified()
  336. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy")
  337. }
  338. imageIntent = IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, decoded)
  339. } else {
  340. imageIntent = canonicalImageIntent
  341. }
  342. if imageIntent && !imageGenerationAllowed {
  343. MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
  344. c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}})
  345. return nil, errors.New("image generation disabled for group")
  346. }
  347. isCompactRequest := compactPath
  348. requestedModel := reqModel
  349. billingModel, upstreamModel := resolveOpenAIForwardMappedModels(account, requestedModel, isCompactRequest)
  350. if isCompactRequest {
  351. if compactModel := s.resolveOpenAICompactFallbackModel(account, requestedModel); compactModel != "" {
  352. upstreamModel = compactModel
  353. }
  354. }
  355. instructions := gjson.GetBytes(body, "instructions")
  356. instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == ""
  357. if instructionsEmpty && account.UsesOpenAICodexProtocol() && !compatMessagesBridge && !nativeCNResponses {
  358. markPatchSet("instructions", defaultCodexSynthInstructions(upstreamModel))
  359. }
  360. if billingModel != requestedModel {
  361. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", requestedModel, billingModel, account.Name, isCodexCLI)
  362. }
  363. reqModel = billingModel
  364. if upstreamModel != requestedModel {
  365. markPatchSet("model", upstreamModel)
  366. }
  367. if upstreamModel != billingModel {
  368. if isCompactRequest {
  369. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", requestedModel, upstreamModel, account.Name, isCodexCLI)
  370. } else {
  371. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", billingModel, upstreamModel, account.Name, account.Type, isCodexCLI)
  372. }
  373. }
  374. if strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) == "minimal" {
  375. markPatchSet("reasoning.effort", "none")
  376. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized reasoning.effort: minimal -> none (account: %s)", account.Name)
  377. }
  378. if strings.TrimSpace(gjson.GetBytes(body, "text.format.type").String()) == "json_schema" ||
  379. strings.TrimSpace(gjson.GetBytes(body, "response_format.type").String()) == "json_schema" {
  380. decoded, decodeErr := ensureReqBody()
  381. if decodeErr != nil {
  382. return nil, decodeErr
  383. }
  384. if normalizeOpenAIResponseFormatSchemas(decoded) {
  385. markDecodedModified()
  386. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized Responses JSON schema compatibility")
  387. }
  388. }
  389. imageIntent = imageIntent || IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, nil) || isOpenAIImageGenerationModel(upstreamModel)
  390. if imageIntent && !imageGenerationAllowed {
  391. MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
  392. c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}})
  393. return nil, errors.New("image generation disabled for group")
  394. }
  395. // /responses/compact 是会话压缩请求:上游不接受 tool_choice(400 unknown_parameter),
  396. // 注入 image_generation 工具也没有意义,整块豁免。
  397. if imageGenerationAllowed && !isCompactRequest && (codexImageGenerationBridgeEnabled || isOpenAIImageGenerationModel(requestView.Model) || openAIRequestBodyImageGenerationToolNeedsNormalization(body) || isOpenAIImageGenerationModel(upstreamModel)) {
  398. decoded, decodeErr := ensureReqBody()
  399. if decodeErr != nil {
  400. return nil, decodeErr
  401. }
  402. if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationTool(decoded) {
  403. markDecodedModified()
  404. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Injected /responses image_generation tool for Codex client")
  405. }
  406. if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationToolChoiceAuto(decoded) {
  407. markDecodedModified()
  408. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Set /responses image_generation tool_choice=auto for Codex client")
  409. }
  410. if normalizeOpenAIResponsesImageGenerationTools(decoded) {
  411. markDecodedModified()
  412. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image_generation tool payload")
  413. }
  414. if normalizeOpenAIResponsesImageOnlyModel(decoded) {
  415. markDecodedModified()
  416. if model, ok := decoded["model"].(string); ok {
  417. upstreamModel = strings.TrimSpace(model)
  418. }
  419. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image-only model request inbound_model=%s image_model=%s upstream_model=%s", requestView.Model, billingModel, upstreamModel)
  420. }
  421. if err := validateOpenAIResponsesImageModel(decoded, upstreamModel); err != nil {
  422. setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
  423. c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "model"}})
  424. return nil, err
  425. }
  426. if hasOpenAIImageGenerationTool(decoded) {
  427. imageIntent = true
  428. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type)
  429. }
  430. if codexImageGenerationBridgeEnabled && applyCodexImageGenerationBridgeInstructions(decoded) {
  431. markDecodedModified()
  432. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Added Codex image_generation bridge instructions")
  433. }
  434. } else if imageGenerationAllowed && imageIntent && openAIRequestBodyHasImageGenerationDeclaration(body) {
  435. // 完整 image_generation tool 只做 raw 计费读取,校验/桥接/旧字段迁移命中时才展开大 input map。
  436. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type)
  437. }
  438. if isCodexSparkModel(upstreamModel) && openAIRequestBodyMayContainImageInput(body) {
  439. decoded, decodeErr := ensureReqBody()
  440. if decodeErr != nil {
  441. return nil, decodeErr
  442. }
  443. if err := validateCodexSparkInput(decoded, upstreamModel); err != nil {
  444. setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
  445. c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "input"}})
  446. return nil, err
  447. }
  448. }
  449. // gpt-5.3-codex-spark also rejects the image_generation tool (HTTP 400,
  450. // param=tools). Strip it here so both APIKey and OAuth /responses paths are
  451. // covered regardless of the image-generation feature gate.
  452. if isCodexSparkModel(upstreamModel) && openAIRequestBodyHasImageGenerationDeclaration(body) {
  453. decoded, decodeErr := ensureReqBody()
  454. if decodeErr != nil {
  455. return nil, decodeErr
  456. }
  457. if stripCodexSparkImageGenerationTools(decoded) {
  458. markDecodedModified()
  459. }
  460. }
  461. if account.UsesOpenAICodexProtocol() {
  462. decoded, decodeErr := ensureReqBody()
  463. if decodeErr != nil {
  464. return nil, decodeErr
  465. }
  466. // Responses OAuth 与 Chat 兼容入口保持一致:纯文本 system 可以无损提升后删除,
  467. // JSON object 模式仍需在 input 中保留 JSON 指令供上游兼容校验。
  468. omitPromotedSystemMessages := !strings.EqualFold(
  469. strings.TrimSpace(gjson.GetBytes(body, "text.format.type").String()),
  470. "json_object",
  471. )
  472. codexResult := codexTransformResult{}
  473. if compatMessagesBridge {
  474. codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{
  475. IsCodexCLI: isCodexCLI,
  476. IsCompact: isCompactRequest,
  477. SkipDefaultInstructions: true,
  478. PreserveToolCallIDs: true,
  479. OmitPromotedSystemMessagesFromInput: omitPromotedSystemMessages,
  480. })
  481. ensureCodexOAuthInstructionsField(decoded)
  482. markDecodedModified()
  483. } else {
  484. codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{
  485. IsCodexCLI: isCodexCLI,
  486. IsCompact: isCompactRequest,
  487. OmitPromotedSystemMessagesFromInput: omitPromotedSystemMessages,
  488. })
  489. }
  490. if codexResult.Error != nil {
  491. c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": codexResult.Error.Error()}})
  492. return nil, codexResult.Error
  493. }
  494. setCodexToolNameReverse(c, codexResult.ToolNameReverse)
  495. if codexResult.Modified {
  496. markDecodedModified()
  497. }
  498. // 带真实 device_id 时补齐 client_metadata 安装标识,与真实 Codex 对齐(compact 形态不同,跳过)。
  499. if !isCompactRequest && applyCodexClientMetadata(decoded, account) {
  500. markDecodedModified()
  501. }
  502. if currentClientPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok {
  503. clientPromptCacheKey = currentClientPromptCacheKey
  504. }
  505. // Account namespace is orthogonal to fingerprint convergence: preserve
  506. // each client's identity cardinality, but never reuse it across OAuth
  507. // credentials after scheduler failover.
  508. if !isCompactRequest && applyCodexAccountIdentityClientMetadataMap(decoded, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) {
  509. markDecodedModified()
  510. }
  511. stageCodexFingerprintIDs(c, nil)
  512. // 指纹收敛:一次性解析收敛 ID,请求体和出站头共享同一份 IDs(保证 turn_id 等随机字段一致)。
  513. // fingerprintIDs 在此处解析,后续 buildUpstreamRequest 中使用同一份。
  514. if !isCompactRequest {
  515. var clientHeaders http.Header
  516. if c != nil && c.Request != nil {
  517. clientHeaders = c.Request.Header
  518. }
  519. fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders)
  520. if fpIDs != nil {
  521. if applyCodexFingerprintClientMetadata(decoded, fpIDs) {
  522. markDecodedModified()
  523. }
  524. }
  525. // 将 fpIDs 存入 gin context,供 buildUpstreamRequest 中头改写使用。
  526. // 无条件覆写(含 nil):failover 从收敛账号切到 off 账号时,上一
  527. // 账号的 IDs 不得残留(stageCodexFingerprintIDs 注释)。
  528. stageCodexFingerprintIDs(c, fpIDs)
  529. }
  530. if codexResult.NormalizedModel != "" {
  531. upstreamModel = codexResult.NormalizedModel
  532. }
  533. if strings.TrimSpace(clientPromptCacheKey) != "" {
  534. // The body now carries an account-scoped value. Keep the original here
  535. // so the header builder derives the same namespace exactly once.
  536. promptCacheKey = clientPromptCacheKey
  537. } else if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" {
  538. // Fingerprint convergence may inject a default key when the client did
  539. // not provide one; preserve that existing fallback.
  540. promptCacheKey = currentPromptCacheKey
  541. } else if codexResult.PromptCacheKey != "" {
  542. promptCacheKey = codexResult.PromptCacheKey
  543. }
  544. }
  545. if !SupportsVerbosity(upstreamModel) && gjson.GetBytes(body, "text.verbosity").Exists() {
  546. markPatchDelete("text.verbosity")
  547. }
  548. if !isCodexCLI {
  549. maxOutputTokens := gjson.GetBytes(body, "max_output_tokens")
  550. if maxOutputTokens.Exists() {
  551. switch account.Platform {
  552. case PlatformOpenAI, PlatformDeepseek:
  553. // Preserve Responses-native output limits unless the selected upstream
  554. // explicitly rejects the field in the bounded HTTP retry loop below.
  555. case PlatformAnthropic:
  556. decoded, decodeErr := ensureReqBody()
  557. if decodeErr != nil {
  558. return nil, decodeErr
  559. }
  560. delete(decoded, "max_output_tokens")
  561. if _, hasMaxTokens := decoded["max_tokens"]; !hasMaxTokens {
  562. decoded["max_tokens"] = maxOutputTokens.Value()
  563. }
  564. markDecodedModified()
  565. case PlatformGemini:
  566. markPatchDelete("max_output_tokens")
  567. default:
  568. markPatchDelete("max_output_tokens")
  569. }
  570. }
  571. // /v1/responses 的规范输出上限字段是 max_output_tokens;部分客户端仍按
  572. // Chat Completions 习惯发送 max_tokens,兼容 Responses 上游会拒绝该字段(#4417)。
  573. // 仅对 OpenAI 平台归一化:Anthropic 合法使用 max_tokens,其 max_output_tokens
  574. // 反向转换已在上方 switch 中处理。
  575. if account.Platform == PlatformOpenAI {
  576. if maxTokens := gjson.GetBytes(body, "max_tokens"); maxTokens.Exists() {
  577. if !gjson.GetBytes(body, "max_output_tokens").Exists() {
  578. markPatchSet("max_output_tokens", maxTokens.Value())
  579. }
  580. markPatchDelete("max_tokens")
  581. }
  582. }
  583. if gjson.GetBytes(body, "max_completion_tokens").Exists() && (account.Type == AccountTypeAPIKey || account.Platform != PlatformOpenAI) {
  584. markPatchDelete("max_completion_tokens")
  585. }
  586. for _, unsupportedField := range []string{"prompt_cache_retention", "safety_identifier", "prompt_cache_options"} {
  587. if gjson.GetBytes(body, unsupportedField).Exists() {
  588. markPatchDelete(unsupportedField)
  589. }
  590. }
  591. }
  592. // Ollama Cloud(实际 Responses 上游为 ollama.com)输出上限 clamp:对 Codex 与
  593. // 非 Codex 客户端一律执行(真实 Codex 客户端同样会带超限 max_output_tokens 被
  594. // ollama.com 以 400 拒绝)。在 `!isCodexCLI` 归一化块之后独立调用:非 Codex 时
  595. // 位于平台字段归一化之后,不跳过原有平台 switch(patch 按追加顺序应用,set 在
  596. // 先前的 delete/set 之后生效);Codex 请求不做归一化,直接按 body 现值判定。
  597. if clampedCap, ok := ollamaCloudResponsesMaxOutputTokensClamp(account, upstreamModel, body); ok {
  598. markPatchSet("max_output_tokens", clampedCap)
  599. }
  600. if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 &&
  601. !account.IsOpenAIApiKey() && gjson.GetBytes(body, "previous_response_id").Exists() {
  602. markPatchDelete("previous_response_id")
  603. }
  604. if openAIRequestBodyMayContainEmptyBase64InputImage(body) {
  605. decoded, decodeErr := ensureReqBody()
  606. if decodeErr != nil {
  607. return nil, decodeErr
  608. }
  609. if sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(decoded) {
  610. markDecodedModified()
  611. }
  612. }
  613. rawTier := requestView.ServiceTier
  614. if openAIGroupForcesFast(ctx, account) {
  615. rawTier = OpenAIFastTierPriority
  616. if requestView.ServiceTier != OpenAIFastTierPriority {
  617. markPatchSet("service_tier", OpenAIFastTierPriority)
  618. }
  619. }
  620. if rawTier != "" {
  621. if normTier := normalizedOpenAIServiceTierValue(rawTier); normTier != "" {
  622. action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, upstreamModel, normTier)
  623. switch action {
  624. case BetaPolicyActionBlock:
  625. msg := errMsg
  626. if msg == "" {
  627. msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, upstreamModel)
  628. }
  629. blocked := &OpenAIFastBlockedError{Message: msg}
  630. writeOpenAIFastPolicyBlockedResponse(c, blocked)
  631. return nil, blocked
  632. case BetaPolicyActionFilter:
  633. markPatchDelete("service_tier")
  634. case OpenAIFastPolicyActionForcePriority:
  635. if rawTier != OpenAIFastTierPriority {
  636. markPatchSet("service_tier", OpenAIFastTierPriority)
  637. }
  638. default:
  639. if normTier != rawTier {
  640. markPatchSet("service_tier", normTier)
  641. }
  642. }
  643. }
  644. }
  645. if account.UsesOpenAICodexProtocol() {
  646. decoded, decodeErr := ensureReqBody()
  647. if decodeErr != nil {
  648. return nil, decodeErr
  649. }
  650. if input, ok := decoded["input"].([]any); ok && sanitizeOpenAIResponsesOrphanToolOutputs(
  651. decoded,
  652. input,
  653. strings.TrimSpace(firstNonEmptyString(decoded["previous_response_id"])) != "",
  654. ) {
  655. markDecodedModified()
  656. }
  657. }
  658. if reqBody != nil || openAIResponsesInputMayNeedTruncation(body) {
  659. decoded, decodeErr := ensureReqBody()
  660. if decodeErr != nil {
  661. return nil, decodeErr
  662. }
  663. if truncateOpenAIResponsesInputText(decoded) {
  664. markDecodedModified()
  665. }
  666. }
  667. if bodyModified {
  668. if requestView.HasPatches() {
  669. if patchedBody, patchErr := requestView.ApplyPatches(); patchErr == nil {
  670. body = patchedBody
  671. requestView = newOpenAIRequestView(body)
  672. reqBody = nil
  673. bodyModified = false
  674. }
  675. }
  676. if bodyModified {
  677. decoded, decodeErr := ensureReqBody()
  678. if decodeErr != nil {
  679. return nil, decodeErr
  680. }
  681. var marshalErr error
  682. body, marshalErr = marshalOpenAIUpstreamJSON(decoded)
  683. if marshalErr != nil {
  684. return nil, fmt.Errorf("serialize request body: %w", marshalErr)
  685. }
  686. requestView = newOpenAIRequestView(body)
  687. }
  688. }
  689. // Run after orphan-output filtering and all request-map rebuilds so a
  690. // compaction trigger cannot remain ahead of surviving history items.
  691. if normalizedBody, changed, normalizeErr := NormalizeCompactionTriggerInputOrder(body); normalizeErr != nil {
  692. return nil, fmt.Errorf("normalize compaction trigger order: %w", normalizeErr)
  693. } else if changed {
  694. body = normalizedBody
  695. requestView = newOpenAIRequestView(body)
  696. reqBody = nil
  697. }
  698. // 剥离本会话已被上游判定失效的加密项(invalid_encrypted_content lineage),
  699. // 阻断同一失效密文随客户端历史在每一轮重复触发"被拒→剥离→重试/重连"。
  700. // lineage 会话键统一按进场形态的 body 派生:后续重试可能改写 body,
  701. // 延迟计算会与下一请求的进场键漂移。
  702. lineageGroupID := getOpenAIGroupIDFromContext(c)
  703. lineageEntryBody := body
  704. lineageSessionHash := ""
  705. if stateStore := s.getOpenAIWSStateStore(); stateStore != nil && stateStore.HasAnySessionInvalidEncryptedContent() {
  706. lineageSessionHash = s.GenerateSessionHash(c, body)
  707. if invalidDigests := stateStore.GetSessionInvalidEncryptedContentDigests(lineageGroupID, lineageSessionHash); len(invalidDigests) > 0 {
  708. strippedBody, strippedCount := s.stripSessionInvalidEncryptedContentLogged(
  709. body, invalidDigests, "invalid_encrypted_lineage_strip", account.ID, 0,
  710. )
  711. if strippedCount > 0 {
  712. body = strippedBody
  713. requestView = newOpenAIRequestView(body)
  714. reqBody = nil
  715. }
  716. }
  717. }
  718. imageBillingModel := ""
  719. imageSizeTier := ""
  720. imageInputSize := ""
  721. if imageIntent {
  722. var imageCfg OpenAIResponsesImageBillingConfig
  723. var imageCfgErr error
  724. if reqBody != nil {
  725. imageCfg, imageCfgErr = resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, billingModel)
  726. } else {
  727. imageCfg, imageCfgErr = resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, billingModel)
  728. }
  729. if imageCfgErr != nil {
  730. setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "")
  731. c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": imageCfgErr.Error(), "param": "size"}})
  732. return nil, imageCfgErr
  733. }
  734. imageBillingModel = imageCfg.Model
  735. imageSizeTier = imageCfg.SizeTier
  736. imageInputSize = imageCfg.InputSize
  737. }
  738. // Get access token
  739. token, _, err := s.GetAccessToken(ctx, account)
  740. if err != nil {
  741. return nil, err
  742. }
  743. SetOpsUpstreamModel(c, upstreamModel)
  744. // 命中 WS 时仅走 WebSocket Mode;不再自动回退 HTTP。
  745. if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 {
  746. // WS 分支需要结构化 payload 与重连恢复,命中后再触发 full-map decode。
  747. wsReqBody, err := ensureReqBody()
  748. if err != nil {
  749. return nil, err
  750. }
  751. _, hasPreviousResponseID := wsReqBody["previous_response_id"]
  752. logOpenAIWSModeDebug(
  753. "forward_start account_id=%d account_type=%s model=%s stream=%v has_previous_response_id=%v",
  754. account.ID,
  755. account.Type,
  756. upstreamModel,
  757. reqStream,
  758. hasPreviousResponseID,
  759. )
  760. maxAttempts := openAIWSReconnectRetryLimit + 1
  761. wsAttempts := 0
  762. var wsResult *OpenAIForwardResult
  763. var wsErr error
  764. wsLastFailureReason := ""
  765. agentTaskRecoveryTried := false
  766. wsPrevResponseRecoveryTried := false
  767. wsInvalidEncryptedContentRecoveryTried := false
  768. recoverPrevResponseNotFound := func(attempt int) bool {
  769. if wsPrevResponseRecoveryTried {
  770. return false
  771. }
  772. previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id")
  773. if previousResponseID == "" {
  774. logOpenAIWSModeInfo(
  775. "reconnect_prev_response_recovery_skip account_id=%d attempt=%d reason=missing_previous_response_id previous_response_id_present=false",
  776. account.ID,
  777. attempt,
  778. )
  779. return false
  780. }
  781. if HasFunctionCallOutput(wsReqBody) {
  782. logOpenAIWSModeInfo(
  783. "reconnect_prev_response_recovery_skip account_id=%d attempt=%d reason=has_function_call_output previous_response_id_present=true",
  784. account.ID,
  785. attempt,
  786. )
  787. return false
  788. }
  789. delete(wsReqBody, "previous_response_id")
  790. wsPrevResponseRecoveryTried = true
  791. logOpenAIWSModeInfo(
  792. "reconnect_prev_response_recovery account_id=%d attempt=%d action=drop_previous_response_id retry=1 previous_response_id=%s previous_response_id_kind=%s",
  793. account.ID,
  794. attempt,
  795. truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
  796. normalizeOpenAIWSLogValue(ClassifyOpenAIPreviousResponseIDKind(previousResponseID)),
  797. )
  798. return true
  799. }
  800. recoverInvalidEncryptedContent := func(attempt int) bool {
  801. if isMode1ProtectionEnabled(account) {
  802. return false
  803. }
  804. if wsInvalidEncryptedContentRecoveryTried {
  805. return false
  806. }
  807. // 写入 lineage 后,同一失效密文在后续 turn 进场时被预剥离,不再重复
  808. // 触发上游拒绝与重连。摘要取自进场形态的 body(密文项只可能来自
  809. // 客户端进场请求,重复摘要幂等)。
  810. invalidDigests := collectOpenAIEncryptedContentDigestsRaw(lineageEntryBody)
  811. removedReasoningItems := trimOpenAIEncryptedReasoningItems(wsReqBody)
  812. if !removedReasoningItems {
  813. logOpenAIWSModeInfo(
  814. "reconnect_invalid_encrypted_content_recovery_skip account_id=%d attempt=%d reason=missing_encrypted_reasoning_items",
  815. account.ID,
  816. attempt,
  817. )
  818. return false
  819. }
  820. if len(invalidDigests) > 0 {
  821. if lineageSessionHash == "" {
  822. lineageSessionHash = s.GenerateSessionHash(c, lineageEntryBody)
  823. }
  824. s.markOpenAIWSInvalidEncryptedContentLineage(lineageGroupID, lineageSessionHash, invalidDigests)
  825. }
  826. previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id")
  827. hasFunctionCallOutput := HasFunctionCallOutput(wsReqBody)
  828. if previousResponseID != "" && !hasFunctionCallOutput {
  829. delete(wsReqBody, "previous_response_id")
  830. }
  831. wsInvalidEncryptedContentRecoveryTried = true
  832. logOpenAIWSModeInfo(
  833. "reconnect_invalid_encrypted_content_recovery account_id=%d attempt=%d action=drop_encrypted_reasoning_items retry=1 previous_response_id_present=%v previous_response_id=%s previous_response_id_kind=%s has_function_call_output=%v dropped_previous_response_id=%v",
  834. account.ID,
  835. attempt,
  836. previousResponseID != "",
  837. truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
  838. normalizeOpenAIWSLogValue(ClassifyOpenAIPreviousResponseIDKind(previousResponseID)),
  839. hasFunctionCallOutput,
  840. previousResponseID != "" && !hasFunctionCallOutput,
  841. )
  842. return true
  843. }
  844. retryBudget := s.openAIWSRetryTotalBudget()
  845. retryStartedAt := time.Now()
  846. wsRetryLoop:
  847. for attempt := 1; attempt <= maxAttempts; attempt++ {
  848. wsAttempts = attempt
  849. wsResult, wsErr = s.forwardOpenAIWSV2(
  850. ctx,
  851. c,
  852. account,
  853. wsReqBody,
  854. clientPromptCacheKey,
  855. token,
  856. wsDecision,
  857. isCodexCLI,
  858. reqStream,
  859. originalModel,
  860. upstreamModel,
  861. startTime,
  862. attempt,
  863. wsLastFailureReason,
  864. &agentTaskRecoveryTried,
  865. )
  866. if wsErr == nil {
  867. break
  868. }
  869. if c != nil && c.Writer != nil && c.Writer.Written() {
  870. break
  871. }
  872. var taskRecoveredErr *agentIdentityTaskRecoveredError
  873. if errors.As(wsErr, &taskRecoveredErr) {
  874. continue
  875. }
  876. reason, retryable := classifyOpenAIWSReconnectReason(wsErr)
  877. if reason != "" {
  878. wsLastFailureReason = reason
  879. }
  880. // previous_response_not_found 说明续链锚点不可用:
  881. // 对非 function_call_output 场景,允许一次“去掉 previous_response_id 后重放”。
  882. if reason == "previous_response_not_found" && recoverPrevResponseNotFound(attempt) {
  883. continue
  884. }
  885. if reason == "invalid_encrypted_content" && recoverInvalidEncryptedContent(attempt) {
  886. continue
  887. }
  888. if retryable && attempt < maxAttempts {
  889. backoff := s.openAIWSRetryBackoff(attempt)
  890. if retryBudget > 0 && time.Since(retryStartedAt)+backoff > retryBudget {
  891. s.recordOpenAIWSRetryExhausted()
  892. logOpenAIWSModeInfo(
  893. "reconnect_budget_exhausted account_id=%d attempts=%d max_retries=%d reason=%s elapsed_ms=%d budget_ms=%d",
  894. account.ID,
  895. attempt,
  896. openAIWSReconnectRetryLimit,
  897. normalizeOpenAIWSLogValue(reason),
  898. time.Since(retryStartedAt).Milliseconds(),
  899. retryBudget.Milliseconds(),
  900. )
  901. break
  902. }
  903. s.recordOpenAIWSRetryAttempt(backoff)
  904. logOpenAIWSModeInfo(
  905. "reconnect_retry account_id=%d retry=%d max_retries=%d reason=%s backoff_ms=%d",
  906. account.ID,
  907. attempt,
  908. openAIWSReconnectRetryLimit,
  909. normalizeOpenAIWSLogValue(reason),
  910. backoff.Milliseconds(),
  911. )
  912. if backoff > 0 {
  913. timer := time.NewTimer(backoff)
  914. select {
  915. case <-ctx.Done():
  916. if !timer.Stop() {
  917. <-timer.C
  918. }
  919. wsErr = wrapOpenAIWSFallback("retry_backoff_canceled", ctx.Err())
  920. break wsRetryLoop
  921. case <-timer.C:
  922. }
  923. }
  924. continue
  925. }
  926. if retryable {
  927. s.recordOpenAIWSRetryExhausted()
  928. logOpenAIWSModeInfo(
  929. "reconnect_exhausted account_id=%d attempts=%d max_retries=%d reason=%s",
  930. account.ID,
  931. attempt,
  932. openAIWSReconnectRetryLimit,
  933. normalizeOpenAIWSLogValue(reason),
  934. )
  935. } else if reason != "" {
  936. s.recordOpenAIWSNonRetryableFastFallback()
  937. logOpenAIWSModeInfo(
  938. "reconnect_stop account_id=%d attempt=%d reason=%s",
  939. account.ID,
  940. attempt,
  941. normalizeOpenAIWSLogValue(reason),
  942. )
  943. }
  944. break
  945. }
  946. if wsErr == nil {
  947. firstTokenMs := int64(0)
  948. hasFirstTokenMs := wsResult != nil && wsResult.FirstTokenMs != nil
  949. if hasFirstTokenMs {
  950. firstTokenMs = int64(*wsResult.FirstTokenMs)
  951. }
  952. requestID := ""
  953. if wsResult != nil {
  954. requestID = strings.TrimSpace(wsResult.RequestID)
  955. }
  956. logOpenAIWSModeDebug(
  957. "forward_succeeded account_id=%d request_id=%s stream=%v has_first_token_ms=%v first_token_ms=%d ws_attempts=%d",
  958. account.ID,
  959. requestID,
  960. reqStream,
  961. hasFirstTokenMs,
  962. firstTokenMs,
  963. wsAttempts,
  964. )
  965. wsResult.UpstreamModel = upstreamModel
  966. if wsResult.BillingModel == "" {
  967. wsResult.BillingModel = billingModel
  968. }
  969. if wsResult.ImageCount > 0 {
  970. wsResult.ImageSize = imageSizeTier
  971. wsResult.ImageInputSize = imageInputSize
  972. wsResult.BillingModel = imageBillingModel
  973. }
  974. return wsResult, nil
  975. }
  976. s.writeOpenAIWSFallbackErrorResponse(c, account, wsErr)
  977. return nil, wsErr
  978. }
  979. reasoningEffort := extractOpenAIReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel)
  980. // 国产模型默认 effort 补充:此处 reqModel 已被 mapping 重写为 billingModel。
  981. reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, reqModel)
  982. reasoningEffortValue := ""
  983. if reasoningEffort != nil {
  984. reasoningEffortValue = *reasoningEffort
  985. }
  986. firstOutputTimeout := time.Duration(0)
  987. if reqStream && account.Platform == PlatformOpenAI {
  988. firstOutputTimeout = s.openAIFirstOutputTimeout(reasoningEffortValue)
  989. }
  990. httpInvalidEncryptedContentRetryTried := false
  991. compactModelFallbackRetried := false
  992. agentTaskRecoveryTried := false
  993. rejectedFieldRetryState := openAIResponsesRejectedFieldRetryStateForRequest(c, body)
  994. for {
  995. // Build upstream request
  996. upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
  997. var headerGuard *openAIFirstOutputHeaderGuard
  998. if firstOutputTimeout > 0 {
  999. upstreamCtx, headerGuard = newOpenAIFirstOutputHeaderGuard(
  1000. upstreamCtx, releaseUpstreamCtx, startTime.Add(firstOutputTimeout),
  1001. )
  1002. }
  1003. upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, reqStream, promptCacheKey, isCodexCLI)
  1004. if headerGuard == nil {
  1005. releaseUpstreamCtx()
  1006. }
  1007. if err != nil {
  1008. if headerGuard != nil {
  1009. headerGuard.close()
  1010. }
  1011. return nil, err
  1012. }
  1013. // Get proxy URL
  1014. proxyURL := ""
  1015. if account.ProxyID != nil && account.Proxy != nil {
  1016. proxyURL = account.Proxy.URL()
  1017. }
  1018. // Send request
  1019. upstreamStart := time.Now()
  1020. resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
  1021. SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
  1022. if headerGuard != nil && headerGuard.stopHeaderWait() {
  1023. if resp != nil && resp.Body != nil {
  1024. _ = resp.Body.Close()
  1025. }
  1026. headerGuard.close()
  1027. return nil, s.newOpenAIFirstOutputTimeoutError(
  1028. ctx, c, account, opsUpstreamProxyID(account), opsUpstreamProxyName(account),
  1029. startTime, originalModel, reasoningEffortValue,
  1030. firstOutputTimeout, "response_headers", nil,
  1031. )
  1032. }
  1033. if err != nil {
  1034. if resp != nil && resp.Body != nil {
  1035. _ = resp.Body.Close()
  1036. }
  1037. if headerGuard != nil {
  1038. headerGuard.close()
  1039. }
  1040. // Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to
  1041. // a failover so the handler switches to a healthy account, and temporarily
  1042. // unschedule the account on durable faults (e.g. rejected proxy credentials).
  1043. return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
  1044. }
  1045. if headerGuard != nil {
  1046. resp.Body = &openAIRequestContextReadCloser{ReadCloser: resp.Body, cleanup: headerGuard.close}
  1047. }
  1048. // Handle error response
  1049. if resp.StatusCode >= 400 {
  1050. respBody := s.readUpstreamErrorBody(resp)
  1051. _ = resp.Body.Close()
  1052. resp.Body = io.NopCloser(bytes.NewReader(respBody))
  1053. upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
  1054. upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
  1055. upstreamCode := extractUpstreamErrorCode(respBody)
  1056. if !agentTaskRecoveryTried && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, respBody) {
  1057. agentTaskRecoveryTried = true
  1058. expectedTaskID := account.GetCredential("task_id")
  1059. if err := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); err != nil {
  1060. return nil, fmt.Errorf("agent identity task recovery failed: %w", err)
  1061. }
  1062. continue
  1063. }
  1064. respBody = s.redactAgentIdentitySensitiveBody(ctx, account, respBody)
  1065. resp.Body = io.NopCloser(bytes.NewReader(respBody))
  1066. if !isMode1ProtectionEnabled(account) && !httpInvalidEncryptedContentRetryTried && resp.StatusCode == http.StatusBadRequest && upstreamCode == "invalid_encrypted_content" {
  1067. decoded, decodeErr := ensureReqBody()
  1068. if decodeErr != nil {
  1069. return nil, decodeErr
  1070. }
  1071. invalidDigests := collectOpenAIEncryptedContentDigestsRaw(lineageEntryBody)
  1072. if trimOpenAIEncryptedReasoningItems(decoded) {
  1073. body, err = marshalOpenAIUpstreamJSON(decoded)
  1074. if err != nil {
  1075. return nil, fmt.Errorf("serialize invalid_encrypted_content retry body: %w", err)
  1076. }
  1077. if len(invalidDigests) > 0 {
  1078. if lineageSessionHash == "" {
  1079. lineageSessionHash = s.GenerateSessionHash(c, lineageEntryBody)
  1080. }
  1081. s.markOpenAIWSInvalidEncryptedContentLineage(lineageGroupID, lineageSessionHash, invalidDigests)
  1082. }
  1083. httpInvalidEncryptedContentRetryTried = true
  1084. rejectedFieldRetryState.remember(body)
  1085. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request once after invalid_encrypted_content (account: %s)", account.Name)
  1086. continue
  1087. }
  1088. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Skip non-WSv2 invalid_encrypted_content retry because encrypted reasoning items are missing (account: %s)", account.Name)
  1089. }
  1090. if retryBody, reason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, respBody); retryErr != nil {
  1091. return nil, fmt.Errorf("normalize rejected Responses field retry body: %w", retryErr)
  1092. } else if changed && rejectedFieldRetryState.Allow(retryBody) {
  1093. body = retryBody
  1094. requestView = newOpenAIRequestView(body)
  1095. reqBody = nil
  1096. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request after %s (account: %s)", reason, account.Name)
  1097. continue
  1098. }
  1099. if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
  1100. c, account, requestedModel, body, resp.StatusCode, upstreamMsg, respBody, compactModelFallbackRetried,
  1101. ); retry {
  1102. s.appendOpenAICompactFallbackRetryOps(c, account, resp, respBody, upstreamMsg, false)
  1103. fromModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
  1104. body = retryBody
  1105. requestView = newOpenAIRequestView(body)
  1106. reqBody = nil
  1107. upstreamModel = fallbackModel
  1108. compactModelFallbackRetried = true
  1109. SetOpsUpstreamModel(c, fallbackModel)
  1110. logger.LegacyPrintf(
  1111. "service.openai_gateway",
  1112. "[OpenAI] Retrying explicit compact request once with fallback model (account: %s, from: %s, to: %s, upstream_code: %s)",
  1113. account.Name, fromModel, fallbackModel, upstreamCode,
  1114. )
  1115. continue
  1116. }
  1117. if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) {
  1118. upstreamDetail := ""
  1119. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  1120. maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
  1121. if maxBytes <= 0 {
  1122. maxBytes = 2048
  1123. }
  1124. upstreamDetail = truncateString(string(respBody), maxBytes)
  1125. }
  1126. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  1127. ProxyID: opsUpstreamProxyID(account),
  1128. ProxyName: opsUpstreamProxyName(account),
  1129. Platform: account.Platform,
  1130. AccountID: account.ID,
  1131. AccountName: account.Name,
  1132. UpstreamStatusCode: resp.StatusCode,
  1133. UpstreamRequestID: resp.Header.Get("x-request-id"),
  1134. Kind: "failover",
  1135. Message: upstreamMsg,
  1136. Detail: upstreamDetail,
  1137. })
  1138. shouldDisable := s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
  1139. return nil, s.newOpenAIAccountFailoverError(
  1140. account,
  1141. resp.StatusCode,
  1142. resp.Header,
  1143. respBody,
  1144. upstreamMsg,
  1145. shouldDisable,
  1146. !shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
  1147. )
  1148. }
  1149. return s.handleErrorResponse(ctx, resp, c, account, body, resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel))
  1150. }
  1151. defer func() { _ = resp.Body.Close() }()
  1152. if mapping, ok := openAIResponsesClientToolMapping(c); ok && isEventStreamResponse(resp.Header) {
  1153. maxLineSize := defaultMaxLineSize
  1154. if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
  1155. maxLineSize = s.cfg.Gateway.MaxLineSize
  1156. }
  1157. resp.Body = newResponsesClientToolStreamBody(resp.Body, mapping, maxLineSize)
  1158. }
  1159. serviceTier := extractOpenAIServiceTierFromBody(body)
  1160. // 上游接受后只保留计费需要的标量,避免响应处理期间继续保活完整 input/tools map。
  1161. reqBody = nil
  1162. // Handle normal response
  1163. var usage *OpenAIUsage
  1164. var firstTokenMs *int
  1165. responseID := ""
  1166. imageCount := 0
  1167. searchCount := 0
  1168. var imageOutputSizes []string
  1169. if reqStream {
  1170. streamResult, err := s.handleStreamingResponseWithReasoning(ctx, resp, c, account, startTime, originalModel, upstreamModel, reasoningEffortValue)
  1171. if err != nil {
  1172. if signal, ok := asOpenAICompactFallbackSignal(err); ok {
  1173. if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
  1174. c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried,
  1175. ); retry {
  1176. s.appendOpenAICompactFallbackRetryOps(c, account, resp, signal.payload, signal.message, false)
  1177. body = retryBody
  1178. requestView = newOpenAIRequestView(body)
  1179. upstreamModel = fallbackModel
  1180. compactModelFallbackRetried = true
  1181. SetOpsUpstreamModel(c, fallbackModel)
  1182. continue
  1183. }
  1184. if resp.Body != nil {
  1185. _ = resp.Body.Close()
  1186. }
  1187. compactResp, compactBody := openAICompactFallbackErrorResponse(resp, signal)
  1188. if s.shouldFailoverOpenAIUpstreamResponse(account, compactResp.StatusCode, signal.message, compactBody) {
  1189. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  1190. ProxyID: opsUpstreamProxyID(account),
  1191. ProxyName: opsUpstreamProxyName(account),
  1192. Platform: account.Platform,
  1193. AccountID: account.ID,
  1194. AccountName: account.Name,
  1195. UpstreamStatusCode: compactResp.StatusCode,
  1196. UpstreamRequestID: compactResp.Header.Get("x-request-id"),
  1197. Kind: "failover",
  1198. Message: signal.message,
  1199. })
  1200. shouldDisable := s.handleFailoverSideEffects(ctx, compactResp, account, compactBody, upstreamModel)
  1201. return nil, s.newOpenAIAccountFailoverError(
  1202. account, compactResp.StatusCode, compactResp.Header, compactBody, signal.message, shouldDisable,
  1203. !shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(compactResp.StatusCode) || isOpenAITransientProcessingError(compactResp.StatusCode, signal.message, compactBody)),
  1204. )
  1205. }
  1206. return s.handleErrorResponse(ctx, compactResp, c, account, body, resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel))
  1207. }
  1208. return nil, err
  1209. }
  1210. usage = streamResult.usage
  1211. firstTokenMs = streamResult.firstTokenMs
  1212. responseID = strings.TrimSpace(streamResult.responseID)
  1213. imageCount = streamResult.imageCount
  1214. imageOutputSizes = streamResult.imageOutputSizes
  1215. searchCount = streamResult.searchCount
  1216. } else {
  1217. nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel)
  1218. if err != nil {
  1219. if signal, ok := asOpenAICompactFallbackSignal(err); ok {
  1220. if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
  1221. c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried,
  1222. ); retry {
  1223. s.appendOpenAICompactFallbackRetryOps(c, account, resp, signal.payload, signal.message, false)
  1224. body = retryBody
  1225. requestView = newOpenAIRequestView(body)
  1226. upstreamModel = fallbackModel
  1227. compactModelFallbackRetried = true
  1228. SetOpsUpstreamModel(c, fallbackModel)
  1229. continue
  1230. }
  1231. }
  1232. return nil, err
  1233. }
  1234. usage = nonStreamResult.usage
  1235. responseID = strings.TrimSpace(nonStreamResult.responseID)
  1236. imageCount = nonStreamResult.imageCount
  1237. imageOutputSizes = nonStreamResult.imageOutputSizes
  1238. searchCount = nonStreamResult.searchCount
  1239. }
  1240. s.bindHTTPResponseAccount(ctx, c, account, responseID)
  1241. // Extract and save Codex usage snapshot from response headers (for OAuth accounts).
  1242. // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
  1243. if account.UsesOpenAICodexProtocol() && !account.IsShadow() {
  1244. if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil {
  1245. s.updateCodexUsageSnapshot(ctx, account.ID, snapshot)
  1246. }
  1247. } else if account.IsShadow() && account.ParentAccountID != nil {
  1248. notifyOpenAIAutoReset(*account.ParentAccountID)
  1249. }
  1250. if usage == nil {
  1251. usage = &OpenAIUsage{}
  1252. }
  1253. forwardResult := &OpenAIForwardResult{
  1254. RequestID: resp.Header.Get("x-request-id"),
  1255. UpstreamHeaders: resp.Header,
  1256. ResponseID: responseID,
  1257. Usage: *usage,
  1258. Model: originalModel,
  1259. BillingModel: billingModel,
  1260. UpstreamModel: upstreamModel,
  1261. UpstreamResponseModel: observedUpstreamResponseModel(c),
  1262. UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
  1263. UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
  1264. ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier),
  1265. ReasoningEffort: reasoningEffort,
  1266. Stream: reqStream,
  1267. OpenAIWSMode: false,
  1268. Duration: time.Since(startTime),
  1269. FirstTokenMs: firstTokenMs,
  1270. }
  1271. if imageCount > 0 {
  1272. forwardResult.ImageCount = imageCount
  1273. forwardResult.ImageSize = imageSizeTier
  1274. forwardResult.ImageInputSize = imageInputSize
  1275. forwardResult.ImageOutputSizes = imageOutputSizes
  1276. forwardResult.BillingModel = imageBillingModel
  1277. }
  1278. // Grok-native web_search / x_search / tool_search tool invocations (per-1k pricing).
  1279. // Token cost still applies separately when usage is present; search is additive only
  1280. // when search_price_per_1k is configured (nil price → $0 from CalculateSearchCost).
  1281. if searchCount > 0 && account != nil && account.IsGrok() {
  1282. forwardResult.SearchCount = searchCount
  1283. }
  1284. return forwardResult, nil
  1285. }
  1286. }
  1287. func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool {
  1288. if account == nil || account.Type != AccountTypeAPIKey {
  1289. return false
  1290. }
  1291. if account.IsCNProvider() {
  1292. // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek / Kimi
  1293. // 有原生 Responses,GLM 回退 Chat Completions。
  1294. switch account.GetAPIProtocol() {
  1295. case APIProtocolChatCompletions:
  1296. return true
  1297. case APIProtocolAdaptive:
  1298. return !account.SupportsNativeCNResponses()
  1299. default:
  1300. return false
  1301. }
  1302. }
  1303. return !openai_compat.ShouldUseResponsesAPI(account.Extra)
  1304. }
  1305. func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) {
  1306. if err := validateMode1StagedRequest(c, account, body); err != nil {
  1307. return nil, err
  1308. }
  1309. // Determine target URL based on account type
  1310. var targetURL string
  1311. switch account.Type {
  1312. case AccountTypeOAuth:
  1313. // OAuth accounts use ChatGPT internal API
  1314. targetURL = chatgptCodexURL
  1315. case AccountTypeSetupToken:
  1316. if account.IsOpenAIOAuthLike() {
  1317. targetURL = chatgptCodexURL
  1318. } else {
  1319. targetURL = openaiPlatformAPIURL
  1320. }
  1321. case AccountTypeAPIKey:
  1322. // API Key accounts use Platform API or custom base URL
  1323. baseURL := account.GetOpenAIBaseURL()
  1324. if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() {
  1325. baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses)
  1326. }
  1327. if baseURL == "" {
  1328. targetURL = openaiPlatformAPIURL
  1329. } else {
  1330. validatedURL, err := s.validateUpstreamBaseURL(baseURL)
  1331. if err != nil {
  1332. return nil, err
  1333. }
  1334. targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL)
  1335. }
  1336. default:
  1337. targetURL = openaiPlatformAPIURL
  1338. }
  1339. targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c))
  1340. // DeepSeek / Kimi 原生 Responses 端点为无状态实现:强制 store=false、清除
  1341. // previous_response_id,避免携带状态字段被上游拒绝。
  1342. body = normalizeDeepSeekResponsesRequestBody(account, body)
  1343. req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
  1344. if err != nil {
  1345. return nil, err
  1346. }
  1347. req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
  1348. // Build authentication for this request. Agent Identity signs a fresh
  1349. // assertion here; OAuth/PAT/API-key keep their existing Bearer behavior.
  1350. authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
  1351. if err != nil {
  1352. return nil, fmt.Errorf("build openai authentication headers: %w", err)
  1353. }
  1354. for key, values := range authHeaders {
  1355. for _, value := range values {
  1356. req.Header.Add(key, value)
  1357. }
  1358. }
  1359. // Set headers specific to OAuth accounts (ChatGPT internal API)
  1360. if account.UsesOpenAICodexProtocol() {
  1361. // Required: set Host for ChatGPT API (must use req.Host, not Header.Set)
  1362. req.Host = "chatgpt.com"
  1363. if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
  1364. return nil, fmt.Errorf("resolve chatgpt account headers: %w", err)
  1365. }
  1366. }
  1367. // Whitelist passthrough headers
  1368. for key, values := range c.Request.Header {
  1369. lowerKey := strings.ToLower(key)
  1370. if openaiAllowedHeaders[lowerKey] {
  1371. for _, v := range values {
  1372. req.Header.Add(key, v)
  1373. }
  1374. }
  1375. }
  1376. // 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号),
  1377. // 剥离后再出站——异账号 blob 与本账号的(指纹收敛后)出站身份自相矛盾。
  1378. s.guardOpenAICodexTurnStateEcho(c, account, req.Header)
  1379. if account.UsesOpenAICodexProtocol() {
  1380. compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body)
  1381. // 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。
  1382. clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id"))
  1383. req.Header.Del("conversation_id")
  1384. req.Header.Del("session_id")
  1385. if compatMessagesBridge {
  1386. req.Header.Del("OpenAI-Beta")
  1387. req.Header.Del("originator")
  1388. } else {
  1389. req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI))
  1390. }
  1391. apiKeyID := getAPIKeyIDFromContext(c)
  1392. if isOpenAIResponsesCompactPath(c) {
  1393. req.Header.Set("accept", "application/json")
  1394. if req.Header.Get("version") == "" {
  1395. req.Header.Set("version", CodexCanonicalClientVersion())
  1396. }
  1397. compactSession := resolveOpenAICompactSessionID(c)
  1398. req.Header.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), compactSession))
  1399. } else {
  1400. req.Header.Set("accept", "text/event-stream")
  1401. }
  1402. if promptCacheKey != "" {
  1403. isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey)
  1404. req.Header.Set("session_id", isolated)
  1405. if !compatMessagesBridge || clientConversationID != "" {
  1406. req.Header.Set("conversation_id", isolated)
  1407. }
  1408. }
  1409. } else if isOpenAIResponsesCompactPath(c) {
  1410. // compact 上游是 unary JSON 协议:API-key 账号也显式声明 Accept,
  1411. // 避免 OpenAI 兼容网关按 SSE 返回(#3777 期望行为 4)。
  1412. req.Header.Set("accept", "application/json")
  1413. }
  1414. // Apply custom User-Agent if configured
  1415. customUA := account.GetOpenAIUserAgent()
  1416. if customUA != "" {
  1417. req.Header.Set("user-agent", customUA)
  1418. }
  1419. // 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为规范 Codex 身份。
  1420. // 用于网关未透传/改写 User-Agent 时,仍能命中 Codex 侧识别逻辑。
  1421. if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
  1422. req.Header.Set("user-agent", CodexCanonicalUserAgent())
  1423. }
  1424. // 账号 namespace 不改变客户端身份基数,但确保 scheduler failover 后不会把
  1425. // 同一组 Codex IDs 发送给另一份 OAuth 凭据。可选指纹收敛随后仍可覆盖这些值。
  1426. applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
  1427. // 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。
  1428. applyStagedCodexFingerprintHeaders(c, account, req.Header)
  1429. // 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。
  1430. // 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。
  1431. if account.UsesOpenAICodexProtocol() {
  1432. enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
  1433. }
  1434. // Ensure required headers exist
  1435. if req.Header.Get("content-type") == "" {
  1436. req.Header.Set("content-type", "application/json")
  1437. }
  1438. // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
  1439. account.ApplyHeaderOverrides(req.Header)
  1440. applyOpenCodeSessionHeader(c, account, targetURL, req.Header)
  1441. // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后,
  1442. // 保证不被覆盖丢失)。
  1443. applyOpenAICodexBetaFeatures(c, account, req.Header)
  1444. setOpenAICodexRoutingHintFromBody(req.Header, account, body)
  1445. logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http", req.Header, body, "not_applicable")
  1446. return req, nil
  1447. }
  1448. // codexIdentityOverrideUA 返回账号级显式配置的出站 User-Agent,供强制统一身份时作为覆写来源。
  1449. // ForceCodexCLI 语义是「强制使用 Codex CLI 身份」,等价于使用网关规范身份,故返回空串;
  1450. // 该优先级与历史行为一致(ForceCodexCLI 在账号自定义 UA 之后生效)。
  1451. func (s *OpenAIGatewayService) codexIdentityOverrideUA(account *Account) string {
  1452. if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
  1453. return ""
  1454. }
  1455. return account.GetOpenAIUserAgent()
  1456. }