openai_gateway_passthrough.go 92 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474
  1. package service
  2. // 本文件承载 /v1/responses 透传转发及其流式、非流式响应与错误处理。
  3. import (
  4. "bufio"
  5. "bytes"
  6. "context"
  7. "encoding/json"
  8. "errors"
  9. "fmt"
  10. "io"
  11. "net/http"
  12. "sort"
  13. "strconv"
  14. "strings"
  15. "time"
  16. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  17. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  18. "github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
  19. "github.com/gin-gonic/gin"
  20. "github.com/tidwall/gjson"
  21. "github.com/tidwall/sjson"
  22. "go.uber.org/zap"
  23. )
  24. const openAIResponsesClientToolMappingContextKey = "openai_responses_client_tool_mapping"
  25. func hasOpenAIResponsesClientToolMapping(mapping apicompat.ResponsesClientToolMapping) bool {
  26. return len(mapping.CustomTools) > 0 || mapping.ToolSearch || len(mapping.NamespaceTools) > 0
  27. }
  28. func adaptOpenAIResponsesClientTools(body []byte) ([]byte, apicompat.ResponsesClientToolMapping, error) {
  29. if !needsOpenAIResponsesClientToolAdaptation(body) {
  30. return body, apicompat.ResponsesClientToolMapping{}, nil
  31. }
  32. decoder := json.NewDecoder(bytes.NewReader(body))
  33. decoder.UseNumber()
  34. var requestBody map[string]any
  35. if err := decoder.Decode(&requestBody); err != nil {
  36. return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools: %w", err)
  37. }
  38. var trailingValue any
  39. if err := decoder.Decode(&trailingValue); !errors.Is(err, io.EOF) {
  40. if err == nil {
  41. err = errors.New("multiple JSON values")
  42. }
  43. return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools trailing data: %w", err)
  44. }
  45. mapping, changed, err := apicompat.AdaptResponsesClientTools(requestBody)
  46. if err != nil || !changed {
  47. return body, mapping, err
  48. }
  49. rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
  50. if err != nil {
  51. return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("encode OpenAI Responses client tools: %w", err)
  52. }
  53. return rebuilt, mapping, nil
  54. }
  55. func needsOpenAIResponsesClientToolAdaptation(body []byte) bool {
  56. needsAdaptation := false
  57. var visit func(gjson.Result) bool
  58. visit = func(value gjson.Result) bool {
  59. if value.IsObject() {
  60. switch strings.TrimSpace(value.Get("type").String()) {
  61. case "custom", "custom_tool_call", "custom_tool_call_output",
  62. "tool_search", "tool_search_call", "tool_search_output":
  63. needsAdaptation = true
  64. return false
  65. }
  66. }
  67. if value.IsObject() || value.IsArray() {
  68. value.ForEach(func(_, child gjson.Result) bool {
  69. return visit(child)
  70. })
  71. }
  72. return !needsAdaptation
  73. }
  74. visit(gjson.ParseBytes(body))
  75. return needsAdaptation
  76. }
  77. func openAIResponsesClientToolMapping(c *gin.Context) (apicompat.ResponsesClientToolMapping, bool) {
  78. if c == nil {
  79. return apicompat.ResponsesClientToolMapping{}, false
  80. }
  81. value, ok := c.Get(openAIResponsesClientToolMappingContextKey)
  82. mapping, typed := value.(apicompat.ResponsesClientToolMapping)
  83. return mapping, ok && typed && hasOpenAIResponsesClientToolMapping(mapping)
  84. }
  85. func setOpenAIResponsesClientToolMapping(c *gin.Context, mapping apicompat.ResponsesClientToolMapping) {
  86. if c == nil {
  87. return
  88. }
  89. if !hasOpenAIResponsesClientToolMapping(mapping) {
  90. clearOpenAIResponsesClientToolMapping(c)
  91. return
  92. }
  93. c.Set(openAIResponsesClientToolMappingContextKey, mapping)
  94. }
  95. // clearOpenAIResponsesClientToolMapping removes mapping state from the prior
  96. // forwarding attempt. Forward retries accounts on the same Gin context.
  97. func clearOpenAIResponsesClientToolMapping(c *gin.Context) {
  98. if c == nil {
  99. return
  100. }
  101. if _, exists := c.Get(openAIResponsesClientToolMappingContextKey); exists {
  102. c.Set(openAIResponsesClientToolMappingContextKey, apicompat.ResponsesClientToolMapping{})
  103. }
  104. }
  105. func restoreOpenAIResponsesClientToolPayload(c *gin.Context, payload []byte) ([]byte, error) {
  106. mapping, ok := openAIResponsesClientToolMapping(c)
  107. if !ok || !bytes.Contains(payload, []byte(`"function_call"`)) || !json.Valid(payload) {
  108. return payload, nil
  109. }
  110. restored, _, err := apicompat.RestoreResponsesClientToolPayload(payload, mapping)
  111. return restored, err
  112. }
  113. func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
  114. ctx context.Context,
  115. c *gin.Context,
  116. account *Account,
  117. body []byte,
  118. canonicalImageIntentBody []byte,
  119. reqModel string,
  120. attemptImageIntentInvalidated bool,
  121. reasoningEffort *string,
  122. reqStream bool,
  123. startTime time.Time,
  124. ) (*OpenAIForwardResult, error) {
  125. requestedModel := reqModel
  126. upstreamPassthroughModel := ""
  127. if isOpenAIResponsesCompactPath(c) {
  128. compactMappedModel := s.resolveOpenAICompactFallbackModel(account, reqModel)
  129. if compactMappedModel != "" && compactMappedModel != reqModel {
  130. nextBody, setErr := sjson.SetBytes(body, "model", compactMappedModel)
  131. if setErr != nil {
  132. return nil, fmt.Errorf("set compact passthrough model: %w", setErr)
  133. }
  134. body = nextBody
  135. upstreamPassthroughModel = compactMappedModel
  136. attemptImageIntentInvalidated = true
  137. }
  138. }
  139. if account != nil && account.UsesOpenAICodexProtocol() {
  140. if rejectReason := detectOpenAIPassthroughInstructionsRejectReason(reqModel, body); rejectReason != "" {
  141. rejectMsg := "OpenAI codex passthrough requires a non-empty instructions field"
  142. MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
  143. logOpenAIPassthroughInstructionsRejected(ctx, c, account, reqModel, rejectReason, body)
  144. c.JSON(http.StatusForbidden, gin.H{
  145. "error": gin.H{
  146. "type": "forbidden_error",
  147. "message": rejectMsg,
  148. },
  149. })
  150. return nil, fmt.Errorf("openai passthrough rejected before upstream: %s", rejectReason)
  151. }
  152. if isOpenAICodexModel(reqModel) && !gjson.GetBytes(body, "instructions").Exists() {
  153. nextBody, setErr := sjson.SetBytes(body, "instructions", defaultCodexSynthInstructions(reqModel))
  154. if setErr != nil {
  155. return nil, fmt.Errorf("set passthrough codex instructions: %w", setErr)
  156. }
  157. body = nextBody
  158. }
  159. normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBody(body, isOpenAIResponsesCompactPath(c))
  160. if err != nil {
  161. return nil, err
  162. }
  163. if normalized {
  164. body = normalizedBody
  165. }
  166. reqStream = gjson.GetBytes(body, "stream").Bool()
  167. accountScopedBody, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(body, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
  168. if scopeErr != nil {
  169. return nil, scopeErr
  170. }
  171. if accountScoped {
  172. body = accountScopedBody
  173. }
  174. stageCodexFingerprintIDs(c, nil)
  175. // 指纹收敛:与非透传路径同门控(仅 OAuth、legacy compact 形态跳过)。
  176. // 一次性解析收敛 ID:请求体 client_metadata 在此改写(raw 字节外科
  177. // 手术,透传热路径禁全量 Unmarshal),出站头改写由请求构造器读取
  178. // context 中的同一份 IDs 完成(turn_id 等随机字段两侧必须一致)。
  179. if !isOpenAIResponsesCompactPath(c) {
  180. var clientHeaders http.Header
  181. if c != nil && c.Request != nil {
  182. clientHeaders = c.Request.Header
  183. }
  184. fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders)
  185. if fpIDs != nil {
  186. fpBody, fpChanged, fpErr := applyCodexFingerprintClientMetadataRaw(body, fpIDs)
  187. if fpErr != nil {
  188. return nil, fpErr
  189. }
  190. if fpChanged {
  191. body = fpBody
  192. }
  193. }
  194. stageCodexFingerprintIDs(c, fpIDs)
  195. }
  196. }
  197. if account != nil && account.IsOpenAI() {
  198. responsesLite := isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) || isOpenAIResponsesLiteWebSocketPayload(body)
  199. normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, responsesLite)
  200. if normalizeErr != nil {
  201. return nil, fmt.Errorf("normalize passthrough Responses compatibility: %w", normalizeErr)
  202. }
  203. if normalized {
  204. body = normalizedBody
  205. }
  206. if account.IsOpenAIOAuthLike() {
  207. aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(body)
  208. if aliasErr != nil {
  209. return nil, aliasErr
  210. }
  211. mergeCodexToolNameReverse(c, reverse)
  212. if aliased {
  213. body = aliasedBody
  214. }
  215. }
  216. }
  217. if account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey &&
  218. !isOpenAIResponsesCompactPath(c) && needsOpenAIResponsesClientToolAdaptation(body) {
  219. adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body)
  220. if adaptErr != nil {
  221. return nil, adaptErr
  222. }
  223. body = adaptedBody
  224. setOpenAIResponsesClientToolMapping(c, mapping)
  225. }
  226. sanitizedBody, sanitized, err := sanitizeEmptyBase64InputImagesInOpenAIBody(body)
  227. if err != nil {
  228. return nil, err
  229. }
  230. if sanitized {
  231. body = sanitizedBody
  232. }
  233. // Apply OpenAI fast policy to the passthrough body (filter/block by service_tier).
  234. // 统一使用 upstream 视角的 model:透传路径下 body 已经过 compact 映射 +
  235. // OAuth normalize,body 中的 model 字段即上游真正会看到的 slug。
  236. // 这样可以与 chat-completions / messages / native /responses 入口的
  237. // upstreamModel 保持一致,避免 whitelist 命中差异。当 body 中没有
  238. // model 字段时退回 reqModel。
  239. policyModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
  240. if policyModel == "" {
  241. policyModel = reqModel
  242. }
  243. updatedBody, policyErr := s.applyOpenAIFastPolicyToBody(ctx, account, policyModel, body)
  244. if policyErr != nil {
  245. var blocked *OpenAIFastBlockedError
  246. if errors.As(policyErr, &blocked) {
  247. writeOpenAIFastPolicyBlockedResponse(c, blocked)
  248. }
  249. return nil, policyErr
  250. }
  251. body = updatedBody
  252. apiKey := getAPIKeyFromContext(c)
  253. // 同一 attempt 的最终 model/body 只判定一次,权限检查与后续图片状态设置共用该结果。
  254. imageIntent := resolveOpenAIPassthroughImageIntent(
  255. c,
  256. reqModel,
  257. canonicalImageIntentBody,
  258. policyModel,
  259. body,
  260. attemptImageIntentInvalidated,
  261. IsImageGenerationIntent,
  262. )
  263. if imageIntent && !GroupAllowsImageGeneration(apiKeyGroup(apiKey)) {
  264. MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
  265. c.JSON(http.StatusForbidden, gin.H{
  266. "error": gin.H{
  267. "type": "permission_error",
  268. "message": ImageGenerationPermissionMessage(),
  269. },
  270. })
  271. return nil, errors.New("image generation disabled for group")
  272. }
  273. imageBillingModel := ""
  274. imageSizeTier := ""
  275. imageInputSize := ""
  276. if imageIntent {
  277. var imageCfgErr error
  278. imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, reqModel)
  279. if imageCfgErr != nil {
  280. setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "")
  281. c.JSON(http.StatusBadRequest, gin.H{
  282. "error": gin.H{
  283. "type": "invalid_request_error",
  284. "message": imageCfgErr.Error(),
  285. "param": "size",
  286. },
  287. })
  288. return nil, imageCfgErr
  289. }
  290. imageBillingModel = imageCfg.Model
  291. imageSizeTier = imageCfg.SizeTier
  292. imageInputSize = imageCfg.InputSize
  293. }
  294. logger.LegacyPrintf("service.openai_gateway",
  295. "[OpenAI 自动透传] 命中自动透传分支: account=%d name=%s type=%s model=%s stream=%v",
  296. account.ID,
  297. account.Name,
  298. account.Type,
  299. reqModel,
  300. reqStream,
  301. )
  302. if reqStream && c != nil && c.Request != nil {
  303. if timeoutHeaders := collectOpenAIPassthroughTimeoutHeaders(c.Request.Header); len(timeoutHeaders) > 0 {
  304. streamWarnLogger := logger.FromContext(ctx).With(
  305. zap.String("component", "service.openai_gateway"),
  306. zap.Int64("account_id", account.ID),
  307. zap.Strings("timeout_headers", timeoutHeaders),
  308. )
  309. if s.isOpenAIPassthroughTimeoutHeadersAllowed() {
  310. streamWarnLogger.Warn("OpenAI passthrough 透传请求包含超时相关请求头,且当前配置为放行,可能导致上游提前断流")
  311. } else {
  312. streamWarnLogger.Warn("OpenAI passthrough 检测到超时相关请求头,将按配置过滤以降低断流风险")
  313. }
  314. }
  315. }
  316. // Get access token
  317. token, _, err := s.GetAccessToken(ctx, account)
  318. if err != nil {
  319. return nil, err
  320. }
  321. proxyURL := ""
  322. if account.ProxyID != nil && account.Proxy != nil {
  323. proxyURL = account.Proxy.URL()
  324. }
  325. if c != nil {
  326. c.Set("openai_passthrough", true)
  327. }
  328. agentTaskRecoveryTried := false
  329. compactModelFallbackRetried := false
  330. rejectedFieldRetryState := openAIResponsesRejectedFieldRetryStateForRequest(c, body)
  331. var resp *http.Response
  332. var usage *OpenAIUsage
  333. var firstTokenMs *int
  334. responseID := ""
  335. imageCount := 0
  336. var imageOutputSizes []string
  337. for {
  338. actualModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
  339. if actualModel == "" {
  340. actualModel = reqModel
  341. }
  342. SetOpsUpstreamModel(c, actualModel)
  343. upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
  344. upstreamReq, buildErr := s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token)
  345. releaseUpstreamCtx()
  346. if buildErr != nil {
  347. return nil, buildErr
  348. }
  349. upstreamStart := time.Now()
  350. resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account)
  351. SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
  352. if err != nil {
  353. // Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to
  354. // a failover so the handler switches to a healthy account.
  355. return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
  356. }
  357. if resp.StatusCode >= 400 {
  358. // Peek only to identify an invalid task. Restore the body so the existing
  359. // passthrough error handling sees the same response after recovery fails.
  360. probeBody := s.readUpstreamErrorBody(resp)
  361. _ = resp.Body.Close()
  362. resp.Body = io.NopCloser(bytes.NewReader(probeBody))
  363. if retryBody, reason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, probeBody); retryErr != nil {
  364. return nil, fmt.Errorf("normalize passthrough rejected Responses field retry body: %w", retryErr)
  365. } else if changed && rejectedFieldRetryState.Allow(retryBody) {
  366. body = retryBody
  367. logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying passthrough request after %s (account: %s)", reason, account.Name)
  368. continue
  369. }
  370. if !agentTaskRecoveryTried && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, probeBody) {
  371. agentTaskRecoveryTried = true
  372. expectedTaskID := account.GetCredential("task_id")
  373. if recoveryErr := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); recoveryErr != nil {
  374. return nil, fmt.Errorf("agent identity task recovery failed: %w", recoveryErr)
  375. }
  376. continue
  377. }
  378. upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(probeBody)))
  379. if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
  380. c, account, requestedModel, body, resp.StatusCode, upstreamMsg, probeBody, compactModelFallbackRetried,
  381. ); retry {
  382. s.appendOpenAICompactFallbackRetryOps(c, account, resp, probeBody, upstreamMsg, true)
  383. fromModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
  384. body = retryBody
  385. upstreamPassthroughModel = fallbackModel
  386. compactModelFallbackRetried = true
  387. SetOpsUpstreamModel(c, fallbackModel)
  388. logger.LegacyPrintf(
  389. "service.openai_gateway",
  390. "[OpenAI passthrough] Retrying explicit compact request once with fallback model (account: %s, from: %s, to: %s, upstream_code: %s)",
  391. account.Name, fromModel, fallbackModel, extractUpstreamErrorCode(probeBody),
  392. )
  393. continue
  394. }
  395. // 透传模式默认保持原样代理;容量错误以及 API-key 上游的瞬时
  396. // 5xx 应先触发多账号 failover,且此时尚未写入下游响应。
  397. // probeBody 已在上方任务探测时读取过一次,直接复用避免重复读取。
  398. if shouldFailoverOpenAIPassthroughResponse(account, resp.StatusCode, probeBody) {
  399. return nil, s.handleFailoverErrorResponsePassthrough(ctx, resp, c, account, body, probeBody)
  400. }
  401. return nil, s.handleErrorResponsePassthrough(ctx, resp, c, account, body, probeBody)
  402. }
  403. if mapping, ok := openAIResponsesClientToolMapping(c); ok && isEventStreamResponse(resp.Header) {
  404. maxLineSize := defaultMaxLineSize
  405. if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
  406. maxLineSize = s.cfg.Gateway.MaxLineSize
  407. }
  408. resp.Body = newGrokResponsesClientToolStreamBody(resp.Body, mapping, maxLineSize)
  409. }
  410. // x-codex-turn-state 溯源:下游回传由 writeOpenAIPassthroughResponseHeaders
  411. // 在各 handler 的写头点强制放行,铸造账号在此统一记录,供出站守卫剥离
  412. // failover 换号后的跨账号回带(openai_codex_turn_state.go)。
  413. if extractOpenAICodexTurnState(resp.Header) != "" {
  414. s.noteOpenAICodexTurnStateProvenance(c, account)
  415. }
  416. if reqStream {
  417. result, handleErr := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel)
  418. if handleErr != nil {
  419. if retryBody, fallbackModel, retry := s.applyOpenAIPassthroughCompactFallbackFromSignal(
  420. c, account, requestedModel, body, handleErr, compactModelFallbackRetried, resp,
  421. ); retry {
  422. body = retryBody
  423. upstreamPassthroughModel = fallbackModel
  424. compactModelFallbackRetried = true
  425. continue
  426. }
  427. if signal, ok := asOpenAICompactFallbackSignal(handleErr); ok {
  428. _ = resp.Body.Close()
  429. compactResp, compactBody := openAICompactFallbackErrorResponse(resp, signal)
  430. if shouldFailoverOpenAIPassthroughResponse(account, compactResp.StatusCode, compactBody) {
  431. return nil, s.handleFailoverErrorResponsePassthrough(ctx, compactResp, c, account, body, compactBody)
  432. }
  433. return nil, s.handleErrorResponsePassthrough(ctx, compactResp, c, account, body, compactBody)
  434. }
  435. _ = resp.Body.Close()
  436. return nil, handleErr
  437. }
  438. usage = result.usage
  439. firstTokenMs = result.firstTokenMs
  440. responseID = strings.TrimSpace(result.responseID)
  441. imageCount = result.imageCount
  442. imageOutputSizes = result.imageOutputSizes
  443. } else {
  444. result, handleErr := s.handleNonStreamingResponsePassthrough(ctx, resp, c, account, reqModel, upstreamPassthroughModel)
  445. if handleErr != nil {
  446. if retryBody, fallbackModel, retry := s.applyOpenAIPassthroughCompactFallbackFromSignal(
  447. c, account, requestedModel, body, handleErr, compactModelFallbackRetried, resp,
  448. ); retry {
  449. body = retryBody
  450. upstreamPassthroughModel = fallbackModel
  451. compactModelFallbackRetried = true
  452. continue
  453. }
  454. if signal, ok := asOpenAICompactFallbackSignal(handleErr); ok {
  455. _ = resp.Body.Close()
  456. compactResp, compactBody := openAICompactFallbackErrorResponse(resp, signal)
  457. if shouldFailoverOpenAIPassthroughResponse(account, compactResp.StatusCode, compactBody) {
  458. return nil, s.handleFailoverErrorResponsePassthrough(ctx, compactResp, c, account, body, compactBody)
  459. }
  460. return nil, s.handleErrorResponsePassthrough(ctx, compactResp, c, account, body, compactBody)
  461. }
  462. _ = resp.Body.Close()
  463. return nil, handleErr
  464. }
  465. usage = result.usage
  466. responseID = strings.TrimSpace(result.responseID)
  467. imageCount = result.imageCount
  468. imageOutputSizes = result.imageOutputSizes
  469. }
  470. break
  471. }
  472. defer func() { _ = resp.Body.Close() }()
  473. serviceTier := extractOpenAIServiceTierFromBody(body)
  474. s.bindHTTPResponseAccount(ctx, c, account, responseID)
  475. // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
  476. if !account.IsShadow() {
  477. if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil {
  478. s.updateCodexUsageSnapshot(ctx, account.ID, snapshot)
  479. }
  480. } else if account.ParentAccountID != nil {
  481. notifyOpenAIAutoReset(*account.ParentAccountID)
  482. }
  483. if usage == nil {
  484. usage = &OpenAIUsage{}
  485. }
  486. forwardResult := &OpenAIForwardResult{
  487. RequestID: resp.Header.Get("x-request-id"),
  488. UpstreamHeaders: resp.Header,
  489. ResponseID: responseID,
  490. Usage: *usage,
  491. Model: reqModel,
  492. UpstreamModel: upstreamPassthroughModel,
  493. UpstreamResponseModel: observedUpstreamResponseModel(c),
  494. UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
  495. UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
  496. ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier),
  497. ReasoningEffort: reasoningEffort,
  498. Stream: reqStream,
  499. OpenAIWSMode: false,
  500. Duration: time.Since(startTime),
  501. FirstTokenMs: firstTokenMs,
  502. }
  503. if imageCount > 0 {
  504. forwardResult.ImageCount = imageCount
  505. forwardResult.ImageSize = imageSizeTier
  506. forwardResult.ImageInputSize = imageInputSize
  507. forwardResult.ImageOutputSizes = imageOutputSizes
  508. forwardResult.BillingModel = imageBillingModel
  509. }
  510. return forwardResult, nil
  511. }
  512. func logOpenAIPassthroughInstructionsRejected(
  513. ctx context.Context,
  514. c *gin.Context,
  515. account *Account,
  516. reqModel string,
  517. rejectReason string,
  518. body []byte,
  519. ) {
  520. if ctx == nil {
  521. ctx = context.Background()
  522. }
  523. accountID := int64(0)
  524. accountName := ""
  525. accountType := ""
  526. if account != nil {
  527. accountID = account.ID
  528. accountName = strings.TrimSpace(account.Name)
  529. accountType = strings.TrimSpace(string(account.Type))
  530. }
  531. fields := []zap.Field{
  532. zap.String("component", "service.openai_gateway"),
  533. zap.Int64("account_id", accountID),
  534. zap.String("account_name", accountName),
  535. zap.String("account_type", accountType),
  536. zap.String("request_model", strings.TrimSpace(reqModel)),
  537. zap.String("reject_reason", strings.TrimSpace(rejectReason)),
  538. }
  539. fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, body)
  540. logger.FromContext(ctx).With(fields...).Warn("OpenAI passthrough 本地拦截:Codex 请求缺少有效 instructions")
  541. }
  542. func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
  543. ctx context.Context,
  544. c *gin.Context,
  545. account *Account,
  546. body []byte,
  547. token string,
  548. ) (*http.Request, error) {
  549. if err := validateMode1StagedRequest(c, account, body); err != nil {
  550. return nil, err
  551. }
  552. targetURL := openaiPlatformAPIURL
  553. switch account.Type {
  554. case AccountTypeOAuth:
  555. targetURL = chatgptCodexURL
  556. case AccountTypeSetupToken:
  557. if account.IsOpenAIOAuthLike() {
  558. targetURL = chatgptCodexURL
  559. }
  560. case AccountTypeAPIKey:
  561. baseURL := account.GetOpenAIBaseURL()
  562. if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() {
  563. baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses)
  564. }
  565. if baseURL != "" {
  566. validatedURL, err := s.validateUpstreamBaseURL(baseURL)
  567. if err != nil {
  568. return nil, err
  569. }
  570. targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL)
  571. }
  572. }
  573. targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c))
  574. // DeepSeek / Kimi 原生 Responses 端点为无状态实现(见 normalizeDeepSeekResponsesRequestBody)。
  575. body = normalizeDeepSeekResponsesRequestBody(account, body)
  576. req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
  577. if err != nil {
  578. return nil, err
  579. }
  580. req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
  581. // 透传客户端请求头(安全白名单)。
  582. allowTimeoutHeaders := s.isOpenAIPassthroughTimeoutHeadersAllowed()
  583. if c != nil && c.Request != nil {
  584. for key, values := range c.Request.Header {
  585. lower := strings.ToLower(strings.TrimSpace(key))
  586. if !isOpenAIPassthroughAllowedRequestHeader(lower, allowTimeoutHeaders) {
  587. continue
  588. }
  589. for _, v := range values {
  590. req.Header.Add(key, v)
  591. }
  592. }
  593. }
  594. // 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号),
  595. // 剥离后再出站(openai_codex_turn_state.go)。
  596. s.guardOpenAICodexTurnStateEcho(c, account, req.Header)
  597. // 覆盖入站鉴权残留,并注入上游认证
  598. req.Header.Del("authorization")
  599. req.Header.Del("x-api-key")
  600. req.Header.Del("x-goog-api-key")
  601. authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
  602. if err != nil {
  603. return nil, fmt.Errorf("build openai authentication headers: %w", err)
  604. }
  605. for key, values := range authHeaders {
  606. for _, value := range values {
  607. req.Header.Add(key, value)
  608. }
  609. }
  610. // OAuth 透传到 ChatGPT internal API 时补齐必要头。
  611. if account.UsesOpenAICodexProtocol() {
  612. // Current Codex OAuth HTTP no longer negotiates the legacy Responses
  613. // experiment. Passthrough may receive it from an older client, so remove
  614. // only that token while preserving any independent beta negotiation.
  615. stripOpenAILegacyResponsesBeta(req.Header)
  616. promptCacheKey := strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String())
  617. req.Host = "chatgpt.com"
  618. if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
  619. return nil, fmt.Errorf("resolve chatgpt account headers: %w", err)
  620. }
  621. apiKeyID := getAPIKeyIDFromContext(c)
  622. // 先保存客户端原始值,再做 compact 补充,避免后续统一隔离时读到已处理的值。
  623. clientSessionID := strings.TrimSpace(req.Header.Get("session_id"))
  624. clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id"))
  625. if isOpenAIResponsesCompactPath(c) {
  626. req.Header.Set("accept", "application/json")
  627. if req.Header.Get("version") == "" {
  628. req.Header.Set("version", CodexCanonicalClientVersion())
  629. }
  630. if clientSessionID == "" {
  631. clientSessionID = resolveOpenAICompactSessionID(c)
  632. }
  633. } else if req.Header.Get("accept") == "" {
  634. req.Header.Set("accept", "text/event-stream")
  635. }
  636. if req.Header.Get("originator") == "" {
  637. req.Header.Set("originator", resolveCodexOutboundIdentity("").originator)
  638. }
  639. // 用隔离后的 session 标识符覆盖客户端透传值,防止跨用户会话碰撞。
  640. if clientSessionID == "" {
  641. clientSessionID = promptCacheKey
  642. }
  643. if clientConversationID == "" {
  644. clientConversationID = promptCacheKey
  645. }
  646. if clientSessionID != "" {
  647. req.Header.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientSessionID))
  648. }
  649. if clientConversationID != "" {
  650. req.Header.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientConversationID))
  651. }
  652. } else if isOpenAIResponsesCompactPath(c) {
  653. // 透传白名单会放行客户端的 Accept: text/event-stream;compact 上游是
  654. // unary JSON 协议,API-key 账号同样强制 Accept,避免上游按 SSE 返回
  655. // (#3777 期望行为 4)。
  656. req.Header.Set("accept", "application/json")
  657. }
  658. // 透传模式也支持账户自定义 User-Agent 与 ForceCodexCLI 兜底。
  659. customUA := account.GetOpenAIUserAgent()
  660. if customUA != "" {
  661. req.Header.Set("user-agent", customUA)
  662. }
  663. if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
  664. req.Header.Set("user-agent", CodexCanonicalUserAgent())
  665. }
  666. applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
  667. // 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头,
  668. // 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置:
  669. // 会话隔离之后、终态身份收口之前)。
  670. applyStagedCodexFingerprintHeaders(c, account, req.Header)
  671. // 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份
  672. // (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。
  673. if account.UsesOpenAICodexProtocol() {
  674. enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
  675. }
  676. if req.Header.Get("content-type") == "" {
  677. req.Header.Set("content-type", "application/json")
  678. }
  679. // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
  680. account.ApplyHeaderOverrides(req.Header)
  681. applyOpenCodeSessionHeader(c, account, targetURL, req.Header)
  682. // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后,
  683. // 保证不被覆盖丢失)。
  684. applyOpenAICodexBetaFeatures(c, account, req.Header)
  685. setOpenAICodexRoutingHintFromBody(req.Header, account, body)
  686. logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http_passthrough", req.Header, body, "not_applicable")
  687. return req, nil
  688. }
  689. func stripOpenAILegacyResponsesBeta(headers http.Header) {
  690. if headers == nil {
  691. return
  692. }
  693. preserved := make([]string, 0)
  694. for key, values := range headers {
  695. if !strings.EqualFold(strings.TrimSpace(key), "OpenAI-Beta") {
  696. continue
  697. }
  698. delete(headers, key)
  699. for _, value := range values {
  700. parts := strings.Split(value, ",")
  701. kept := parts[:0]
  702. for _, part := range parts {
  703. part = strings.TrimSpace(part)
  704. if part == "" || strings.EqualFold(part, "responses=experimental") {
  705. continue
  706. }
  707. kept = append(kept, part)
  708. }
  709. if len(kept) > 0 {
  710. preserved = append(preserved, strings.Join(kept, ", "))
  711. }
  712. }
  713. }
  714. for _, value := range preserved {
  715. headers.Add("OpenAI-Beta", value)
  716. }
  717. }
  718. func shouldFailoverOpenAIPassthroughResponse(account *Account, statusCode int, responseBody []byte) bool {
  719. if hit, _, _ := detectOpenAICyberPolicy(responseBody); hit {
  720. return false
  721. }
  722. if isOpenAIContextWindowError("", responseBody) {
  723. return false
  724. }
  725. if isOpenAIHTTPUpstreamAccessStateError(statusCode, "", responseBody) {
  726. return true
  727. }
  728. if isOpenAIRequestBodyTooLargeError(statusCode, "", responseBody) {
  729. return true
  730. }
  731. if account != nil && account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode) {
  732. return true
  733. }
  734. switch statusCode {
  735. case http.StatusTooManyRequests, 529:
  736. return true
  737. }
  738. if account == nil || account.Type != AccountTypeAPIKey {
  739. return false
  740. }
  741. switch statusCode {
  742. case http.StatusInternalServerError,
  743. http.StatusBadGateway,
  744. http.StatusServiceUnavailable,
  745. http.StatusGatewayTimeout,
  746. 520, 521, 522, 523, 524:
  747. return true
  748. default:
  749. return false
  750. }
  751. }
  752. func writeOpenAIPassthroughErrorHeaders(dst, src http.Header) {
  753. if dst == nil {
  754. return
  755. }
  756. dst.Set("Content-Type", "application/json; charset=utf-8")
  757. dst.Set("Cache-Control", "no-store")
  758. dst.Del("Retry-After")
  759. if src == nil {
  760. return
  761. }
  762. rawRetryAfter := strings.TrimSpace(src.Get("Retry-After"))
  763. if validOpenAIPassthroughRetryAfter(rawRetryAfter, time.Now()) {
  764. dst.Set("Retry-After", rawRetryAfter)
  765. }
  766. }
  767. func validOpenAIPassthroughRetryAfter(raw string, now time.Time) bool {
  768. raw = strings.TrimSpace(raw)
  769. if raw == "" {
  770. return false
  771. }
  772. delaySeconds := true
  773. for i := 0; i < len(raw); i++ {
  774. if raw[i] < '0' || raw[i] > '9' {
  775. delaySeconds = false
  776. break
  777. }
  778. }
  779. if delaySeconds {
  780. seconds, err := strconv.ParseUint(raw, 10, 64)
  781. return err == nil && seconds > 0
  782. }
  783. parsed, err := http.ParseTime(raw)
  784. return err == nil && parsed.After(now)
  785. }
  786. func writeSanitizedOpenAIPassthroughError(c *gin.Context, upstreamStatus int, upstreamHeaders http.Header) {
  787. downstreamStatus := upstreamStatus
  788. message := "Upstream request failed"
  789. switch upstreamStatus {
  790. case http.StatusUnauthorized:
  791. downstreamStatus = http.StatusBadGateway
  792. message = "Upstream authentication failed"
  793. case http.StatusForbidden:
  794. downstreamStatus = http.StatusBadGateway
  795. message = "Upstream access denied"
  796. default:
  797. if upstreamStatus >= http.StatusInternalServerError {
  798. message = "Upstream service temporarily unavailable"
  799. }
  800. }
  801. writeOpenAIPassthroughErrorEnvelope(c, downstreamStatus, upstreamHeaders, message)
  802. }
  803. // writeOpenAIPassthroughErrorEnvelope 以本地 JSON 信封 + 净化后的头策略写出
  804. // 错误响应;message 由调用方决定(净化通用文案或脱敏后的上游消息)。
  805. func writeOpenAIPassthroughErrorEnvelope(c *gin.Context, downstreamStatus int, upstreamHeaders http.Header, message string) {
  806. if c == nil {
  807. return
  808. }
  809. body, _ := json.Marshal(gin.H{
  810. "error": gin.H{
  811. "type": "upstream_error",
  812. "message": message,
  813. },
  814. })
  815. if writeOpenAICompactSSEBridge(c, downstreamStatus, body) {
  816. return
  817. }
  818. writeOpenAIPassthroughErrorHeaders(c.Writer.Header(), upstreamHeaders)
  819. c.Data(downstreamStatus, "application/json; charset=utf-8", body)
  820. }
  821. func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough(
  822. ctx context.Context,
  823. resp *http.Response,
  824. c *gin.Context,
  825. account *Account,
  826. requestBody []byte,
  827. responseBody []byte,
  828. ) error {
  829. body := s.redactAgentIdentitySensitiveBody(ctx, account, responseBody)
  830. upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
  831. upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
  832. upstreamDetail := ""
  833. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  834. maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
  835. if maxBytes <= 0 {
  836. maxBytes = 2048
  837. }
  838. upstreamDetail = truncateString(string(body), maxBytes)
  839. }
  840. setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
  841. logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
  842. reqModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody)
  843. canonicalModel := canonicalOpenAIAccountSchedulingModel(account, reqModel)
  844. shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, canonicalModel)
  845. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  846. ProxyID: opsUpstreamProxyID(account),
  847. ProxyName: opsUpstreamProxyName(account),
  848. Platform: account.Platform,
  849. AccountID: account.ID,
  850. AccountName: account.Name,
  851. UpstreamStatusCode: resp.StatusCode,
  852. UpstreamRequestID: resp.Header.Get("x-request-id"),
  853. Passthrough: true,
  854. Kind: "failover",
  855. Message: upstreamMsg,
  856. Detail: upstreamDetail,
  857. UpstreamResponseBody: upstreamDetail,
  858. })
  859. return s.newOpenAIAccountFailoverError(
  860. account,
  861. resp.StatusCode,
  862. resp.Header,
  863. body,
  864. upstreamMsg,
  865. shouldDisable,
  866. !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
  867. )
  868. }
  869. func (s *OpenAIGatewayService) handleErrorResponsePassthrough(
  870. ctx context.Context,
  871. resp *http.Response,
  872. c *gin.Context,
  873. account *Account,
  874. requestBody []byte,
  875. responseBody []byte,
  876. ) error {
  877. MarkResponseCommitted(c)
  878. body := s.redactAgentIdentitySensitiveBody(ctx, account, responseBody)
  879. // cyber_policy 仍按原始 body 打内部标记,供 handler 事后写风控/邮件;面向客户端的
  880. // 错误体在下方统一重建。cyber 是上游网络安全策略拦截,不冷却账号,
  881. // 故下方跳过 handleOpenAIAccountUpstreamError(避免自定义 temp-unschedulable 规则误冷却)。
  882. cyberHit, cyberCode, cyberMsg := detectOpenAICyberPolicy(body)
  883. if cyberHit {
  884. MarkOpsCyberPolicy(c, CyberPolicyMark{
  885. Code: cyberCode,
  886. Message: cyberMsg,
  887. Body: truncateString(string(body), 4096),
  888. UpstreamStatus: resp.StatusCode,
  889. })
  890. }
  891. upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
  892. upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
  893. upstreamDetail := ""
  894. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  895. maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
  896. if maxBytes <= 0 {
  897. maxBytes = 2048
  898. }
  899. upstreamDetail = truncateString(string(body), maxBytes)
  900. }
  901. setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
  902. logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
  903. // 错误体虽不会原样透传,运行态账号状态仍需更新,避免粘性路由继续复用
  904. // 刚被限流的账号。cyber 例外:不冷却账号。
  905. if !cyberHit {
  906. reqModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody)
  907. canonicalModel := canonicalOpenAIAccountSchedulingModel(account, reqModel)
  908. _ = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, canonicalModel)
  909. }
  910. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  911. ProxyID: opsUpstreamProxyID(account),
  912. ProxyName: opsUpstreamProxyName(account),
  913. Platform: account.Platform,
  914. AccountID: account.ID,
  915. AccountName: account.Name,
  916. UpstreamStatusCode: resp.StatusCode,
  917. UpstreamRequestID: resp.Header.Get("x-request-id"),
  918. Passthrough: true,
  919. Kind: "http_error",
  920. Message: upstreamMsg,
  921. Detail: upstreamDetail,
  922. UpstreamResponseBody: upstreamDetail,
  923. })
  924. // context-window 超限是确定性请求失败(shouldFailoverOpenAIPassthroughResponse
  925. // 已保证不切号),其文案对客户端可操作(如触发自动压缩);在净化信封内保留
  926. // 脱敏后的上游消息,而不是抹成通用文案。
  927. if isOpenAIContextWindowError(upstreamMsg, body) && upstreamMsg != "" {
  928. writeOpenAIPassthroughErrorEnvelope(c, resp.StatusCode, resp.Header, upstreamMsg)
  929. } else {
  930. writeSanitizedOpenAIPassthroughError(c, resp.StatusCode, resp.Header)
  931. }
  932. return fmt.Errorf("upstream error: %d (client response sanitized)", resp.StatusCode)
  933. }
  934. func isOpenAIPassthroughAllowedRequestHeader(lowerKey string, allowTimeoutHeaders bool) bool {
  935. if lowerKey == "" {
  936. return false
  937. }
  938. if isOpenAIPassthroughTimeoutHeader(lowerKey) {
  939. return allowTimeoutHeaders
  940. }
  941. return openaiPassthroughAllowedHeaders[lowerKey]
  942. }
  943. func isOpenAIPassthroughTimeoutHeader(lowerKey string) bool {
  944. switch lowerKey {
  945. case "x-stainless-timeout", "x-stainless-read-timeout", "x-stainless-connect-timeout", "x-request-timeout", "request-timeout", "grpc-timeout":
  946. return true
  947. default:
  948. return false
  949. }
  950. }
  951. func (s *OpenAIGatewayService) isOpenAIPassthroughTimeoutHeadersAllowed() bool {
  952. return s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIPassthroughAllowTimeoutHeaders
  953. }
  954. func collectOpenAIPassthroughTimeoutHeaders(h http.Header) []string {
  955. if h == nil {
  956. return nil
  957. }
  958. var matched []string
  959. for key, values := range h {
  960. lowerKey := strings.ToLower(strings.TrimSpace(key))
  961. if isOpenAIPassthroughTimeoutHeader(lowerKey) {
  962. entry := lowerKey
  963. if len(values) > 0 {
  964. entry = fmt.Sprintf("%s=%s", lowerKey, strings.Join(values, "|"))
  965. }
  966. matched = append(matched, entry)
  967. }
  968. }
  969. sort.Strings(matched)
  970. return matched
  971. }
  972. type openaiStreamingResultPassthrough struct {
  973. usage *OpenAIUsage
  974. firstTokenMs *int
  975. responseID string
  976. imageCount int
  977. imageOutputSizes []string
  978. }
  979. type openaiNonStreamingResultPassthrough struct {
  980. *OpenAIUsage
  981. usage *OpenAIUsage
  982. responseID string
  983. imageCount int
  984. imageOutputSizes []string
  985. }
  986. const openAIStreamKeepaliveBytesKey = "openai_stream_keepalive_bytes"
  987. func recordOpenAIStreamKeepaliveBytes(c *gin.Context, written int) {
  988. if c == nil || written <= 0 {
  989. return
  990. }
  991. current := 0
  992. if value, ok := c.Get(openAIStreamKeepaliveBytesKey); ok {
  993. current, _ = value.(int)
  994. }
  995. c.Set(openAIStreamKeepaliveBytesKey, current+written)
  996. }
  997. func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool {
  998. if localStarted {
  999. return true
  1000. }
  1001. if c == nil || c.Writer == nil {
  1002. return false
  1003. }
  1004. // compact keepalive comments commit the HTTP response as 200, but they are
  1005. // not semantic model output and therefore must not block a safe retry.
  1006. // Without a compact keepalive this is equivalent to checking Writer.Size().
  1007. return OpenAICompactKeepaliveAdjustedWrittenSize(c) >= 0
  1008. }
  1009. func openAIStreamEventIsPreamble(eventType string) bool {
  1010. switch strings.TrimSpace(eventType) {
  1011. case "response.created", "response.in_progress":
  1012. return true
  1013. default:
  1014. return false
  1015. }
  1016. }
  1017. func openAIStreamAddedEventStartsClientOutput(payload []byte, eventType string) bool {
  1018. if len(payload) == 0 || !gjson.ValidBytes(payload) {
  1019. return true
  1020. }
  1021. switch strings.TrimSpace(eventType) {
  1022. case "response.output_item.added":
  1023. item := gjson.GetBytes(payload, "item")
  1024. if !item.Exists() || !item.IsObject() {
  1025. return true
  1026. }
  1027. switch strings.TrimSpace(item.Get("type").String()) {
  1028. case "reasoning":
  1029. if item.Get("encrypted_content").String() != "" {
  1030. return true
  1031. }
  1032. summary := item.Get("summary")
  1033. if !summary.IsArray() {
  1034. return false
  1035. }
  1036. for _, part := range summary.Array() {
  1037. if strings.TrimSpace(part.Get("type").String()) != "summary_text" || part.Get("text").String() != "" {
  1038. return true
  1039. }
  1040. }
  1041. return false
  1042. case "message":
  1043. content := item.Get("content")
  1044. if !content.IsArray() {
  1045. return false
  1046. }
  1047. for _, part := range content.Array() {
  1048. switch strings.TrimSpace(part.Get("type").String()) {
  1049. case "output_text":
  1050. if part.Get("text").String() != "" {
  1051. return true
  1052. }
  1053. case "refusal":
  1054. if part.Get("refusal").String() != "" {
  1055. return true
  1056. }
  1057. default:
  1058. return true
  1059. }
  1060. }
  1061. return false
  1062. case "function_call":
  1063. return item.Get("arguments").String() != ""
  1064. case "custom_tool_call":
  1065. return item.Get("input").String() != ""
  1066. case "compaction":
  1067. return item.Get("encrypted_content").String() != ""
  1068. default:
  1069. return true
  1070. }
  1071. case "response.content_part.added":
  1072. part := gjson.GetBytes(payload, "part")
  1073. if !part.Exists() || !part.IsObject() {
  1074. return true
  1075. }
  1076. switch strings.TrimSpace(part.Get("type").String()) {
  1077. case "output_text":
  1078. return part.Get("text").String() != ""
  1079. case "refusal":
  1080. return part.Get("refusal").String() != ""
  1081. default:
  1082. return true
  1083. }
  1084. case "response.reasoning_summary_part.added":
  1085. part := gjson.GetBytes(payload, "part")
  1086. if !part.Exists() || !part.IsObject() || strings.TrimSpace(part.Get("type").String()) != "summary_text" {
  1087. return true
  1088. }
  1089. return part.Get("text").String() != ""
  1090. default:
  1091. return true
  1092. }
  1093. }
  1094. func openAIStreamDataStartsClientOutput(data, eventType string) bool {
  1095. trimmed := strings.TrimSpace(data)
  1096. if trimmed == "" {
  1097. return false
  1098. }
  1099. switch strings.TrimSpace(eventType) {
  1100. case "response.failed":
  1101. return false
  1102. case "error":
  1103. // 上游降载/瞬时故障会先推 {"type":"error"} 帧、再以 response.failed 收尾。
  1104. // 可重试类错误帧不能算客户端输出:一旦把它当首输出 flush,
  1105. // clientOutputStarted 即被固化,随后的 failed 事件永远进不了 pre-output
  1106. // failover 分支,只能把致命错误原样转发给客户端。不可重试类
  1107. // (content_policy / invalid_request 等)维持原样转发,保留上游错误细节。
  1108. payload := []byte(trimmed)
  1109. return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload))
  1110. case "response.output_item.added", "response.content_part.added", "response.reasoning_summary_part.added":
  1111. return openAIStreamAddedEventStartsClientOutput([]byte(trimmed), eventType)
  1112. }
  1113. return !openAIStreamEventIsPreamble(eventType)
  1114. }
  1115. func openAIStreamItemHasVisibleOutput(item gjson.Result) bool {
  1116. if item.Get("arguments").String() != "" || item.Get("input").String() != "" || item.Get("result").String() != "" {
  1117. return true
  1118. }
  1119. for _, path := range []string{"content", "summary"} {
  1120. for _, part := range item.Get(path).Array() {
  1121. if part.Get("text").String() != "" || part.Get("transcript").String() != "" {
  1122. return true
  1123. }
  1124. }
  1125. }
  1126. return false
  1127. }
  1128. // Structural progress can commit an attempt and disarm first-output failover,
  1129. // but TTFT should start only when the stream carries content a client can use.
  1130. func openAIStreamDataStartsVisibleOutput(data, eventType string) bool {
  1131. trimmed := strings.TrimSpace(data)
  1132. if trimmed == "" || trimmed == "[DONE]" || !gjson.Valid(trimmed) {
  1133. return false
  1134. }
  1135. eventType = strings.TrimSpace(eventType)
  1136. if eventType == "" {
  1137. eventType = strings.TrimSpace(gjson.Get(trimmed, "type").String())
  1138. }
  1139. if strings.HasSuffix(eventType, ".delta") {
  1140. delta := gjson.Get(trimmed, "delta")
  1141. return delta.Exists() && delta.String() != ""
  1142. }
  1143. switch eventType {
  1144. case "response.output_text.done",
  1145. "response.reasoning_summary_text.done",
  1146. "response.reasoning_text.done",
  1147. "response.audio_transcript.done":
  1148. return gjson.Get(trimmed, "text").String() != ""
  1149. case "response.function_call_arguments.done":
  1150. return gjson.Get(trimmed, "arguments").String() != ""
  1151. case "response.custom_tool_call_input.done":
  1152. return gjson.Get(trimmed, "input").String() != ""
  1153. case "response.image_generation_call.partial_image":
  1154. return gjson.Get(trimmed, "partial_image_b64").String() != ""
  1155. case "response.content_part.added", "response.content_part.done",
  1156. "response.reasoning_summary_part.added", "response.reasoning_summary_part.done":
  1157. part := gjson.Get(trimmed, "part")
  1158. return part.Get("text").String() != "" || part.Get("transcript").String() != ""
  1159. case "response.output_item.added", "response.output_item.done":
  1160. return openAIStreamItemHasVisibleOutput(gjson.Get(trimmed, "item"))
  1161. case "response.completed", "response.done":
  1162. for _, item := range gjson.Get(trimmed, "response.output").Array() {
  1163. if openAIStreamItemHasVisibleOutput(item) {
  1164. return true
  1165. }
  1166. }
  1167. }
  1168. return false
  1169. }
  1170. // openAIStreamDataStartsSemanticTTFT 保留 900194fab 之前的 first_token_ms
  1171. // 口径:跳过 Responses preamble 后,首个语义 SSE 事件即视为首 token。
  1172. func openAIStreamDataStartsSemanticTTFT(data, eventType string) bool {
  1173. trimmed := strings.TrimSpace(data)
  1174. if trimmed == "" || trimmed == "[DONE]" {
  1175. return false
  1176. }
  1177. eventType = strings.TrimSpace(eventType)
  1178. if eventType == "" && gjson.Valid(trimmed) {
  1179. eventType = strings.TrimSpace(gjson.Get(trimmed, "type").String())
  1180. }
  1181. switch eventType {
  1182. case "response.failed":
  1183. return false
  1184. case "error":
  1185. payload := []byte(trimmed)
  1186. return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload))
  1187. default:
  1188. return !openAIStreamEventIsPreamble(eventType)
  1189. }
  1190. }
  1191. func (s *OpenAIGatewayService) openAITTFTMode(ctx context.Context) string {
  1192. mode := OpenAITTFTModeSemantic
  1193. if s != nil && s.settingService != nil {
  1194. mode = s.settingService.GetOpenAITTFTMode(ctx)
  1195. } else if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
  1196. if cached.expiresAt == 0 || time.Now().UnixNano() < cached.expiresAt {
  1197. mode = normalizeOpenAITTFTMode(cached.openAITTFTMode)
  1198. }
  1199. }
  1200. return normalizeOpenAITTFTMode(mode)
  1201. }
  1202. func openAIStreamDataStartsTTFT(data, eventType string, forceOutput bool, mode string) bool {
  1203. if mode == OpenAITTFTModeVisible {
  1204. return openAIStreamDataStartsVisibleOutput(data, eventType)
  1205. }
  1206. return forceOutput || openAIStreamDataStartsSemanticTTFT(data, eventType)
  1207. }
  1208. // openAIStreamFailedEventErrorCode 提取流内 failed 事件的错误码(小写),
  1209. // 兼容 response.failed 的嵌套形态与裸 error 形态。
  1210. func openAIStreamFailedEventErrorCode(payload []byte) string {
  1211. code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
  1212. if code == "" {
  1213. code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
  1214. }
  1215. return code
  1216. }
  1217. // isOpenAIUpstreamCapacityShedEvent 判断流内 failed 事件是否为上游容量降载信号。
  1218. // 上游在容量紧张时会把请求丢进降载路径:HTTP 200 之后立刻推 event: error
  1219. // (code=server_is_overloaded / slow_down)并以 response.failed 收尾。
  1220. func isOpenAIUpstreamCapacityShedEvent(payload []byte) bool {
  1221. switch openAIStreamFailedEventErrorCode(payload) {
  1222. case "server_is_overloaded", "slow_down":
  1223. return true
  1224. }
  1225. for _, path := range []string{"response.error.message", "error.message", "message"} {
  1226. if isOpenAICapacityShedMessage(gjson.GetBytes(payload, path).String()) {
  1227. return true
  1228. }
  1229. }
  1230. return false
  1231. }
  1232. func logOpenAICapacityFailoverSuppressed(
  1233. ctx context.Context,
  1234. account *Account,
  1235. path string,
  1236. upstreamRequestID string,
  1237. eventType string,
  1238. ) {
  1239. fields := []zap.Field{
  1240. zap.String("path", path),
  1241. zap.String("event_type", strings.TrimSpace(eventType)),
  1242. zap.String("upstream_request_id", strings.TrimSpace(upstreamRequestID)),
  1243. }
  1244. if account != nil {
  1245. fields = append(fields,
  1246. zap.Int64("account_id", account.ID),
  1247. zap.String("platform", account.Platform),
  1248. )
  1249. }
  1250. logger.FromContext(ctx).Warn("gateway.failover_suppressed_after_semantic_output", fields...)
  1251. }
  1252. // openAICapacityShedRetryableClientCode 是把上游容量降载错误转发给客户端时改写
  1253. // 使用的错误码。Codex CLI 按闭集对错误码分类:server_is_overloaded / slow_down
  1254. // 被判为致命错误(客户端提示 "Selected model is at capacity. Please try a
  1255. // different model." 并直接终止会话),而 server_error 等致命集之外的错误码会进入
  1256. // 客户端内置的退避重试。
  1257. const openAICapacityShedRetryableClientCode = "server_error"
  1258. // sanitizeOpenAICapacityShedErrorCodeForClient 把即将写给下游客户端的
  1259. // error / response.failed 事件中的容量降载错误码改写为客户端可重试的错误码。
  1260. // 走到转发这一步说明网关侧 failover 已不可用(流中途)或已用尽;保留原始降载码
  1261. // 只会让客户端就地终止会话。错误消息原样保留;监控与账号状态判定都基于改写前
  1262. // 的原始 payload,不受影响。rate_limit 等其他错误码一律不动(客户端依赖
  1263. // rate_limit_exceeded 原码解析重试延时)。
  1264. func sanitizeOpenAICapacityShedErrorCodeForClient(payload []byte) ([]byte, bool) {
  1265. if len(payload) == 0 || !gjson.ValidBytes(payload) || !isOpenAIUpstreamCapacityShedEvent(payload) {
  1266. return payload, false
  1267. }
  1268. updated := payload
  1269. changed := false
  1270. for _, path := range []string{"response.error.code", "error.code"} {
  1271. parent := strings.TrimSuffix(path, ".code")
  1272. if !gjson.GetBytes(updated, parent).Exists() {
  1273. continue
  1274. }
  1275. code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(updated, path).String()))
  1276. if code != "" && code != "server_is_overloaded" && code != "slow_down" {
  1277. continue
  1278. }
  1279. next, err := sjson.SetBytes(updated, path, openAICapacityShedRetryableClientCode)
  1280. if err != nil {
  1281. return payload, false
  1282. }
  1283. updated = next
  1284. changed = true
  1285. }
  1286. return updated, changed
  1287. }
  1288. func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int {
  1289. if isOpenAIContextWindowError(message, payload) {
  1290. return http.StatusBadRequest
  1291. }
  1292. code := openAIStreamFailedEventErrorCode(payload)
  1293. errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.type").String()))
  1294. if errType == "" {
  1295. errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String()))
  1296. }
  1297. combined := strings.TrimSpace(errType + " " + code + " " + strings.ToLower(strings.TrimSpace(message)))
  1298. for _, path := range []string{"response.error.status_code", "error.status_code", "status_code"} {
  1299. if status := int(gjson.GetBytes(payload, path).Int()); status == http.StatusUnauthorized ||
  1300. status == http.StatusForbidden || status == http.StatusTooManyRequests || status == 529 {
  1301. return status
  1302. }
  1303. }
  1304. switch {
  1305. case strings.Contains(combined, "rate_limit"):
  1306. return http.StatusTooManyRequests
  1307. case strings.Contains(errType, "invalid_request"):
  1308. return http.StatusBadRequest
  1309. case strings.Contains(combined, "authentication") || strings.Contains(combined, "unauthorized") || strings.Contains(combined, "invalid_api_key"):
  1310. return http.StatusUnauthorized
  1311. case strings.Contains(combined, "permission") || strings.Contains(combined, "forbidden") || strings.Contains(combined, "access denied"):
  1312. return http.StatusForbidden
  1313. case isOpenAIUpstreamAccessStateError(message, payload):
  1314. return http.StatusForbidden
  1315. case isOpenAIUpstreamCapacityShedEvent(payload):
  1316. return http.StatusServiceUnavailable
  1317. default:
  1318. return http.StatusBadGateway
  1319. }
  1320. }
  1321. func openAIStreamFailureStatus(payload []byte, message string) int {
  1322. if len(bytes.TrimSpace(payload)) == 0 || !gjson.ValidBytes(payload) {
  1323. return http.StatusBadGateway
  1324. }
  1325. semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message)
  1326. switch semanticStatus {
  1327. case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests, 529:
  1328. return semanticStatus
  1329. case http.StatusServiceUnavailable:
  1330. if isOpenAIUpstreamCapacityShedEvent(payload) {
  1331. return semanticStatus
  1332. }
  1333. }
  1334. return http.StatusBadGateway
  1335. }
  1336. // openAIStreamCredentialAuthFailure distinguishes credential failures from
  1337. // request/content permission denials carried inside an HTTP 200 stream. Do not
  1338. // infer credential health from free-form 403 messages: providers also use
  1339. // permission_error/forbidden/access denied for request-scoped policy failures.
  1340. func openAIStreamCredentialAuthFailure(payload []byte) bool {
  1341. if len(bytes.TrimSpace(payload)) == 0 || !gjson.ValidBytes(payload) {
  1342. return false
  1343. }
  1344. for _, path := range []string{"response.error.status_code", "error.status_code", "status_code"} {
  1345. if int(gjson.GetBytes(payload, path).Int()) == http.StatusUnauthorized {
  1346. return true
  1347. }
  1348. }
  1349. for _, path := range []string{"response.error.type", "error.type", "type"} {
  1350. errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, path).String()))
  1351. if errType == "authentication_error" || errType == "authentication_failed" || errType == "unauthorized_error" {
  1352. return true
  1353. }
  1354. }
  1355. for _, path := range []string{"response.error.code", "error.code", "code"} {
  1356. switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, path).String())) {
  1357. case "invalid_api_key", "api_key_disabled", "unauthorized", "authentication_error",
  1358. "invalid_token", "access_token_invalid", "token_revoked", "token_invalidated",
  1359. "invalid_credentials", "credential_invalid":
  1360. return true
  1361. }
  1362. }
  1363. return false
  1364. }
  1365. func openAIStream403AccountFailure(payload []byte, message string) bool {
  1366. return isOpenAIUpstreamAccessStateError(message, payload) || openAIStreamCredentialAuthFailure(payload)
  1367. }
  1368. func openAIStreamFailedEventPassthroughBody(payload []byte, failedMessage string) []byte {
  1369. if len(payload) == 0 || !gjson.ValidBytes(payload) {
  1370. return payload
  1371. }
  1372. if gjson.GetBytes(payload, "error").Exists() {
  1373. return payload
  1374. }
  1375. responseError := gjson.GetBytes(payload, "response.error")
  1376. if !responseError.Exists() {
  1377. if strings.TrimSpace(failedMessage) == "" {
  1378. return payload
  1379. }
  1380. body, err := marshalOpenAIUpstreamJSON(gin.H{
  1381. "error": gin.H{
  1382. "message": failedMessage,
  1383. },
  1384. })
  1385. if err != nil {
  1386. return payload
  1387. }
  1388. return body
  1389. }
  1390. errorPayload := gin.H{}
  1391. if errType := strings.TrimSpace(gjson.Get(responseError.Raw, "type").String()); errType != "" {
  1392. errorPayload["type"] = errType
  1393. }
  1394. if code := strings.TrimSpace(gjson.Get(responseError.Raw, "code").String()); code != "" {
  1395. errorPayload["code"] = code
  1396. }
  1397. if param := strings.TrimSpace(gjson.Get(responseError.Raw, "param").String()); param != "" {
  1398. errorPayload["param"] = param
  1399. }
  1400. message := strings.TrimSpace(gjson.Get(responseError.Raw, "message").String())
  1401. if message == "" {
  1402. message = strings.TrimSpace(failedMessage)
  1403. }
  1404. if message != "" {
  1405. errorPayload["message"] = message
  1406. }
  1407. if len(errorPayload) == 0 {
  1408. return payload
  1409. }
  1410. body, err := marshalOpenAIUpstreamJSON(gin.H{"error": errorPayload})
  1411. if err != nil {
  1412. return payload
  1413. }
  1414. return body
  1415. }
  1416. // applyOpenAIStreamFailedErrorPassthroughRule 对 response.failed 事件应用错误透传规则:
  1417. // 归一化 body 供关键词匹配/消息提取,并推断语义状态码使按错误码配置的规则可以命中。
  1418. // platform 必须传 account.Platform——本服务同时承载 openai 与 grok 平台账号,规则按平台匹配。
  1419. func applyOpenAIStreamFailedErrorPassthroughRule(
  1420. c *gin.Context,
  1421. platform string,
  1422. payload []byte,
  1423. failedMessage string,
  1424. ) (status int, errType string, errMsg string, matched bool) {
  1425. ruleBody := openAIStreamFailedEventPassthroughBody(payload, failedMessage)
  1426. upstreamStatus := openAIStreamFailedEventSemanticStatus(payload, failedMessage)
  1427. return applyErrorPassthroughRule(
  1428. c,
  1429. platform,
  1430. upstreamStatus,
  1431. ruleBody,
  1432. http.StatusBadGateway,
  1433. "upstream_error",
  1434. "Upstream request failed",
  1435. )
  1436. }
  1437. func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool {
  1438. if hit, _, _ := detectOpenAICyberPolicy(payload); hit {
  1439. return false
  1440. }
  1441. if isOpenAIContextWindowError(message, payload) {
  1442. return false
  1443. }
  1444. if isOpenAIUpstreamAccessStateError(message, payload) {
  1445. return true
  1446. }
  1447. semanticStatus := openAIStreamFailureStatus(payload, message)
  1448. if semanticStatus == http.StatusForbidden {
  1449. return openAIStream403AccountFailure(payload, message)
  1450. }
  1451. // A response.failed event is transported over HTTP 200. Prefer its semantic
  1452. // rate-limit status over a generic/invalid_request error type so it can enter
  1453. // the same 429 retry policy as a regular upstream HTTP response.
  1454. if semanticStatus == http.StatusTooManyRequests {
  1455. return true
  1456. }
  1457. if isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) {
  1458. return true
  1459. }
  1460. code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
  1461. if code == "" {
  1462. code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
  1463. }
  1464. errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.type").String()))
  1465. if errType == "" {
  1466. errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String()))
  1467. }
  1468. combined := strings.ToLower(strings.TrimSpace(message + " " + code + " " + errType))
  1469. if combined == "" {
  1470. return true
  1471. }
  1472. nonRetryableMarkers := []string{
  1473. "invalid_request",
  1474. "content_policy",
  1475. "policy",
  1476. "safety",
  1477. "high-risk cyber",
  1478. "not allowed",
  1479. "violat",
  1480. }
  1481. for _, marker := range nonRetryableMarkers {
  1482. if strings.Contains(combined, marker) {
  1483. return false
  1484. }
  1485. }
  1486. return true
  1487. }
  1488. func openAIStreamErrorEventShouldFailover(payload []byte, message string) bool {
  1489. if hit, _, _ := detectOpenAICyberPolicy(payload); hit {
  1490. return false
  1491. }
  1492. if isOpenAIContextWindowError(message, payload) {
  1493. return false
  1494. }
  1495. if isOpenAIUpstreamAccessStateError(message, payload) {
  1496. return true
  1497. }
  1498. switch openAIStreamFailedEventSemanticStatus(payload, message) {
  1499. case http.StatusForbidden:
  1500. return openAIStream403AccountFailure(payload, message)
  1501. case http.StatusUnauthorized, http.StatusTooManyRequests, 529:
  1502. return true
  1503. }
  1504. if isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) {
  1505. return true
  1506. }
  1507. combined := strings.ToLower(strings.TrimSpace(message + " " +
  1508. gjson.GetBytes(payload, "error.message").String() + " " +
  1509. gjson.GetBytes(payload, "response.error.message").String()))
  1510. return strings.Contains(combined, "temporary") ||
  1511. strings.Contains(combined, "try again") ||
  1512. strings.Contains(combined, "please retry")
  1513. }
  1514. func (s *OpenAIGatewayService) handleOpenAIStreamTerminalAccountSideEffects(
  1515. c *gin.Context,
  1516. account *Account,
  1517. payload []byte,
  1518. message string,
  1519. headers http.Header,
  1520. canonicalModel ...string,
  1521. ) (int, bool) {
  1522. statusCode := openAIStreamFailureStatus(payload, message)
  1523. switch statusCode {
  1524. case http.StatusForbidden:
  1525. if !openAIStream403AccountFailure(payload, message) {
  1526. return statusCode, false
  1527. }
  1528. fallthrough
  1529. case http.StatusUnauthorized, http.StatusTooManyRequests, 529:
  1530. ctx := context.Background()
  1531. if c != nil && c.Request != nil {
  1532. ctx = c.Request.Context()
  1533. }
  1534. model := firstNonEmpty(canonicalModel...)
  1535. if model == "" {
  1536. model = firstNonEmpty(gjson.GetBytes(payload, "model").String(), gjson.GetBytes(payload, "response.model").String())
  1537. }
  1538. accountHeaders := headers
  1539. if statusCode == http.StatusTooManyRequests {
  1540. // 普通模型的流式 429 不能继承外层 HTTP 200 的全局 quota 快照;
  1541. // 只有 OAuth/SetupToken 的 Spark 配额 429 才需要保留 headers 读取明确的 5h/7d reset。
  1542. accountHeaders = openAIWSSemantic429Headers(account, model, headers)
  1543. }
  1544. return statusCode, s.handleOpenAIAccountUpstreamError(ctx, account, statusCode, accountHeaders, payload, model)
  1545. default:
  1546. return statusCode, false
  1547. }
  1548. }
  1549. func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool {
  1550. if account == nil {
  1551. return false
  1552. }
  1553. // 容量降载是请求级信号,不是账号级故障:上游只是让本次请求稍后再试。
  1554. // 换账号并不改变被降载的因素(客户端身份、模型容量都与账号无关),
  1555. // 只会让单个请求把整池账号逐个消耗掉,最终仍以同一个错误告终。
  1556. // 因此先在同一账号上做有界重试,用尽后才按常规流程切号。
  1557. if isOpenAIUpstreamCapacityShedEvent(payload) {
  1558. return true
  1559. }
  1560. if !account.IsPoolMode() {
  1561. return false
  1562. }
  1563. semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message)
  1564. return account.IsPoolModeRetryableStatus(semanticStatus) ||
  1565. isOpenAITransientProcessingError(http.StatusBadRequest, message, payload)
  1566. }
  1567. func (s *OpenAIGatewayService) recordOpenAIStreamUpstreamError(
  1568. c *gin.Context,
  1569. account *Account,
  1570. passthrough bool,
  1571. upstreamRequestID string,
  1572. kind string,
  1573. payload []byte,
  1574. message string,
  1575. ) string {
  1576. message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message))
  1577. if message == "" {
  1578. message = "OpenAI upstream response failed"
  1579. }
  1580. statusCode := openAIStreamFailureStatus(payload, message)
  1581. detail := ""
  1582. if len(payload) > 0 && s != nil && s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  1583. maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
  1584. if maxBytes <= 0 {
  1585. maxBytes = 2048
  1586. }
  1587. detail = truncateString(string(payload), maxBytes)
  1588. }
  1589. if c != nil {
  1590. setOpsUpstreamError(c, statusCode, message, detail)
  1591. event := OpsUpstreamErrorEvent{
  1592. ProxyID: opsUpstreamProxyID(account),
  1593. ProxyName: opsUpstreamProxyName(account),
  1594. Platform: PlatformOpenAI,
  1595. UpstreamStatusCode: statusCode,
  1596. UpstreamRequestID: strings.TrimSpace(upstreamRequestID),
  1597. Passthrough: passthrough,
  1598. Kind: kind,
  1599. Message: message,
  1600. Detail: detail,
  1601. }
  1602. if account != nil {
  1603. event.Platform = account.Platform
  1604. event.AccountID = account.ID
  1605. event.AccountName = account.Name
  1606. }
  1607. appendOpsUpstreamError(c, event)
  1608. }
  1609. return message
  1610. }
  1611. func (s *OpenAIGatewayService) newOpenAIStreamFailoverError(
  1612. c *gin.Context,
  1613. account *Account,
  1614. passthrough bool,
  1615. upstreamRequestID string,
  1616. payload []byte,
  1617. message string,
  1618. responseHeaders ...http.Header,
  1619. ) *UpstreamFailoverError {
  1620. return s.newOpenAIStreamFailoverErrorWithModel(c, account, passthrough, upstreamRequestID, payload, message, "", responseHeaders...)
  1621. }
  1622. func (s *OpenAIGatewayService) newOpenAIStreamFailoverErrorWithModel(
  1623. c *gin.Context,
  1624. account *Account,
  1625. passthrough bool,
  1626. upstreamRequestID string,
  1627. payload []byte,
  1628. message string,
  1629. canonicalModel string,
  1630. responseHeaders ...http.Header,
  1631. ) *UpstreamFailoverError {
  1632. message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message))
  1633. if message == "" {
  1634. message = "OpenAI stream disconnected before completion"
  1635. }
  1636. var headers http.Header
  1637. if len(responseHeaders) > 0 && responseHeaders[0] != nil {
  1638. headers = responseHeaders[0].Clone()
  1639. }
  1640. statusCode, shouldDisable := s.handleOpenAIStreamTerminalAccountSideEffects(c, account, payload, message, headers, canonicalModel)
  1641. // 流内 failed 事件承载于 HTTP 200;使用事件的语义状态更新账号健康,
  1642. // 再由 failover 引擎按 StatusCode/RetryableOnSameAccount 决定恢复策略。
  1643. message = s.recordOpenAIStreamUpstreamError(c, account, passthrough, upstreamRequestID, "failover", payload, message)
  1644. errType := "upstream_error"
  1645. if statusCode == http.StatusTooManyRequests {
  1646. errType = "rate_limit_error"
  1647. }
  1648. body, _ := json.Marshal(gin.H{
  1649. "error": gin.H{
  1650. "type": errType,
  1651. "message": message,
  1652. },
  1653. })
  1654. retryableOnSameAccount := openAIStreamFailedEventRetryableOnSameAccount(account, payload, message)
  1655. // 流终止事件承载在 HTTP 200 内,外层响应头描述的是成功流状态,而不是语义上的
  1656. // 429 事件。仅在配额分类时忽略这些头;故障转移错误仍保留它们,使 Retry-After
  1657. // 和请求 ID 能继续传递给后续处理。
  1658. classificationHeaders := headers
  1659. if statusCode == http.StatusTooManyRequests {
  1660. classificationHeaders = nil
  1661. }
  1662. failoverErr := s.newOpenAIAccountFailoverErrorWithClassificationHeaders(account, statusCode, headers, classificationHeaders, payload, message, shouldDisable, retryableOnSameAccount)
  1663. if failoverErr.IsCredentialFailure() || failoverErr.RequestScopedTransient {
  1664. return failoverErr
  1665. }
  1666. // Preserve the existing generic envelope for unclassified stream failures;
  1667. // only typed access/capacity failures need the original payload downstream.
  1668. failoverErr.ResponseBody = body
  1669. return failoverErr
  1670. }
  1671. // nonStreamingTerminalFailureFailover applies the streaming path's terminal-event
  1672. // verdict to a stream=false request whose upstream answered with SSE anyway
  1673. // (other sub2api instances and several OpenAI-compatible upstreams do this).
  1674. //
  1675. // Both handleSSEToJSON and handlePassthroughSSEToJSON collapsed every terminal
  1676. // `response.failed` / `error` frame into writeOpenAINonStreamingProtocolError, a
  1677. // fixed 502. The streaming readers sitting a few hundred lines away classify the
  1678. // very same frame with openAIStreamFailedEventShouldFailover /
  1679. // openAIStreamErrorEventShouldFailover and return an UpstreamFailoverError, so an
  1680. // upstream capacity error switched accounts when stream=true and was handed to the
  1681. // caller verbatim when stream=false — with other schedulable accounts still in the
  1682. // pool. Same event, same upstream, opposite outcome, decided only by a request flag
  1683. // the upstream never saw.
  1684. //
  1685. // Dispatching on terminalType keeps the two verdicts distinct exactly as the
  1686. // streaming readers do: a bare `error` frame goes through the conservative
  1687. // classifier that fails over only on positively transient markers, while
  1688. // `response.failed` uses the fuller one.
  1689. //
  1690. // Replay is safe here because the body was fully buffered by
  1691. // ReadUpstreamResponseBody and this runs before any semantic byte is written.
  1692. // Whether a failover actually happens stays the handler's call:
  1693. // openAIForwardMayFailover compares OpenAICompactKeepaliveAdjustedWrittenSize
  1694. // against its pre-Forward snapshot, so a request that already emitted output is
  1695. // refused there (#3887). This function only declines to propose failover once the
  1696. // service has explicitly committed a response, and never re-implements the
  1697. // keepalive accounting — a second copy of that rule would drift from the handler's.
  1698. //
  1699. // A nil account means there is nothing to fail over from: newOpenAIStreamFailoverError
  1700. // records ops attribution and account health against it, so those callers keep the
  1701. // protocol-error behaviour.
  1702. func (s *OpenAIGatewayService) nonStreamingTerminalFailureFailover(
  1703. c *gin.Context,
  1704. resp *http.Response,
  1705. account *Account,
  1706. passthrough bool,
  1707. terminalType string,
  1708. payload []byte,
  1709. message string,
  1710. canonicalModel ...string,
  1711. ) *UpstreamFailoverError {
  1712. if account == nil || IsResponseCommitted(c) {
  1713. return nil
  1714. }
  1715. shouldFailover := openAIStreamFailedEventShouldFailover(payload, message)
  1716. if terminalType == "error" {
  1717. shouldFailover = openAIStreamErrorEventShouldFailover(payload, message)
  1718. }
  1719. if !shouldFailover {
  1720. return nil
  1721. }
  1722. var headers http.Header
  1723. upstreamRequestID := ""
  1724. if resp != nil {
  1725. headers = resp.Header
  1726. upstreamRequestID = strings.TrimSpace(resp.Header.Get("x-request-id"))
  1727. }
  1728. return s.newOpenAIStreamFailoverErrorWithModel(c, account, passthrough, upstreamRequestID, payload, message, firstNonEmpty(canonicalModel...), headers)
  1729. }
  1730. func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
  1731. ctx context.Context,
  1732. resp *http.Response,
  1733. c *gin.Context,
  1734. account *Account,
  1735. startTime time.Time,
  1736. originalModel string,
  1737. mappedModel string,
  1738. ) (*openaiStreamingResultPassthrough, error) {
  1739. observer := upstreamResponseModelObserverFromContext(c)
  1740. if observer == nil {
  1741. observer = beginUpstreamResponseModelObservation(c)
  1742. }
  1743. writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
  1744. // SSE headers
  1745. c.Header("Content-Type", "text/event-stream")
  1746. c.Header("Cache-Control", "no-cache")
  1747. c.Header("Connection", "keep-alive")
  1748. c.Header("X-Accel-Buffering", "no")
  1749. if v := resp.Header.Get("x-request-id"); v != "" {
  1750. c.Header("x-request-id", v)
  1751. }
  1752. w := c.Writer
  1753. flusher, ok := w.(http.Flusher)
  1754. if !ok {
  1755. return nil, errors.New("streaming not supported")
  1756. }
  1757. usage := &OpenAIUsage{}
  1758. imageCounter := newOpenAIImageOutputCounter()
  1759. var firstTokenMs *int
  1760. responseID := ""
  1761. ttftMode := s.openAITTFTMode(ctx)
  1762. clientDisconnected := false
  1763. sawDone := false
  1764. sawTerminalEvent := false
  1765. sawFailedEvent := false
  1766. sawBareError := false
  1767. sawResponseFailed := false
  1768. terminalEventType := ""
  1769. semanticOutputSeen := false
  1770. capacityFailoverSuppressedLogged := false
  1771. failedMessage := ""
  1772. clientOutputStarted := false
  1773. codexFailureTerminal := account != nil && account.Platform == PlatformOpenAI
  1774. failureDelivered := false
  1775. suppressCurrentEvent := false
  1776. responseFailedPending := false
  1777. var bareErrorPayload []byte
  1778. bareErrorAccountSideEffectsPending := false
  1779. upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
  1780. // pendingLines 在首个可见输出前保留前导事件,确保无输出失败仍可安全 failover。
  1781. pendingLines := make([]string, 0, 8)
  1782. // ── 首个可见输出之前的下游 keepalive ──────────────────────────────────
  1783. //
  1784. // 与 Forward 路径同源的问题。openai_gateway_response_handling.go 里那句注释
  1785. // 说得最清楚:
  1786. //
  1787. // "Track downstream writes separately from upstream reads: pre-output
  1788. // failover can buffer response.created / response.in_progress, so
  1789. // keepalive must be based on downstream idle time."
  1790. //
  1791. // 上面的 pendingLines 正是同一种缓冲:首个可见输出到来之前,下游【一个字节
  1792. // 都收不到】—— 连 HTTP 响应头都不会提交(gin 的 ResponseWriter 直到首次写入
  1793. // 才发送 header)。Forward 路径为此加了心跳,透传路径漏了。
  1794. //
  1795. // 推理模型在首个可见输出前思考数百秒是常态,于是中间层代理会按空闲超时把
  1796. // 连接判死。这不是假设:某生产部署实测 12 小时内 44 个 /v1/responses 请求在
  1797. // 600~900s 才产出首个可见输出(每个 3~5 万 output token,上游其实算完了),
  1798. // 全部被中间 nginx 的 proxy_read_timeout(600s) 判超时回 504,用户一个字没拿到。
  1799. //
  1800. // 心跳写出的 SSE 注释同时做到三件事:
  1801. // 1. 提交 HTTP 响应头,让下游知道连接活着;
  1802. // 2. 刷新中间层的空闲超时(proxy_read_timeout 衡量的是两次读之间的间隔,
  1803. // 不是请求总时长),长推理因此不再被误杀;
  1804. // 3. 不写出任何 pendingLines、不泄露账号相关的头,
  1805. // 且心跳字节已由 OpenAICompactKeepaliveAdjustedWrittenSize 排除,
  1806. // 所以 pre-output failover 的能力完全不受影响(#3887 的记账在此复用)。
  1807. //
  1808. // 用 startOpenAISSEKeepalive 而不是 StartOpenAICompactSSEKeepalive:后者会检查
  1809. // compact 标记,而这里是普通 /v1/responses 透传。走到这一行时上游已回
  1810. // text/event-stream、SSE 响应头也已设好,处于流式上下文是确定的。
  1811. stopKeepalive := func() {}
  1812. if s.cfg != nil && s.cfg.Gateway.StreamKeepaliveInterval > 0 {
  1813. stopKeepalive = startOpenAISSEKeepalive(c,
  1814. time.Duration(s.cfg.Gateway.StreamKeepaliveInterval)*time.Second)
  1815. }
  1816. // 任何返回路径都要停拍。Stop 与心跳 goroutine 之间有互斥锁,
  1817. // 返回后不会再有字节写出。
  1818. defer stopKeepalive()
  1819. // flushPending 表示已写入但未到 SSE 空行边界的脏状态;defer 兜底函数退出前的残留,断连后不再 Flush。
  1820. flushPending := false
  1821. pendingSSEEventType := ""
  1822. flushPendingOutput := func() {
  1823. if clientDisconnected || !flushPending {
  1824. return
  1825. }
  1826. flusher.Flush()
  1827. flushPending = false
  1828. }
  1829. defer flushPendingOutput()
  1830. writePendingLines := func() bool {
  1831. for _, pending := range pendingLines {
  1832. if _, err := fmt.Fprintln(w, pending); err != nil {
  1833. clientDisconnected = true
  1834. logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID)
  1835. return false
  1836. }
  1837. }
  1838. pendingLines = pendingLines[:0]
  1839. return true
  1840. }
  1841. ensureResponseFailedTerminal := func() {
  1842. if !sawBareError || sawResponseFailed || failureDelivered {
  1843. return
  1844. }
  1845. if bareErrorAccountSideEffectsPending {
  1846. s.handleOpenAIStreamTerminalAccountSideEffects(c, account, bareErrorPayload, failedMessage, resp.Header, mappedModel)
  1847. bareErrorAccountSideEffectsPending = false
  1848. }
  1849. if clientDisconnected || !writePendingLines() {
  1850. return
  1851. }
  1852. if _, err := fmt.Fprint(w, buildOpenAIResponseFailedSSE(responseID, originalModel, bareErrorPayload, failedMessage)); err != nil {
  1853. clientDisconnected = true
  1854. return
  1855. }
  1856. clientOutputStarted = true
  1857. failureDelivered = true
  1858. flushPending = true
  1859. flushPendingOutput()
  1860. }
  1861. scanner := bufio.NewScanner(resp.Body)
  1862. maxLineSize := defaultMaxLineSize
  1863. if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
  1864. maxLineSize = s.cfg.Gateway.MaxLineSize
  1865. }
  1866. scanBuf := getSSEScannerBuf64K()
  1867. scanner.Buffer(scanBuf[:0], maxLineSize)
  1868. defer putSSEScannerBuf64K(scanBuf)
  1869. documentScanner := newOpenAISSEJSONDocumentScanner(scanner)
  1870. needModelReplace := strings.TrimSpace(originalModel) != "" && strings.TrimSpace(mappedModel) != "" && strings.TrimSpace(originalModel) != strings.TrimSpace(mappedModel)
  1871. resultWithUsage := func() *openaiStreamingResultPassthrough {
  1872. return &openaiStreamingResultPassthrough{
  1873. usage: usage,
  1874. firstTokenMs: firstTokenMs,
  1875. responseID: responseID,
  1876. imageCount: imageCounter.Count(),
  1877. imageOutputSizes: imageCounter.Sizes(),
  1878. }
  1879. }
  1880. for documentScanner.Scan() {
  1881. line := documentScanner.Text()
  1882. if eventType, ok := extractOpenAISSEEventLine(line); ok {
  1883. pendingSSEEventType = eventType
  1884. eventType = strings.TrimSpace(eventType)
  1885. suppressCurrentEvent = codexFailureTerminal && (eventType == "error" || (sawBareError && !sawResponseFailed && eventType != "response.failed"))
  1886. }
  1887. lineStartsClientOutput := false
  1888. forceFlushFailedEvent := false
  1889. if data, ok := extractOpenAISSEDataLine(line); ok {
  1890. dataBytes := []byte(data)
  1891. trimmedData := strings.TrimSpace(data)
  1892. rawEventType := effectiveOpenAISSEEventType(dataBytes, pendingSSEEventType)
  1893. observer.ObserveOpenAI(dataBytes, rawEventType)
  1894. if needModelReplace && strings.Contains(data, mappedModel) {
  1895. line = s.replaceModelInSSELine(line, mappedModel, originalModel)
  1896. if replacedData, replaced := extractOpenAISSEDataLine(line); replaced {
  1897. dataBytes = []byte(replacedData)
  1898. trimmedData = strings.TrimSpace(replacedData)
  1899. }
  1900. }
  1901. if normalizedData, normalized := normalizeOpenAIResponsesFunctionCallArguments(dataBytes); normalized {
  1902. dataBytes = normalizedData
  1903. trimmedData = strings.TrimSpace(string(normalizedData))
  1904. line = "data: " + string(normalizedData)
  1905. }
  1906. if normalizedData, normalized := normalizeCompletedImageGenerationStatus(dataBytes); normalized {
  1907. dataBytes = normalizedData
  1908. trimmedData = strings.TrimSpace(string(normalizedData))
  1909. line = "data: " + string(normalizedData)
  1910. }
  1911. if trimmedData != "[DONE]" {
  1912. restoredData, restoreErr := restoreOpenAIResponsesNamespacePayload(c, dataBytes)
  1913. if restoreErr != nil {
  1914. return resultWithUsage(), fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr)
  1915. }
  1916. restoredData = restoreCodexToolNamesFromSSEContext(c, restoredData, rawEventType)
  1917. if !bytes.Equal(restoredData, dataBytes) {
  1918. dataBytes = restoredData
  1919. trimmedData = strings.TrimSpace(string(restoredData))
  1920. line = "data: " + string(restoredData)
  1921. }
  1922. }
  1923. eventType := effectiveOpenAISSEEventType(dataBytes, rawEventType)
  1924. if codexFailureTerminal && sawBareError && !sawResponseFailed && eventType != "response.failed" {
  1925. suppressCurrentEvent = true
  1926. }
  1927. if !capacityFailoverSuppressedLogged && account != nil && account.Platform == PlatformOpenAI &&
  1928. (eventType == "error" || eventType == "response.failed") &&
  1929. openAIStreamClientOutputStarted(c, clientOutputStarted) &&
  1930. isOpenAIUpstreamCapacityShedEvent(dataBytes) {
  1931. logOpenAICapacityFailoverSuppressed(ctx, account, "passthrough_sse", upstreamRequestID, eventType)
  1932. capacityFailoverSuppressedLogged = true
  1933. }
  1934. cyberHit := false
  1935. if eventType == "response.failed" || eventType == "error" {
  1936. if codexFailureTerminal && eventType == "error" {
  1937. sawBareError = true
  1938. bareErrorPayload = append(bareErrorPayload[:0], dataBytes...)
  1939. suppressCurrentEvent = true
  1940. } else if codexFailureTerminal && eventType == "response.failed" {
  1941. sawResponseFailed = true
  1942. }
  1943. responseFailedPending = !codexFailureTerminal || eventType == "response.failed"
  1944. failedMessage = extractOpenAISSEErrorMessage(dataBytes)
  1945. if failedMessage == "" {
  1946. failedMessage = "Upstream response failed"
  1947. }
  1948. // response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析
  1949. // 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费
  1950. // 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。
  1951. s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
  1952. if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit {
  1953. cyberHit = true
  1954. MarkOpsCyberPolicy(c, CyberPolicyMark{
  1955. Code: code,
  1956. Message: msg,
  1957. Body: truncateString(string(dataBytes), 4096),
  1958. UpstreamStatus: http.StatusOK,
  1959. UpstreamInTok: usage.InputTokens,
  1960. UpstreamOutTok: usage.OutputTokens,
  1961. })
  1962. }
  1963. outputStarted := openAIStreamClientOutputStarted(c, clientOutputStarted)
  1964. if !outputStarted && !cyberHit {
  1965. if compactErr := newOpenAICompactFallbackSignal(c, dataBytes, failedMessage); compactErr != nil {
  1966. return resultWithUsage(), compactErr
  1967. }
  1968. }
  1969. if outputStarted && !cyberHit {
  1970. if codexFailureTerminal && eventType == "error" {
  1971. // Wait for the authoritative response.failed before mutating
  1972. // account health; EOF synthesis applies the pending effect.
  1973. bareErrorAccountSideEffectsPending = true
  1974. } else {
  1975. s.handleOpenAIStreamTerminalAccountSideEffects(c, account, dataBytes, failedMessage, resp.Header, mappedModel)
  1976. bareErrorAccountSideEffectsPending = false
  1977. }
  1978. if eventType == "response.failed" {
  1979. // The stream cannot be replayed after semantic output. Preserve the
  1980. // terminal event, while making the upstream failure queryable.
  1981. s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "stream_failed", dataBytes, failedMessage)
  1982. }
  1983. }
  1984. if !outputStarted {
  1985. shouldFailover := false
  1986. if !cyberHit {
  1987. if eventType == "error" {
  1988. shouldFailover = openAIStreamErrorEventShouldFailover(dataBytes, failedMessage)
  1989. } else {
  1990. shouldFailover = openAIStreamFailedEventShouldFailover(dataBytes, failedMessage)
  1991. }
  1992. }
  1993. if shouldFailover {
  1994. return resultWithUsage(),
  1995. s.newOpenAIStreamFailoverErrorWithModel(c, account, true, upstreamRequestID, dataBytes, failedMessage, mappedModel, resp.Header)
  1996. }
  1997. if !cyberHit && !sawBareError {
  1998. if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
  1999. // 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
  2000. // antigravity 先例),否则透传命中的 failed 在监控中不可见。
  2001. s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, failedMessage)
  2002. MarkResponseCommitted(c)
  2003. c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
  2004. c.JSON(status, gin.H{
  2005. "error": gin.H{
  2006. "type": errType,
  2007. "message": errMsg,
  2008. },
  2009. })
  2010. return resultWithUsage(), fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg)
  2011. }
  2012. }
  2013. }
  2014. forceFlushFailedEvent = true
  2015. sawFailedEvent = true
  2016. }
  2017. if trimmedData == "[DONE]" {
  2018. sawDone = true
  2019. terminalEventType = "[DONE]"
  2020. }
  2021. if openAIStreamEventIsTerminalWithType(trimmedData, eventType) {
  2022. sawTerminalEvent = true
  2023. if trimmedData != "[DONE]" {
  2024. terminalEventType = eventType
  2025. }
  2026. }
  2027. if responseID == "" {
  2028. responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes)
  2029. }
  2030. imageCounter.AddSSEData(dataBytes)
  2031. if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient(
  2032. dataBytes,
  2033. eventType,
  2034. openAIStreamClientOutputStarted(c, clientOutputStarted),
  2035. ); sanitized {
  2036. dataBytes = sanitizedData
  2037. trimmedData = strings.TrimSpace(string(sanitizedData))
  2038. line = "data: " + string(sanitizedData)
  2039. }
  2040. lineStartsClientOutput = forceFlushFailedEvent || openAIStreamDataStartsClientOutput(trimmedData, eventType)
  2041. if lineStartsClientOutput && trimmedData != "[DONE]" && !openAIStreamEventTypeIsTerminal(eventType) {
  2042. semanticOutputSeen = true
  2043. }
  2044. // OpenAI Responses streams that terminate with an empty
  2045. // response.completed (no output, no usage, no error, nothing sent
  2046. // to the client) are silent upstream refusals: fail over instead of
  2047. // recording a successful 0/0 usage turn (issue #5009).
  2048. if (eventType == "response.completed" || eventType == "response.done") &&
  2049. !sawFailedEvent && !semanticOutputSeen && !clientOutputStarted &&
  2050. openAIResponsesCompletedEventIsEmpty(dataBytes, usage) {
  2051. return resultWithUsage(), newOpenAIResponsesEmptyCompletedFailoverError(c, account, upstreamRequestID)
  2052. }
  2053. if firstTokenMs == nil && openAIStreamDataStartsTTFT(trimmedData, eventType, forceFlushFailedEvent, ttftMode) {
  2054. ms := int(time.Since(startTime).Milliseconds())
  2055. firstTokenMs = &ms
  2056. }
  2057. s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
  2058. }
  2059. if line == "" {
  2060. pendingSSEEventType = ""
  2061. if suppressCurrentEvent {
  2062. suppressCurrentEvent = false
  2063. responseFailedPending = false
  2064. continue
  2065. }
  2066. }
  2067. if !clientDisconnected && !failureDelivered && !suppressCurrentEvent {
  2068. if !clientOutputStarted && !lineStartsClientOutput {
  2069. pendingLines = append(pendingLines, line)
  2070. continue
  2071. }
  2072. // 真实输出开始,心跳的使命结束。停拍是幂等的,且会与心跳 goroutine
  2073. // 建立 happens-before —— 之后 ResponseWriter 由本循环独占。
  2074. if !clientOutputStarted {
  2075. stopKeepalive()
  2076. }
  2077. if !clientOutputStarted && len(pendingLines) > 0 {
  2078. if !writePendingLines() {
  2079. continue
  2080. }
  2081. }
  2082. if _, err := fmt.Fprintln(w, line); err != nil {
  2083. clientDisconnected = true
  2084. logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID)
  2085. } else {
  2086. clientOutputStarted = true
  2087. flushPending = true
  2088. if line == "" {
  2089. flushPendingOutput()
  2090. }
  2091. }
  2092. }
  2093. if line == "" && responseFailedPending {
  2094. responseFailedPending = false
  2095. failureDelivered = true
  2096. }
  2097. }
  2098. ensureResponseFailedTerminal()
  2099. if err := documentScanner.Err(); err != nil {
  2100. if (sawDone || sawTerminalEvent) && !sawFailedEvent {
  2101. s.clearOpenAIProxyStreamDisconnect(account)
  2102. return resultWithUsage(), nil
  2103. }
  2104. if sawFailedEvent {
  2105. return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
  2106. }
  2107. if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
  2108. return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", err)
  2109. }
  2110. if errors.Is(err, bufio.ErrTooLong) {
  2111. logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, err)
  2112. return resultWithUsage(), err
  2113. }
  2114. if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
  2115. msg := "OpenAI stream disconnected before completion"
  2116. if errText := strings.TrimSpace(err.Error()); errText != "" {
  2117. msg += ": " + errText
  2118. }
  2119. return resultWithUsage(),
  2120. s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, nil, msg)
  2121. }
  2122. if clientDisconnected {
  2123. return resultWithUsage(), fmt.Errorf("stream usage incomplete after disconnect: %w", err)
  2124. }
  2125. s.recordOpenAIProxyStreamDisconnect(account, err, upstreamRequestID)
  2126. logger.LegacyPrintf("service.openai_gateway",
  2127. "[OpenAI passthrough] 流读取异常中断: account=%d request_id=%s err=%v",
  2128. account.ID,
  2129. upstreamRequestID,
  2130. err,
  2131. )
  2132. return resultWithUsage(), fmt.Errorf("stream read error: %w", err)
  2133. }
  2134. if sawFailedEvent {
  2135. return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
  2136. }
  2137. if !clientDisconnected && !sawDone && !sawTerminalEvent && ctx.Err() == nil {
  2138. logger.FromContext(ctx).With(
  2139. zap.String("component", "service.openai_gateway"),
  2140. zap.Int64("account_id", account.ID),
  2141. zap.String("upstream_request_id", upstreamRequestID),
  2142. ).Info("OpenAI passthrough 上游流在未收到 [DONE] 时结束,疑似断流")
  2143. if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
  2144. return resultWithUsage(),
  2145. s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, nil, "OpenAI stream ended before a terminal event")
  2146. }
  2147. s.recordOpenAIProxyStreamDisconnect(account, errors.New("stream ended before terminal event"), upstreamRequestID)
  2148. return resultWithUsage(), errors.New("stream usage incomplete: missing terminal event")
  2149. }
  2150. if (sawDone || sawTerminalEvent) && !sawFailedEvent {
  2151. s.clearOpenAIProxyStreamDisconnect(account)
  2152. }
  2153. logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, terminalEventType, clientDisconnected)
  2154. return resultWithUsage(), nil
  2155. }
  2156. func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
  2157. ctx context.Context,
  2158. resp *http.Response,
  2159. c *gin.Context,
  2160. account *Account,
  2161. originalModel string,
  2162. mappedModel string,
  2163. ) (*openaiNonStreamingResultPassthrough, error) {
  2164. body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
  2165. if err != nil {
  2166. return nil, err
  2167. }
  2168. observer := upstreamResponseModelObserverFromContext(c)
  2169. if observer == nil {
  2170. observer = beginUpstreamResponseModelObservation(c)
  2171. }
  2172. if bodyHasSSEFraming(body) {
  2173. observeOpenAISSEBody(observer, string(body))
  2174. } else {
  2175. observer.ObserveOpenAI(body, strings.TrimSpace(gjson.GetBytes(body, "type").String()))
  2176. }
  2177. // Detect SSE responses from upstream and convert to JSON.
  2178. // Some upstreams (e.g. other sub2api instances) may return SSE even when
  2179. // stream=false was requested. Without this conversion the client would
  2180. // receive raw SSE text or a terminal event with empty output.
  2181. if isEventStreamResponse(resp.Header) {
  2182. return s.handlePassthroughSSEToJSON(resp, c, account, body, originalModel, mappedModel)
  2183. }
  2184. usage := &OpenAIUsage{}
  2185. usageParsed := false
  2186. if len(body) > 0 {
  2187. if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(body); ok {
  2188. *usage = parsedUsage
  2189. usageParsed = true
  2190. }
  2191. }
  2192. if !usageParsed {
  2193. // 兜底:尝试从 SSE 文本中解析 usage
  2194. usage = s.parseSSEUsageFromBody(string(body))
  2195. }
  2196. logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, "json", false)
  2197. writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
  2198. contentType := resp.Header.Get("Content-Type")
  2199. if contentType == "" {
  2200. contentType = "application/json"
  2201. }
  2202. if originalModel != "" && mappedModel != "" && originalModel != mappedModel {
  2203. body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
  2204. }
  2205. body, err = restoreOpenAIResponsesNamespacePayload(c, body)
  2206. if err != nil {
  2207. return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", err)
  2208. }
  2209. body = restoreCodexToolNamesFromContext(c, body)
  2210. body, err = restoreOpenAIResponsesClientToolPayload(c, body)
  2211. if err != nil {
  2212. return nil, fmt.Errorf("restore OpenAI Responses client tools: %w", err)
  2213. }
  2214. if !writeOpenAICompactSSEBridge(c, resp.StatusCode, body) {
  2215. c.Data(resp.StatusCode, contentType, body)
  2216. }
  2217. return &openaiNonStreamingResultPassthrough{
  2218. OpenAIUsage: usage,
  2219. usage: usage,
  2220. responseID: extractOpenAIResponseIDFromJSONBytes(body),
  2221. imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
  2222. imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body),
  2223. }, nil
  2224. }
  2225. // handlePassthroughSSEToJSON converts an SSE response body into a JSON
  2226. // response for the passthrough path. It mirrors handleSSEToJSON while
  2227. // preserving passthrough payloads, except compact-only model remapping may
  2228. // rewrite model fields back to the original requested model.
  2229. func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, account *Account, body []byte, originalModel string, mappedModel string) (*openaiNonStreamingResultPassthrough, error) {
  2230. bodyText := string(body)
  2231. terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
  2232. if terminalOK && (terminalType == "response.failed" || terminalType == "error") {
  2233. msg := extractOpenAISSEErrorMessage(terminalPayload)
  2234. if msg == "" {
  2235. msg = "Upstream compact response failed"
  2236. }
  2237. if compactErr := newOpenAICompactFallbackSignal(c, terminalPayload, msg); compactErr != nil {
  2238. return nil, compactErr
  2239. }
  2240. if failoverErr := s.nonStreamingTerminalFailureFailover(c, resp, account, true, terminalType, terminalPayload, msg, mappedModel); failoverErr != nil {
  2241. return nil, failoverErr
  2242. }
  2243. return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg)
  2244. }
  2245. finalResponse, ok := extractCodexFinalResponse(bodyText)
  2246. usage := s.parseSSEUsageFromBody(bodyText)
  2247. if ok {
  2248. if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed {
  2249. *usage = parsedUsage
  2250. }
  2251. // When the terminal event has an empty output array, reconstruct
  2252. // output from accumulated delta events so the client gets full content.
  2253. if len(gjson.GetBytes(finalResponse, "output").Array()) == 0 {
  2254. if outputJSON, reconstructed := reconstructResponseOutputFromSSE(bodyText); reconstructed {
  2255. if patched, err := sjson.SetRawBytes(finalResponse, "output", outputJSON); err == nil {
  2256. finalResponse = patched
  2257. }
  2258. }
  2259. }
  2260. finalResponse = supplementCompactionItemFromSSE(c, finalResponse, bodyText)
  2261. body = finalResponse
  2262. if originalModel != "" && mappedModel != "" && originalModel != mappedModel {
  2263. body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
  2264. }
  2265. // Correct tool calls in final response
  2266. body = s.correctToolCallsInResponseBody(body)
  2267. restoredBody, restoreErr := restoreOpenAIResponsesNamespacePayload(c, body)
  2268. if restoreErr != nil {
  2269. return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr)
  2270. }
  2271. restoredBody = restoreCodexToolNamesFromContext(c, restoredBody)
  2272. body = restoredBody
  2273. } else {
  2274. if originalModel != "" && mappedModel != "" && originalModel != mappedModel {
  2275. bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel)
  2276. }
  2277. body = []byte(bodyText)
  2278. }
  2279. writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
  2280. logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, usage, terminalType, false)
  2281. contentType := "application/json; charset=utf-8"
  2282. if !ok {
  2283. contentType = resp.Header.Get("Content-Type")
  2284. if contentType == "" {
  2285. contentType = "text/event-stream"
  2286. }
  2287. }
  2288. if !writeOpenAICompactSSEBridge(c, resp.StatusCode, body) {
  2289. c.Data(resp.StatusCode, contentType, body)
  2290. }
  2291. return &openaiNonStreamingResultPassthrough{
  2292. OpenAIUsage: usage,
  2293. usage: usage,
  2294. responseID: extractOpenAIResponseIDFromJSONBytes(body),
  2295. imageCount: countOpenAIImageOutputsFromSSEBody(bodyText),
  2296. imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText),
  2297. }, nil
  2298. }
  2299. func writeOpenAIPassthroughResponseHeaders(dst http.Header, src http.Header, filter *responseheaders.CompiledHeaderFilter) {
  2300. if dst == nil || src == nil {
  2301. return
  2302. }
  2303. if filter != nil {
  2304. responseheaders.WriteFilteredHeaders(dst, src, filter)
  2305. } else {
  2306. // 兜底:尽量保留最基础的 content-type
  2307. if v := strings.TrimSpace(src.Get("Content-Type")); v != "" {
  2308. dst.Set("Content-Type", v)
  2309. }
  2310. }
  2311. // 透传模式强制放行 x-codex-* 响应头(若上游返回)。
  2312. // 注意:真实 http.Response.Header 的 key 一般会被 canonicalize;但为了兼容测试/自建响应,
  2313. // 这里用 EqualFold 做一次大小写不敏感的查找。
  2314. getCaseInsensitiveValues := func(h http.Header, want string) []string {
  2315. if h == nil {
  2316. return nil
  2317. }
  2318. for k, vals := range h {
  2319. if strings.EqualFold(k, want) {
  2320. return vals
  2321. }
  2322. }
  2323. return nil
  2324. }
  2325. for _, rawKey := range []string{
  2326. "x-codex-primary-used-percent",
  2327. "x-codex-primary-reset-after-seconds",
  2328. "x-codex-primary-window-minutes",
  2329. "x-codex-secondary-used-percent",
  2330. "x-codex-secondary-reset-after-seconds",
  2331. "x-codex-secondary-window-minutes",
  2332. "x-codex-primary-over-secondary-limit-percent",
  2333. } {
  2334. vals := getCaseInsensitiveValues(src, rawKey)
  2335. if len(vals) == 0 {
  2336. continue
  2337. }
  2338. key := http.CanonicalHeaderKey(rawKey)
  2339. dst.Del(key)
  2340. for _, v := range vals {
  2341. dst.Add(key, v)
  2342. }
  2343. }
  2344. // x-codex-turn-state:Codex 回合状态头,客户端会在同回合后续请求回带。
  2345. // 与上面的用量头不同,这里在上游缺失时也主动清除——failover 换号后残留
  2346. // 上一账号的 blob 会构成跨账号矛盾(openai_codex_turn_state.go)。
  2347. turnStateKey := http.CanonicalHeaderKey(openAICodexTurnStateHeader)
  2348. dst.Del(turnStateKey)
  2349. for _, v := range getCaseInsensitiveValues(src, openAICodexTurnStateHeader) {
  2350. dst.Add(turnStateKey, v)
  2351. }
  2352. }