| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474 |
- package service
- // 本文件承载 /v1/responses 透传转发及其流式、非流式响应与错误处理。
- import (
- "bufio"
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "sort"
- "strconv"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
- "github.com/gin-gonic/gin"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
- "go.uber.org/zap"
- )
- const openAIResponsesClientToolMappingContextKey = "openai_responses_client_tool_mapping"
- func hasOpenAIResponsesClientToolMapping(mapping apicompat.ResponsesClientToolMapping) bool {
- return len(mapping.CustomTools) > 0 || mapping.ToolSearch || len(mapping.NamespaceTools) > 0
- }
- func adaptOpenAIResponsesClientTools(body []byte) ([]byte, apicompat.ResponsesClientToolMapping, error) {
- if !needsOpenAIResponsesClientToolAdaptation(body) {
- return body, apicompat.ResponsesClientToolMapping{}, nil
- }
- decoder := json.NewDecoder(bytes.NewReader(body))
- decoder.UseNumber()
- var requestBody map[string]any
- if err := decoder.Decode(&requestBody); err != nil {
- return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools: %w", err)
- }
- var trailingValue any
- if err := decoder.Decode(&trailingValue); !errors.Is(err, io.EOF) {
- if err == nil {
- err = errors.New("multiple JSON values")
- }
- return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools trailing data: %w", err)
- }
- mapping, changed, err := apicompat.AdaptResponsesClientTools(requestBody)
- if err != nil || !changed {
- return body, mapping, err
- }
- rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
- if err != nil {
- return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("encode OpenAI Responses client tools: %w", err)
- }
- return rebuilt, mapping, nil
- }
- func needsOpenAIResponsesClientToolAdaptation(body []byte) bool {
- needsAdaptation := false
- var visit func(gjson.Result) bool
- visit = func(value gjson.Result) bool {
- if value.IsObject() {
- switch strings.TrimSpace(value.Get("type").String()) {
- case "custom", "custom_tool_call", "custom_tool_call_output",
- "tool_search", "tool_search_call", "tool_search_output":
- needsAdaptation = true
- return false
- }
- }
- if value.IsObject() || value.IsArray() {
- value.ForEach(func(_, child gjson.Result) bool {
- return visit(child)
- })
- }
- return !needsAdaptation
- }
- visit(gjson.ParseBytes(body))
- return needsAdaptation
- }
- func openAIResponsesClientToolMapping(c *gin.Context) (apicompat.ResponsesClientToolMapping, bool) {
- if c == nil {
- return apicompat.ResponsesClientToolMapping{}, false
- }
- value, ok := c.Get(openAIResponsesClientToolMappingContextKey)
- mapping, typed := value.(apicompat.ResponsesClientToolMapping)
- return mapping, ok && typed && hasOpenAIResponsesClientToolMapping(mapping)
- }
- func setOpenAIResponsesClientToolMapping(c *gin.Context, mapping apicompat.ResponsesClientToolMapping) {
- if c == nil {
- return
- }
- if !hasOpenAIResponsesClientToolMapping(mapping) {
- clearOpenAIResponsesClientToolMapping(c)
- return
- }
- c.Set(openAIResponsesClientToolMappingContextKey, mapping)
- }
- // clearOpenAIResponsesClientToolMapping removes mapping state from the prior
- // forwarding attempt. Forward retries accounts on the same Gin context.
- func clearOpenAIResponsesClientToolMapping(c *gin.Context) {
- if c == nil {
- return
- }
- if _, exists := c.Get(openAIResponsesClientToolMappingContextKey); exists {
- c.Set(openAIResponsesClientToolMappingContextKey, apicompat.ResponsesClientToolMapping{})
- }
- }
- func restoreOpenAIResponsesClientToolPayload(c *gin.Context, payload []byte) ([]byte, error) {
- mapping, ok := openAIResponsesClientToolMapping(c)
- if !ok || !bytes.Contains(payload, []byte(`"function_call"`)) || !json.Valid(payload) {
- return payload, nil
- }
- restored, _, err := apicompat.RestoreResponsesClientToolPayload(payload, mapping)
- return restored, err
- }
- func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
- ctx context.Context,
- c *gin.Context,
- account *Account,
- body []byte,
- canonicalImageIntentBody []byte,
- reqModel string,
- attemptImageIntentInvalidated bool,
- reasoningEffort *string,
- reqStream bool,
- startTime time.Time,
- ) (*OpenAIForwardResult, error) {
- requestedModel := reqModel
- upstreamPassthroughModel := ""
- if isOpenAIResponsesCompactPath(c) {
- compactMappedModel := s.resolveOpenAICompactFallbackModel(account, reqModel)
- if compactMappedModel != "" && compactMappedModel != reqModel {
- nextBody, setErr := sjson.SetBytes(body, "model", compactMappedModel)
- if setErr != nil {
- return nil, fmt.Errorf("set compact passthrough model: %w", setErr)
- }
- body = nextBody
- upstreamPassthroughModel = compactMappedModel
- attemptImageIntentInvalidated = true
- }
- }
- if account != nil && account.UsesOpenAICodexProtocol() {
- if rejectReason := detectOpenAIPassthroughInstructionsRejectReason(reqModel, body); rejectReason != "" {
- rejectMsg := "OpenAI codex passthrough requires a non-empty instructions field"
- MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
- logOpenAIPassthroughInstructionsRejected(ctx, c, account, reqModel, rejectReason, body)
- c.JSON(http.StatusForbidden, gin.H{
- "error": gin.H{
- "type": "forbidden_error",
- "message": rejectMsg,
- },
- })
- return nil, fmt.Errorf("openai passthrough rejected before upstream: %s", rejectReason)
- }
- if isOpenAICodexModel(reqModel) && !gjson.GetBytes(body, "instructions").Exists() {
- nextBody, setErr := sjson.SetBytes(body, "instructions", defaultCodexSynthInstructions(reqModel))
- if setErr != nil {
- return nil, fmt.Errorf("set passthrough codex instructions: %w", setErr)
- }
- body = nextBody
- }
- normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBody(body, isOpenAIResponsesCompactPath(c))
- if err != nil {
- return nil, err
- }
- if normalized {
- body = normalizedBody
- }
- reqStream = gjson.GetBytes(body, "stream").Bool()
- accountScopedBody, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(body, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
- if scopeErr != nil {
- return nil, scopeErr
- }
- if accountScoped {
- body = accountScopedBody
- }
- stageCodexFingerprintIDs(c, nil)
- // 指纹收敛:与非透传路径同门控(仅 OAuth、legacy compact 形态跳过)。
- // 一次性解析收敛 ID:请求体 client_metadata 在此改写(raw 字节外科
- // 手术,透传热路径禁全量 Unmarshal),出站头改写由请求构造器读取
- // context 中的同一份 IDs 完成(turn_id 等随机字段两侧必须一致)。
- if !isOpenAIResponsesCompactPath(c) {
- var clientHeaders http.Header
- if c != nil && c.Request != nil {
- clientHeaders = c.Request.Header
- }
- fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders)
- if fpIDs != nil {
- fpBody, fpChanged, fpErr := applyCodexFingerprintClientMetadataRaw(body, fpIDs)
- if fpErr != nil {
- return nil, fpErr
- }
- if fpChanged {
- body = fpBody
- }
- }
- stageCodexFingerprintIDs(c, fpIDs)
- }
- }
- if account != nil && account.IsOpenAI() {
- responsesLite := isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) || isOpenAIResponsesLiteWebSocketPayload(body)
- normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, responsesLite)
- if normalizeErr != nil {
- return nil, fmt.Errorf("normalize passthrough Responses compatibility: %w", normalizeErr)
- }
- if normalized {
- body = normalizedBody
- }
- if account.IsOpenAIOAuthLike() {
- aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(body)
- if aliasErr != nil {
- return nil, aliasErr
- }
- mergeCodexToolNameReverse(c, reverse)
- if aliased {
- body = aliasedBody
- }
- }
- }
- if account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey &&
- !isOpenAIResponsesCompactPath(c) && needsOpenAIResponsesClientToolAdaptation(body) {
- adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body)
- if adaptErr != nil {
- return nil, adaptErr
- }
- body = adaptedBody
- setOpenAIResponsesClientToolMapping(c, mapping)
- }
- sanitizedBody, sanitized, err := sanitizeEmptyBase64InputImagesInOpenAIBody(body)
- if err != nil {
- return nil, err
- }
- if sanitized {
- body = sanitizedBody
- }
- // Apply OpenAI fast policy to the passthrough body (filter/block by service_tier).
- // 统一使用 upstream 视角的 model:透传路径下 body 已经过 compact 映射 +
- // OAuth normalize,body 中的 model 字段即上游真正会看到的 slug。
- // 这样可以与 chat-completions / messages / native /responses 入口的
- // upstreamModel 保持一致,避免 whitelist 命中差异。当 body 中没有
- // model 字段时退回 reqModel。
- policyModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
- if policyModel == "" {
- policyModel = reqModel
- }
- updatedBody, policyErr := s.applyOpenAIFastPolicyToBody(ctx, account, policyModel, body)
- if policyErr != nil {
- var blocked *OpenAIFastBlockedError
- if errors.As(policyErr, &blocked) {
- writeOpenAIFastPolicyBlockedResponse(c, blocked)
- }
- return nil, policyErr
- }
- body = updatedBody
- apiKey := getAPIKeyFromContext(c)
- // 同一 attempt 的最终 model/body 只判定一次,权限检查与后续图片状态设置共用该结果。
- imageIntent := resolveOpenAIPassthroughImageIntent(
- c,
- reqModel,
- canonicalImageIntentBody,
- policyModel,
- body,
- attemptImageIntentInvalidated,
- IsImageGenerationIntent,
- )
- if imageIntent && !GroupAllowsImageGeneration(apiKeyGroup(apiKey)) {
- MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
- c.JSON(http.StatusForbidden, gin.H{
- "error": gin.H{
- "type": "permission_error",
- "message": ImageGenerationPermissionMessage(),
- },
- })
- return nil, errors.New("image generation disabled for group")
- }
- imageBillingModel := ""
- imageSizeTier := ""
- imageInputSize := ""
- if imageIntent {
- var imageCfgErr error
- imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, reqModel)
- if imageCfgErr != nil {
- setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "")
- c.JSON(http.StatusBadRequest, gin.H{
- "error": gin.H{
- "type": "invalid_request_error",
- "message": imageCfgErr.Error(),
- "param": "size",
- },
- })
- return nil, imageCfgErr
- }
- imageBillingModel = imageCfg.Model
- imageSizeTier = imageCfg.SizeTier
- imageInputSize = imageCfg.InputSize
- }
- logger.LegacyPrintf("service.openai_gateway",
- "[OpenAI 自动透传] 命中自动透传分支: account=%d name=%s type=%s model=%s stream=%v",
- account.ID,
- account.Name,
- account.Type,
- reqModel,
- reqStream,
- )
- if reqStream && c != nil && c.Request != nil {
- if timeoutHeaders := collectOpenAIPassthroughTimeoutHeaders(c.Request.Header); len(timeoutHeaders) > 0 {
- streamWarnLogger := logger.FromContext(ctx).With(
- zap.String("component", "service.openai_gateway"),
- zap.Int64("account_id", account.ID),
- zap.Strings("timeout_headers", timeoutHeaders),
- )
- if s.isOpenAIPassthroughTimeoutHeadersAllowed() {
- streamWarnLogger.Warn("OpenAI passthrough 透传请求包含超时相关请求头,且当前配置为放行,可能导致上游提前断流")
- } else {
- streamWarnLogger.Warn("OpenAI passthrough 检测到超时相关请求头,将按配置过滤以降低断流风险")
- }
- }
- }
- // Get access token
- token, _, err := s.GetAccessToken(ctx, account)
- if err != nil {
- return nil, err
- }
- proxyURL := ""
- if account.ProxyID != nil && account.Proxy != nil {
- proxyURL = account.Proxy.URL()
- }
- if c != nil {
- c.Set("openai_passthrough", true)
- }
- agentTaskRecoveryTried := false
- compactModelFallbackRetried := false
- rejectedFieldRetryState := openAIResponsesRejectedFieldRetryStateForRequest(c, body)
- var resp *http.Response
- var usage *OpenAIUsage
- var firstTokenMs *int
- responseID := ""
- imageCount := 0
- var imageOutputSizes []string
- for {
- actualModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
- if actualModel == "" {
- actualModel = reqModel
- }
- SetOpsUpstreamModel(c, actualModel)
- upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
- upstreamReq, buildErr := s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token)
- releaseUpstreamCtx()
- if buildErr != nil {
- return nil, buildErr
- }
- upstreamStart := time.Now()
- resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account)
- SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
- if err != nil {
- // Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to
- // a failover so the handler switches to a healthy account.
- return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
- }
- if resp.StatusCode >= 400 {
- // Peek only to identify an invalid task. Restore the body so the existing
- // passthrough error handling sees the same response after recovery fails.
- probeBody := s.readUpstreamErrorBody(resp)
- _ = resp.Body.Close()
- resp.Body = io.NopCloser(bytes.NewReader(probeBody))
- if retryBody, reason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, probeBody); retryErr != nil {
- return nil, fmt.Errorf("normalize passthrough rejected Responses field retry body: %w", retryErr)
- } else if changed && rejectedFieldRetryState.Allow(retryBody) {
- body = retryBody
- logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying passthrough request after %s (account: %s)", reason, account.Name)
- continue
- }
- if !agentTaskRecoveryTried && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, probeBody) {
- agentTaskRecoveryTried = true
- expectedTaskID := account.GetCredential("task_id")
- if recoveryErr := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); recoveryErr != nil {
- return nil, fmt.Errorf("agent identity task recovery failed: %w", recoveryErr)
- }
- continue
- }
- upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(probeBody)))
- if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
- c, account, requestedModel, body, resp.StatusCode, upstreamMsg, probeBody, compactModelFallbackRetried,
- ); retry {
- s.appendOpenAICompactFallbackRetryOps(c, account, resp, probeBody, upstreamMsg, true)
- fromModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
- body = retryBody
- upstreamPassthroughModel = fallbackModel
- compactModelFallbackRetried = true
- SetOpsUpstreamModel(c, fallbackModel)
- logger.LegacyPrintf(
- "service.openai_gateway",
- "[OpenAI passthrough] Retrying explicit compact request once with fallback model (account: %s, from: %s, to: %s, upstream_code: %s)",
- account.Name, fromModel, fallbackModel, extractUpstreamErrorCode(probeBody),
- )
- continue
- }
- // 透传模式默认保持原样代理;容量错误以及 API-key 上游的瞬时
- // 5xx 应先触发多账号 failover,且此时尚未写入下游响应。
- // probeBody 已在上方任务探测时读取过一次,直接复用避免重复读取。
- if shouldFailoverOpenAIPassthroughResponse(account, resp.StatusCode, probeBody) {
- return nil, s.handleFailoverErrorResponsePassthrough(ctx, resp, c, account, body, probeBody)
- }
- return nil, s.handleErrorResponsePassthrough(ctx, resp, c, account, body, probeBody)
- }
- if mapping, ok := openAIResponsesClientToolMapping(c); ok && isEventStreamResponse(resp.Header) {
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
- }
- resp.Body = newGrokResponsesClientToolStreamBody(resp.Body, mapping, maxLineSize)
- }
- // x-codex-turn-state 溯源:下游回传由 writeOpenAIPassthroughResponseHeaders
- // 在各 handler 的写头点强制放行,铸造账号在此统一记录,供出站守卫剥离
- // failover 换号后的跨账号回带(openai_codex_turn_state.go)。
- if extractOpenAICodexTurnState(resp.Header) != "" {
- s.noteOpenAICodexTurnStateProvenance(c, account)
- }
- if reqStream {
- result, handleErr := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel)
- if handleErr != nil {
- if retryBody, fallbackModel, retry := s.applyOpenAIPassthroughCompactFallbackFromSignal(
- c, account, requestedModel, body, handleErr, compactModelFallbackRetried, resp,
- ); retry {
- body = retryBody
- upstreamPassthroughModel = fallbackModel
- compactModelFallbackRetried = true
- continue
- }
- if signal, ok := asOpenAICompactFallbackSignal(handleErr); ok {
- _ = resp.Body.Close()
- compactResp, compactBody := openAICompactFallbackErrorResponse(resp, signal)
- if shouldFailoverOpenAIPassthroughResponse(account, compactResp.StatusCode, compactBody) {
- return nil, s.handleFailoverErrorResponsePassthrough(ctx, compactResp, c, account, body, compactBody)
- }
- return nil, s.handleErrorResponsePassthrough(ctx, compactResp, c, account, body, compactBody)
- }
- _ = resp.Body.Close()
- return nil, handleErr
- }
- usage = result.usage
- firstTokenMs = result.firstTokenMs
- responseID = strings.TrimSpace(result.responseID)
- imageCount = result.imageCount
- imageOutputSizes = result.imageOutputSizes
- } else {
- result, handleErr := s.handleNonStreamingResponsePassthrough(ctx, resp, c, account, reqModel, upstreamPassthroughModel)
- if handleErr != nil {
- if retryBody, fallbackModel, retry := s.applyOpenAIPassthroughCompactFallbackFromSignal(
- c, account, requestedModel, body, handleErr, compactModelFallbackRetried, resp,
- ); retry {
- body = retryBody
- upstreamPassthroughModel = fallbackModel
- compactModelFallbackRetried = true
- continue
- }
- if signal, ok := asOpenAICompactFallbackSignal(handleErr); ok {
- _ = resp.Body.Close()
- compactResp, compactBody := openAICompactFallbackErrorResponse(resp, signal)
- if shouldFailoverOpenAIPassthroughResponse(account, compactResp.StatusCode, compactBody) {
- return nil, s.handleFailoverErrorResponsePassthrough(ctx, compactResp, c, account, body, compactBody)
- }
- return nil, s.handleErrorResponsePassthrough(ctx, compactResp, c, account, body, compactBody)
- }
- _ = resp.Body.Close()
- return nil, handleErr
- }
- usage = result.usage
- responseID = strings.TrimSpace(result.responseID)
- imageCount = result.imageCount
- imageOutputSizes = result.imageOutputSizes
- }
- break
- }
- defer func() { _ = resp.Body.Close() }()
- serviceTier := extractOpenAIServiceTierFromBody(body)
- s.bindHTTPResponseAccount(ctx, c, account, responseID)
- // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
- if !account.IsShadow() {
- if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil {
- s.updateCodexUsageSnapshot(ctx, account.ID, snapshot)
- }
- } else if account.ParentAccountID != nil {
- notifyOpenAIAutoReset(*account.ParentAccountID)
- }
- if usage == nil {
- usage = &OpenAIUsage{}
- }
- forwardResult := &OpenAIForwardResult{
- RequestID: resp.Header.Get("x-request-id"),
- UpstreamHeaders: resp.Header,
- ResponseID: responseID,
- Usage: *usage,
- Model: reqModel,
- UpstreamModel: upstreamPassthroughModel,
- UpstreamResponseModel: observedUpstreamResponseModel(c),
- UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
- UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
- ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier),
- ReasoningEffort: reasoningEffort,
- Stream: reqStream,
- OpenAIWSMode: false,
- Duration: time.Since(startTime),
- FirstTokenMs: firstTokenMs,
- }
- if imageCount > 0 {
- forwardResult.ImageCount = imageCount
- forwardResult.ImageSize = imageSizeTier
- forwardResult.ImageInputSize = imageInputSize
- forwardResult.ImageOutputSizes = imageOutputSizes
- forwardResult.BillingModel = imageBillingModel
- }
- return forwardResult, nil
- }
- func logOpenAIPassthroughInstructionsRejected(
- ctx context.Context,
- c *gin.Context,
- account *Account,
- reqModel string,
- rejectReason string,
- body []byte,
- ) {
- if ctx == nil {
- ctx = context.Background()
- }
- accountID := int64(0)
- accountName := ""
- accountType := ""
- if account != nil {
- accountID = account.ID
- accountName = strings.TrimSpace(account.Name)
- accountType = strings.TrimSpace(string(account.Type))
- }
- fields := []zap.Field{
- zap.String("component", "service.openai_gateway"),
- zap.Int64("account_id", accountID),
- zap.String("account_name", accountName),
- zap.String("account_type", accountType),
- zap.String("request_model", strings.TrimSpace(reqModel)),
- zap.String("reject_reason", strings.TrimSpace(rejectReason)),
- }
- fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, body)
- logger.FromContext(ctx).With(fields...).Warn("OpenAI passthrough 本地拦截:Codex 请求缺少有效 instructions")
- }
- func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
- ctx context.Context,
- c *gin.Context,
- account *Account,
- body []byte,
- token string,
- ) (*http.Request, error) {
- if err := validateMode1StagedRequest(c, account, body); err != nil {
- return nil, err
- }
- targetURL := openaiPlatformAPIURL
- switch account.Type {
- case AccountTypeOAuth:
- targetURL = chatgptCodexURL
- case AccountTypeSetupToken:
- if account.IsOpenAIOAuthLike() {
- targetURL = chatgptCodexURL
- }
- case AccountTypeAPIKey:
- baseURL := account.GetOpenAIBaseURL()
- if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() {
- baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses)
- }
- if baseURL != "" {
- validatedURL, err := s.validateUpstreamBaseURL(baseURL)
- if err != nil {
- return nil, err
- }
- targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL)
- }
- }
- targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c))
- // DeepSeek / Kimi 原生 Responses 端点为无状态实现(见 normalizeDeepSeekResponsesRequestBody)。
- body = normalizeDeepSeekResponsesRequestBody(account, body)
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
- if err != nil {
- return nil, err
- }
- req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
- // 透传客户端请求头(安全白名单)。
- allowTimeoutHeaders := s.isOpenAIPassthroughTimeoutHeadersAllowed()
- if c != nil && c.Request != nil {
- for key, values := range c.Request.Header {
- lower := strings.ToLower(strings.TrimSpace(key))
- if !isOpenAIPassthroughAllowedRequestHeader(lower, allowTimeoutHeaders) {
- continue
- }
- for _, v := range values {
- req.Header.Add(key, v)
- }
- }
- }
- // 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号),
- // 剥离后再出站(openai_codex_turn_state.go)。
- s.guardOpenAICodexTurnStateEcho(c, account, req.Header)
- // 覆盖入站鉴权残留,并注入上游认证
- req.Header.Del("authorization")
- req.Header.Del("x-api-key")
- req.Header.Del("x-goog-api-key")
- authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
- if err != nil {
- return nil, fmt.Errorf("build openai authentication headers: %w", err)
- }
- for key, values := range authHeaders {
- for _, value := range values {
- req.Header.Add(key, value)
- }
- }
- // OAuth 透传到 ChatGPT internal API 时补齐必要头。
- if account.UsesOpenAICodexProtocol() {
- // Current Codex OAuth HTTP no longer negotiates the legacy Responses
- // experiment. Passthrough may receive it from an older client, so remove
- // only that token while preserving any independent beta negotiation.
- stripOpenAILegacyResponsesBeta(req.Header)
- promptCacheKey := strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String())
- req.Host = "chatgpt.com"
- if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
- return nil, fmt.Errorf("resolve chatgpt account headers: %w", err)
- }
- apiKeyID := getAPIKeyIDFromContext(c)
- // 先保存客户端原始值,再做 compact 补充,避免后续统一隔离时读到已处理的值。
- clientSessionID := strings.TrimSpace(req.Header.Get("session_id"))
- clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id"))
- if isOpenAIResponsesCompactPath(c) {
- req.Header.Set("accept", "application/json")
- if req.Header.Get("version") == "" {
- req.Header.Set("version", CodexCanonicalClientVersion())
- }
- if clientSessionID == "" {
- clientSessionID = resolveOpenAICompactSessionID(c)
- }
- } else if req.Header.Get("accept") == "" {
- req.Header.Set("accept", "text/event-stream")
- }
- if req.Header.Get("originator") == "" {
- req.Header.Set("originator", resolveCodexOutboundIdentity("").originator)
- }
- // 用隔离后的 session 标识符覆盖客户端透传值,防止跨用户会话碰撞。
- if clientSessionID == "" {
- clientSessionID = promptCacheKey
- }
- if clientConversationID == "" {
- clientConversationID = promptCacheKey
- }
- if clientSessionID != "" {
- req.Header.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientSessionID))
- }
- if clientConversationID != "" {
- req.Header.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientConversationID))
- }
- } else if isOpenAIResponsesCompactPath(c) {
- // 透传白名单会放行客户端的 Accept: text/event-stream;compact 上游是
- // unary JSON 协议,API-key 账号同样强制 Accept,避免上游按 SSE 返回
- // (#3777 期望行为 4)。
- req.Header.Set("accept", "application/json")
- }
- // 透传模式也支持账户自定义 User-Agent 与 ForceCodexCLI 兜底。
- customUA := account.GetOpenAIUserAgent()
- if customUA != "" {
- req.Header.Set("user-agent", customUA)
- }
- if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
- req.Header.Set("user-agent", CodexCanonicalUserAgent())
- }
- applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
- // 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头,
- // 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置:
- // 会话隔离之后、终态身份收口之前)。
- applyStagedCodexFingerprintHeaders(c, account, req.Header)
- // 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份
- // (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。
- if account.UsesOpenAICodexProtocol() {
- enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
- }
- if req.Header.Get("content-type") == "" {
- req.Header.Set("content-type", "application/json")
- }
- // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
- account.ApplyHeaderOverrides(req.Header)
- applyOpenCodeSessionHeader(c, account, targetURL, req.Header)
- // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后,
- // 保证不被覆盖丢失)。
- applyOpenAICodexBetaFeatures(c, account, req.Header)
- setOpenAICodexRoutingHintFromBody(req.Header, account, body)
- logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http_passthrough", req.Header, body, "not_applicable")
- return req, nil
- }
- func stripOpenAILegacyResponsesBeta(headers http.Header) {
- if headers == nil {
- return
- }
- preserved := make([]string, 0)
- for key, values := range headers {
- if !strings.EqualFold(strings.TrimSpace(key), "OpenAI-Beta") {
- continue
- }
- delete(headers, key)
- for _, value := range values {
- parts := strings.Split(value, ",")
- kept := parts[:0]
- for _, part := range parts {
- part = strings.TrimSpace(part)
- if part == "" || strings.EqualFold(part, "responses=experimental") {
- continue
- }
- kept = append(kept, part)
- }
- if len(kept) > 0 {
- preserved = append(preserved, strings.Join(kept, ", "))
- }
- }
- }
- for _, value := range preserved {
- headers.Add("OpenAI-Beta", value)
- }
- }
- func shouldFailoverOpenAIPassthroughResponse(account *Account, statusCode int, responseBody []byte) bool {
- if hit, _, _ := detectOpenAICyberPolicy(responseBody); hit {
- return false
- }
- if isOpenAIContextWindowError("", responseBody) {
- return false
- }
- if isOpenAIHTTPUpstreamAccessStateError(statusCode, "", responseBody) {
- return true
- }
- if isOpenAIRequestBodyTooLargeError(statusCode, "", responseBody) {
- return true
- }
- if account != nil && account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode) {
- return true
- }
- switch statusCode {
- case http.StatusTooManyRequests, 529:
- return true
- }
- if account == nil || account.Type != AccountTypeAPIKey {
- return false
- }
- switch statusCode {
- case http.StatusInternalServerError,
- http.StatusBadGateway,
- http.StatusServiceUnavailable,
- http.StatusGatewayTimeout,
- 520, 521, 522, 523, 524:
- return true
- default:
- return false
- }
- }
- func writeOpenAIPassthroughErrorHeaders(dst, src http.Header) {
- if dst == nil {
- return
- }
- dst.Set("Content-Type", "application/json; charset=utf-8")
- dst.Set("Cache-Control", "no-store")
- dst.Del("Retry-After")
- if src == nil {
- return
- }
- rawRetryAfter := strings.TrimSpace(src.Get("Retry-After"))
- if validOpenAIPassthroughRetryAfter(rawRetryAfter, time.Now()) {
- dst.Set("Retry-After", rawRetryAfter)
- }
- }
- func validOpenAIPassthroughRetryAfter(raw string, now time.Time) bool {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return false
- }
- delaySeconds := true
- for i := 0; i < len(raw); i++ {
- if raw[i] < '0' || raw[i] > '9' {
- delaySeconds = false
- break
- }
- }
- if delaySeconds {
- seconds, err := strconv.ParseUint(raw, 10, 64)
- return err == nil && seconds > 0
- }
- parsed, err := http.ParseTime(raw)
- return err == nil && parsed.After(now)
- }
- func writeSanitizedOpenAIPassthroughError(c *gin.Context, upstreamStatus int, upstreamHeaders http.Header) {
- downstreamStatus := upstreamStatus
- message := "Upstream request failed"
- switch upstreamStatus {
- case http.StatusUnauthorized:
- downstreamStatus = http.StatusBadGateway
- message = "Upstream authentication failed"
- case http.StatusForbidden:
- downstreamStatus = http.StatusBadGateway
- message = "Upstream access denied"
- default:
- if upstreamStatus >= http.StatusInternalServerError {
- message = "Upstream service temporarily unavailable"
- }
- }
- writeOpenAIPassthroughErrorEnvelope(c, downstreamStatus, upstreamHeaders, message)
- }
- // writeOpenAIPassthroughErrorEnvelope 以本地 JSON 信封 + 净化后的头策略写出
- // 错误响应;message 由调用方决定(净化通用文案或脱敏后的上游消息)。
- func writeOpenAIPassthroughErrorEnvelope(c *gin.Context, downstreamStatus int, upstreamHeaders http.Header, message string) {
- if c == nil {
- return
- }
- body, _ := json.Marshal(gin.H{
- "error": gin.H{
- "type": "upstream_error",
- "message": message,
- },
- })
- if writeOpenAICompactSSEBridge(c, downstreamStatus, body) {
- return
- }
- writeOpenAIPassthroughErrorHeaders(c.Writer.Header(), upstreamHeaders)
- c.Data(downstreamStatus, "application/json; charset=utf-8", body)
- }
- func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough(
- ctx context.Context,
- resp *http.Response,
- c *gin.Context,
- account *Account,
- requestBody []byte,
- responseBody []byte,
- ) error {
- body := s.redactAgentIdentitySensitiveBody(ctx, account, responseBody)
- upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
- upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
- upstreamDetail := ""
- if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
- maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
- if maxBytes <= 0 {
- maxBytes = 2048
- }
- upstreamDetail = truncateString(string(body), maxBytes)
- }
- setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
- logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
- reqModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody)
- canonicalModel := canonicalOpenAIAccountSchedulingModel(account, reqModel)
- shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, canonicalModel)
- appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
- ProxyID: opsUpstreamProxyID(account),
- ProxyName: opsUpstreamProxyName(account),
- Platform: account.Platform,
- AccountID: account.ID,
- AccountName: account.Name,
- UpstreamStatusCode: resp.StatusCode,
- UpstreamRequestID: resp.Header.Get("x-request-id"),
- Passthrough: true,
- Kind: "failover",
- Message: upstreamMsg,
- Detail: upstreamDetail,
- UpstreamResponseBody: upstreamDetail,
- })
- return s.newOpenAIAccountFailoverError(
- account,
- resp.StatusCode,
- resp.Header,
- body,
- upstreamMsg,
- shouldDisable,
- !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
- )
- }
- func (s *OpenAIGatewayService) handleErrorResponsePassthrough(
- ctx context.Context,
- resp *http.Response,
- c *gin.Context,
- account *Account,
- requestBody []byte,
- responseBody []byte,
- ) error {
- MarkResponseCommitted(c)
- body := s.redactAgentIdentitySensitiveBody(ctx, account, responseBody)
- // cyber_policy 仍按原始 body 打内部标记,供 handler 事后写风控/邮件;面向客户端的
- // 错误体在下方统一重建。cyber 是上游网络安全策略拦截,不冷却账号,
- // 故下方跳过 handleOpenAIAccountUpstreamError(避免自定义 temp-unschedulable 规则误冷却)。
- cyberHit, cyberCode, cyberMsg := detectOpenAICyberPolicy(body)
- if cyberHit {
- MarkOpsCyberPolicy(c, CyberPolicyMark{
- Code: cyberCode,
- Message: cyberMsg,
- Body: truncateString(string(body), 4096),
- UpstreamStatus: resp.StatusCode,
- })
- }
- upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
- upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
- upstreamDetail := ""
- if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
- maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
- if maxBytes <= 0 {
- maxBytes = 2048
- }
- upstreamDetail = truncateString(string(body), maxBytes)
- }
- setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
- logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
- // 错误体虽不会原样透传,运行态账号状态仍需更新,避免粘性路由继续复用
- // 刚被限流的账号。cyber 例外:不冷却账号。
- if !cyberHit {
- reqModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody)
- canonicalModel := canonicalOpenAIAccountSchedulingModel(account, reqModel)
- _ = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, canonicalModel)
- }
- appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
- ProxyID: opsUpstreamProxyID(account),
- ProxyName: opsUpstreamProxyName(account),
- Platform: account.Platform,
- AccountID: account.ID,
- AccountName: account.Name,
- UpstreamStatusCode: resp.StatusCode,
- UpstreamRequestID: resp.Header.Get("x-request-id"),
- Passthrough: true,
- Kind: "http_error",
- Message: upstreamMsg,
- Detail: upstreamDetail,
- UpstreamResponseBody: upstreamDetail,
- })
- // context-window 超限是确定性请求失败(shouldFailoverOpenAIPassthroughResponse
- // 已保证不切号),其文案对客户端可操作(如触发自动压缩);在净化信封内保留
- // 脱敏后的上游消息,而不是抹成通用文案。
- if isOpenAIContextWindowError(upstreamMsg, body) && upstreamMsg != "" {
- writeOpenAIPassthroughErrorEnvelope(c, resp.StatusCode, resp.Header, upstreamMsg)
- } else {
- writeSanitizedOpenAIPassthroughError(c, resp.StatusCode, resp.Header)
- }
- return fmt.Errorf("upstream error: %d (client response sanitized)", resp.StatusCode)
- }
- func isOpenAIPassthroughAllowedRequestHeader(lowerKey string, allowTimeoutHeaders bool) bool {
- if lowerKey == "" {
- return false
- }
- if isOpenAIPassthroughTimeoutHeader(lowerKey) {
- return allowTimeoutHeaders
- }
- return openaiPassthroughAllowedHeaders[lowerKey]
- }
- func isOpenAIPassthroughTimeoutHeader(lowerKey string) bool {
- switch lowerKey {
- case "x-stainless-timeout", "x-stainless-read-timeout", "x-stainless-connect-timeout", "x-request-timeout", "request-timeout", "grpc-timeout":
- return true
- default:
- return false
- }
- }
- func (s *OpenAIGatewayService) isOpenAIPassthroughTimeoutHeadersAllowed() bool {
- return s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIPassthroughAllowTimeoutHeaders
- }
- func collectOpenAIPassthroughTimeoutHeaders(h http.Header) []string {
- if h == nil {
- return nil
- }
- var matched []string
- for key, values := range h {
- lowerKey := strings.ToLower(strings.TrimSpace(key))
- if isOpenAIPassthroughTimeoutHeader(lowerKey) {
- entry := lowerKey
- if len(values) > 0 {
- entry = fmt.Sprintf("%s=%s", lowerKey, strings.Join(values, "|"))
- }
- matched = append(matched, entry)
- }
- }
- sort.Strings(matched)
- return matched
- }
- type openaiStreamingResultPassthrough struct {
- usage *OpenAIUsage
- firstTokenMs *int
- responseID string
- imageCount int
- imageOutputSizes []string
- }
- type openaiNonStreamingResultPassthrough struct {
- *OpenAIUsage
- usage *OpenAIUsage
- responseID string
- imageCount int
- imageOutputSizes []string
- }
- const openAIStreamKeepaliveBytesKey = "openai_stream_keepalive_bytes"
- func recordOpenAIStreamKeepaliveBytes(c *gin.Context, written int) {
- if c == nil || written <= 0 {
- return
- }
- current := 0
- if value, ok := c.Get(openAIStreamKeepaliveBytesKey); ok {
- current, _ = value.(int)
- }
- c.Set(openAIStreamKeepaliveBytesKey, current+written)
- }
- func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool {
- if localStarted {
- return true
- }
- if c == nil || c.Writer == nil {
- return false
- }
- // compact keepalive comments commit the HTTP response as 200, but they are
- // not semantic model output and therefore must not block a safe retry.
- // Without a compact keepalive this is equivalent to checking Writer.Size().
- return OpenAICompactKeepaliveAdjustedWrittenSize(c) >= 0
- }
- func openAIStreamEventIsPreamble(eventType string) bool {
- switch strings.TrimSpace(eventType) {
- case "response.created", "response.in_progress":
- return true
- default:
- return false
- }
- }
- func openAIStreamAddedEventStartsClientOutput(payload []byte, eventType string) bool {
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
- return true
- }
- switch strings.TrimSpace(eventType) {
- case "response.output_item.added":
- item := gjson.GetBytes(payload, "item")
- if !item.Exists() || !item.IsObject() {
- return true
- }
- switch strings.TrimSpace(item.Get("type").String()) {
- case "reasoning":
- if item.Get("encrypted_content").String() != "" {
- return true
- }
- summary := item.Get("summary")
- if !summary.IsArray() {
- return false
- }
- for _, part := range summary.Array() {
- if strings.TrimSpace(part.Get("type").String()) != "summary_text" || part.Get("text").String() != "" {
- return true
- }
- }
- return false
- case "message":
- content := item.Get("content")
- if !content.IsArray() {
- return false
- }
- for _, part := range content.Array() {
- switch strings.TrimSpace(part.Get("type").String()) {
- case "output_text":
- if part.Get("text").String() != "" {
- return true
- }
- case "refusal":
- if part.Get("refusal").String() != "" {
- return true
- }
- default:
- return true
- }
- }
- return false
- case "function_call":
- return item.Get("arguments").String() != ""
- case "custom_tool_call":
- return item.Get("input").String() != ""
- case "compaction":
- return item.Get("encrypted_content").String() != ""
- default:
- return true
- }
- case "response.content_part.added":
- part := gjson.GetBytes(payload, "part")
- if !part.Exists() || !part.IsObject() {
- return true
- }
- switch strings.TrimSpace(part.Get("type").String()) {
- case "output_text":
- return part.Get("text").String() != ""
- case "refusal":
- return part.Get("refusal").String() != ""
- default:
- return true
- }
- case "response.reasoning_summary_part.added":
- part := gjson.GetBytes(payload, "part")
- if !part.Exists() || !part.IsObject() || strings.TrimSpace(part.Get("type").String()) != "summary_text" {
- return true
- }
- return part.Get("text").String() != ""
- default:
- return true
- }
- }
- func openAIStreamDataStartsClientOutput(data, eventType string) bool {
- trimmed := strings.TrimSpace(data)
- if trimmed == "" {
- return false
- }
- switch strings.TrimSpace(eventType) {
- case "response.failed":
- return false
- case "error":
- // 上游降载/瞬时故障会先推 {"type":"error"} 帧、再以 response.failed 收尾。
- // 可重试类错误帧不能算客户端输出:一旦把它当首输出 flush,
- // clientOutputStarted 即被固化,随后的 failed 事件永远进不了 pre-output
- // failover 分支,只能把致命错误原样转发给客户端。不可重试类
- // (content_policy / invalid_request 等)维持原样转发,保留上游错误细节。
- payload := []byte(trimmed)
- return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload))
- case "response.output_item.added", "response.content_part.added", "response.reasoning_summary_part.added":
- return openAIStreamAddedEventStartsClientOutput([]byte(trimmed), eventType)
- }
- return !openAIStreamEventIsPreamble(eventType)
- }
- func openAIStreamItemHasVisibleOutput(item gjson.Result) bool {
- if item.Get("arguments").String() != "" || item.Get("input").String() != "" || item.Get("result").String() != "" {
- return true
- }
- for _, path := range []string{"content", "summary"} {
- for _, part := range item.Get(path).Array() {
- if part.Get("text").String() != "" || part.Get("transcript").String() != "" {
- return true
- }
- }
- }
- return false
- }
- // Structural progress can commit an attempt and disarm first-output failover,
- // but TTFT should start only when the stream carries content a client can use.
- func openAIStreamDataStartsVisibleOutput(data, eventType string) bool {
- trimmed := strings.TrimSpace(data)
- if trimmed == "" || trimmed == "[DONE]" || !gjson.Valid(trimmed) {
- return false
- }
- eventType = strings.TrimSpace(eventType)
- if eventType == "" {
- eventType = strings.TrimSpace(gjson.Get(trimmed, "type").String())
- }
- if strings.HasSuffix(eventType, ".delta") {
- delta := gjson.Get(trimmed, "delta")
- return delta.Exists() && delta.String() != ""
- }
- switch eventType {
- case "response.output_text.done",
- "response.reasoning_summary_text.done",
- "response.reasoning_text.done",
- "response.audio_transcript.done":
- return gjson.Get(trimmed, "text").String() != ""
- case "response.function_call_arguments.done":
- return gjson.Get(trimmed, "arguments").String() != ""
- case "response.custom_tool_call_input.done":
- return gjson.Get(trimmed, "input").String() != ""
- case "response.image_generation_call.partial_image":
- return gjson.Get(trimmed, "partial_image_b64").String() != ""
- case "response.content_part.added", "response.content_part.done",
- "response.reasoning_summary_part.added", "response.reasoning_summary_part.done":
- part := gjson.Get(trimmed, "part")
- return part.Get("text").String() != "" || part.Get("transcript").String() != ""
- case "response.output_item.added", "response.output_item.done":
- return openAIStreamItemHasVisibleOutput(gjson.Get(trimmed, "item"))
- case "response.completed", "response.done":
- for _, item := range gjson.Get(trimmed, "response.output").Array() {
- if openAIStreamItemHasVisibleOutput(item) {
- return true
- }
- }
- }
- return false
- }
- // openAIStreamDataStartsSemanticTTFT 保留 900194fab 之前的 first_token_ms
- // 口径:跳过 Responses preamble 后,首个语义 SSE 事件即视为首 token。
- func openAIStreamDataStartsSemanticTTFT(data, eventType string) bool {
- trimmed := strings.TrimSpace(data)
- if trimmed == "" || trimmed == "[DONE]" {
- return false
- }
- eventType = strings.TrimSpace(eventType)
- if eventType == "" && gjson.Valid(trimmed) {
- eventType = strings.TrimSpace(gjson.Get(trimmed, "type").String())
- }
- switch eventType {
- case "response.failed":
- return false
- case "error":
- payload := []byte(trimmed)
- return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload))
- default:
- return !openAIStreamEventIsPreamble(eventType)
- }
- }
- func (s *OpenAIGatewayService) openAITTFTMode(ctx context.Context) string {
- mode := OpenAITTFTModeSemantic
- if s != nil && s.settingService != nil {
- mode = s.settingService.GetOpenAITTFTMode(ctx)
- } else if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
- if cached.expiresAt == 0 || time.Now().UnixNano() < cached.expiresAt {
- mode = normalizeOpenAITTFTMode(cached.openAITTFTMode)
- }
- }
- return normalizeOpenAITTFTMode(mode)
- }
- func openAIStreamDataStartsTTFT(data, eventType string, forceOutput bool, mode string) bool {
- if mode == OpenAITTFTModeVisible {
- return openAIStreamDataStartsVisibleOutput(data, eventType)
- }
- return forceOutput || openAIStreamDataStartsSemanticTTFT(data, eventType)
- }
- // openAIStreamFailedEventErrorCode 提取流内 failed 事件的错误码(小写),
- // 兼容 response.failed 的嵌套形态与裸 error 形态。
- func openAIStreamFailedEventErrorCode(payload []byte) string {
- code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
- if code == "" {
- code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
- }
- return code
- }
- // isOpenAIUpstreamCapacityShedEvent 判断流内 failed 事件是否为上游容量降载信号。
- // 上游在容量紧张时会把请求丢进降载路径:HTTP 200 之后立刻推 event: error
- // (code=server_is_overloaded / slow_down)并以 response.failed 收尾。
- func isOpenAIUpstreamCapacityShedEvent(payload []byte) bool {
- switch openAIStreamFailedEventErrorCode(payload) {
- case "server_is_overloaded", "slow_down":
- return true
- }
- for _, path := range []string{"response.error.message", "error.message", "message"} {
- if isOpenAICapacityShedMessage(gjson.GetBytes(payload, path).String()) {
- return true
- }
- }
- return false
- }
- func logOpenAICapacityFailoverSuppressed(
- ctx context.Context,
- account *Account,
- path string,
- upstreamRequestID string,
- eventType string,
- ) {
- fields := []zap.Field{
- zap.String("path", path),
- zap.String("event_type", strings.TrimSpace(eventType)),
- zap.String("upstream_request_id", strings.TrimSpace(upstreamRequestID)),
- }
- if account != nil {
- fields = append(fields,
- zap.Int64("account_id", account.ID),
- zap.String("platform", account.Platform),
- )
- }
- logger.FromContext(ctx).Warn("gateway.failover_suppressed_after_semantic_output", fields...)
- }
- // openAICapacityShedRetryableClientCode 是把上游容量降载错误转发给客户端时改写
- // 使用的错误码。Codex CLI 按闭集对错误码分类:server_is_overloaded / slow_down
- // 被判为致命错误(客户端提示 "Selected model is at capacity. Please try a
- // different model." 并直接终止会话),而 server_error 等致命集之外的错误码会进入
- // 客户端内置的退避重试。
- const openAICapacityShedRetryableClientCode = "server_error"
- // sanitizeOpenAICapacityShedErrorCodeForClient 把即将写给下游客户端的
- // error / response.failed 事件中的容量降载错误码改写为客户端可重试的错误码。
- // 走到转发这一步说明网关侧 failover 已不可用(流中途)或已用尽;保留原始降载码
- // 只会让客户端就地终止会话。错误消息原样保留;监控与账号状态判定都基于改写前
- // 的原始 payload,不受影响。rate_limit 等其他错误码一律不动(客户端依赖
- // rate_limit_exceeded 原码解析重试延时)。
- func sanitizeOpenAICapacityShedErrorCodeForClient(payload []byte) ([]byte, bool) {
- if len(payload) == 0 || !gjson.ValidBytes(payload) || !isOpenAIUpstreamCapacityShedEvent(payload) {
- return payload, false
- }
- updated := payload
- changed := false
- for _, path := range []string{"response.error.code", "error.code"} {
- parent := strings.TrimSuffix(path, ".code")
- if !gjson.GetBytes(updated, parent).Exists() {
- continue
- }
- code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(updated, path).String()))
- if code != "" && code != "server_is_overloaded" && code != "slow_down" {
- continue
- }
- next, err := sjson.SetBytes(updated, path, openAICapacityShedRetryableClientCode)
- if err != nil {
- return payload, false
- }
- updated = next
- changed = true
- }
- return updated, changed
- }
- func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int {
- if isOpenAIContextWindowError(message, payload) {
- return http.StatusBadRequest
- }
- code := openAIStreamFailedEventErrorCode(payload)
- errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.type").String()))
- if errType == "" {
- errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String()))
- }
- combined := strings.TrimSpace(errType + " " + code + " " + strings.ToLower(strings.TrimSpace(message)))
- for _, path := range []string{"response.error.status_code", "error.status_code", "status_code"} {
- if status := int(gjson.GetBytes(payload, path).Int()); status == http.StatusUnauthorized ||
- status == http.StatusForbidden || status == http.StatusTooManyRequests || status == 529 {
- return status
- }
- }
- switch {
- case strings.Contains(combined, "rate_limit"):
- return http.StatusTooManyRequests
- case strings.Contains(errType, "invalid_request"):
- return http.StatusBadRequest
- case strings.Contains(combined, "authentication") || strings.Contains(combined, "unauthorized") || strings.Contains(combined, "invalid_api_key"):
- return http.StatusUnauthorized
- case strings.Contains(combined, "permission") || strings.Contains(combined, "forbidden") || strings.Contains(combined, "access denied"):
- return http.StatusForbidden
- case isOpenAIUpstreamAccessStateError(message, payload):
- return http.StatusForbidden
- case isOpenAIUpstreamCapacityShedEvent(payload):
- return http.StatusServiceUnavailable
- default:
- return http.StatusBadGateway
- }
- }
- func openAIStreamFailureStatus(payload []byte, message string) int {
- if len(bytes.TrimSpace(payload)) == 0 || !gjson.ValidBytes(payload) {
- return http.StatusBadGateway
- }
- semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message)
- switch semanticStatus {
- case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests, 529:
- return semanticStatus
- case http.StatusServiceUnavailable:
- if isOpenAIUpstreamCapacityShedEvent(payload) {
- return semanticStatus
- }
- }
- return http.StatusBadGateway
- }
- // openAIStreamCredentialAuthFailure distinguishes credential failures from
- // request/content permission denials carried inside an HTTP 200 stream. Do not
- // infer credential health from free-form 403 messages: providers also use
- // permission_error/forbidden/access denied for request-scoped policy failures.
- func openAIStreamCredentialAuthFailure(payload []byte) bool {
- if len(bytes.TrimSpace(payload)) == 0 || !gjson.ValidBytes(payload) {
- return false
- }
- for _, path := range []string{"response.error.status_code", "error.status_code", "status_code"} {
- if int(gjson.GetBytes(payload, path).Int()) == http.StatusUnauthorized {
- return true
- }
- }
- for _, path := range []string{"response.error.type", "error.type", "type"} {
- errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, path).String()))
- if errType == "authentication_error" || errType == "authentication_failed" || errType == "unauthorized_error" {
- return true
- }
- }
- for _, path := range []string{"response.error.code", "error.code", "code"} {
- switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, path).String())) {
- case "invalid_api_key", "api_key_disabled", "unauthorized", "authentication_error",
- "invalid_token", "access_token_invalid", "token_revoked", "token_invalidated",
- "invalid_credentials", "credential_invalid":
- return true
- }
- }
- return false
- }
- func openAIStream403AccountFailure(payload []byte, message string) bool {
- return isOpenAIUpstreamAccessStateError(message, payload) || openAIStreamCredentialAuthFailure(payload)
- }
- func openAIStreamFailedEventPassthroughBody(payload []byte, failedMessage string) []byte {
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
- return payload
- }
- if gjson.GetBytes(payload, "error").Exists() {
- return payload
- }
- responseError := gjson.GetBytes(payload, "response.error")
- if !responseError.Exists() {
- if strings.TrimSpace(failedMessage) == "" {
- return payload
- }
- body, err := marshalOpenAIUpstreamJSON(gin.H{
- "error": gin.H{
- "message": failedMessage,
- },
- })
- if err != nil {
- return payload
- }
- return body
- }
- errorPayload := gin.H{}
- if errType := strings.TrimSpace(gjson.Get(responseError.Raw, "type").String()); errType != "" {
- errorPayload["type"] = errType
- }
- if code := strings.TrimSpace(gjson.Get(responseError.Raw, "code").String()); code != "" {
- errorPayload["code"] = code
- }
- if param := strings.TrimSpace(gjson.Get(responseError.Raw, "param").String()); param != "" {
- errorPayload["param"] = param
- }
- message := strings.TrimSpace(gjson.Get(responseError.Raw, "message").String())
- if message == "" {
- message = strings.TrimSpace(failedMessage)
- }
- if message != "" {
- errorPayload["message"] = message
- }
- if len(errorPayload) == 0 {
- return payload
- }
- body, err := marshalOpenAIUpstreamJSON(gin.H{"error": errorPayload})
- if err != nil {
- return payload
- }
- return body
- }
- // applyOpenAIStreamFailedErrorPassthroughRule 对 response.failed 事件应用错误透传规则:
- // 归一化 body 供关键词匹配/消息提取,并推断语义状态码使按错误码配置的规则可以命中。
- // platform 必须传 account.Platform——本服务同时承载 openai 与 grok 平台账号,规则按平台匹配。
- func applyOpenAIStreamFailedErrorPassthroughRule(
- c *gin.Context,
- platform string,
- payload []byte,
- failedMessage string,
- ) (status int, errType string, errMsg string, matched bool) {
- ruleBody := openAIStreamFailedEventPassthroughBody(payload, failedMessage)
- upstreamStatus := openAIStreamFailedEventSemanticStatus(payload, failedMessage)
- return applyErrorPassthroughRule(
- c,
- platform,
- upstreamStatus,
- ruleBody,
- http.StatusBadGateway,
- "upstream_error",
- "Upstream request failed",
- )
- }
- func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool {
- if hit, _, _ := detectOpenAICyberPolicy(payload); hit {
- return false
- }
- if isOpenAIContextWindowError(message, payload) {
- return false
- }
- if isOpenAIUpstreamAccessStateError(message, payload) {
- return true
- }
- semanticStatus := openAIStreamFailureStatus(payload, message)
- if semanticStatus == http.StatusForbidden {
- return openAIStream403AccountFailure(payload, message)
- }
- // A response.failed event is transported over HTTP 200. Prefer its semantic
- // rate-limit status over a generic/invalid_request error type so it can enter
- // the same 429 retry policy as a regular upstream HTTP response.
- if semanticStatus == http.StatusTooManyRequests {
- return true
- }
- if isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) {
- return true
- }
- code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
- if code == "" {
- code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
- }
- errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.type").String()))
- if errType == "" {
- errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String()))
- }
- combined := strings.ToLower(strings.TrimSpace(message + " " + code + " " + errType))
- if combined == "" {
- return true
- }
- nonRetryableMarkers := []string{
- "invalid_request",
- "content_policy",
- "policy",
- "safety",
- "high-risk cyber",
- "not allowed",
- "violat",
- }
- for _, marker := range nonRetryableMarkers {
- if strings.Contains(combined, marker) {
- return false
- }
- }
- return true
- }
- func openAIStreamErrorEventShouldFailover(payload []byte, message string) bool {
- if hit, _, _ := detectOpenAICyberPolicy(payload); hit {
- return false
- }
- if isOpenAIContextWindowError(message, payload) {
- return false
- }
- if isOpenAIUpstreamAccessStateError(message, payload) {
- return true
- }
- switch openAIStreamFailedEventSemanticStatus(payload, message) {
- case http.StatusForbidden:
- return openAIStream403AccountFailure(payload, message)
- case http.StatusUnauthorized, http.StatusTooManyRequests, 529:
- return true
- }
- if isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) {
- return true
- }
- combined := strings.ToLower(strings.TrimSpace(message + " " +
- gjson.GetBytes(payload, "error.message").String() + " " +
- gjson.GetBytes(payload, "response.error.message").String()))
- return strings.Contains(combined, "temporary") ||
- strings.Contains(combined, "try again") ||
- strings.Contains(combined, "please retry")
- }
- func (s *OpenAIGatewayService) handleOpenAIStreamTerminalAccountSideEffects(
- c *gin.Context,
- account *Account,
- payload []byte,
- message string,
- headers http.Header,
- canonicalModel ...string,
- ) (int, bool) {
- statusCode := openAIStreamFailureStatus(payload, message)
- switch statusCode {
- case http.StatusForbidden:
- if !openAIStream403AccountFailure(payload, message) {
- return statusCode, false
- }
- fallthrough
- case http.StatusUnauthorized, http.StatusTooManyRequests, 529:
- ctx := context.Background()
- if c != nil && c.Request != nil {
- ctx = c.Request.Context()
- }
- model := firstNonEmpty(canonicalModel...)
- if model == "" {
- model = firstNonEmpty(gjson.GetBytes(payload, "model").String(), gjson.GetBytes(payload, "response.model").String())
- }
- accountHeaders := headers
- if statusCode == http.StatusTooManyRequests {
- // 普通模型的流式 429 不能继承外层 HTTP 200 的全局 quota 快照;
- // 只有 OAuth/SetupToken 的 Spark 配额 429 才需要保留 headers 读取明确的 5h/7d reset。
- accountHeaders = openAIWSSemantic429Headers(account, model, headers)
- }
- return statusCode, s.handleOpenAIAccountUpstreamError(ctx, account, statusCode, accountHeaders, payload, model)
- default:
- return statusCode, false
- }
- }
- func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool {
- if account == nil {
- return false
- }
- // 容量降载是请求级信号,不是账号级故障:上游只是让本次请求稍后再试。
- // 换账号并不改变被降载的因素(客户端身份、模型容量都与账号无关),
- // 只会让单个请求把整池账号逐个消耗掉,最终仍以同一个错误告终。
- // 因此先在同一账号上做有界重试,用尽后才按常规流程切号。
- if isOpenAIUpstreamCapacityShedEvent(payload) {
- return true
- }
- if !account.IsPoolMode() {
- return false
- }
- semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message)
- return account.IsPoolModeRetryableStatus(semanticStatus) ||
- isOpenAITransientProcessingError(http.StatusBadRequest, message, payload)
- }
- func (s *OpenAIGatewayService) recordOpenAIStreamUpstreamError(
- c *gin.Context,
- account *Account,
- passthrough bool,
- upstreamRequestID string,
- kind string,
- payload []byte,
- message string,
- ) string {
- message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message))
- if message == "" {
- message = "OpenAI upstream response failed"
- }
- statusCode := openAIStreamFailureStatus(payload, message)
- detail := ""
- if len(payload) > 0 && s != nil && s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
- maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
- if maxBytes <= 0 {
- maxBytes = 2048
- }
- detail = truncateString(string(payload), maxBytes)
- }
- if c != nil {
- setOpsUpstreamError(c, statusCode, message, detail)
- event := OpsUpstreamErrorEvent{
- ProxyID: opsUpstreamProxyID(account),
- ProxyName: opsUpstreamProxyName(account),
- Platform: PlatformOpenAI,
- UpstreamStatusCode: statusCode,
- UpstreamRequestID: strings.TrimSpace(upstreamRequestID),
- Passthrough: passthrough,
- Kind: kind,
- Message: message,
- Detail: detail,
- }
- if account != nil {
- event.Platform = account.Platform
- event.AccountID = account.ID
- event.AccountName = account.Name
- }
- appendOpsUpstreamError(c, event)
- }
- return message
- }
- func (s *OpenAIGatewayService) newOpenAIStreamFailoverError(
- c *gin.Context,
- account *Account,
- passthrough bool,
- upstreamRequestID string,
- payload []byte,
- message string,
- responseHeaders ...http.Header,
- ) *UpstreamFailoverError {
- return s.newOpenAIStreamFailoverErrorWithModel(c, account, passthrough, upstreamRequestID, payload, message, "", responseHeaders...)
- }
- func (s *OpenAIGatewayService) newOpenAIStreamFailoverErrorWithModel(
- c *gin.Context,
- account *Account,
- passthrough bool,
- upstreamRequestID string,
- payload []byte,
- message string,
- canonicalModel string,
- responseHeaders ...http.Header,
- ) *UpstreamFailoverError {
- message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message))
- if message == "" {
- message = "OpenAI stream disconnected before completion"
- }
- var headers http.Header
- if len(responseHeaders) > 0 && responseHeaders[0] != nil {
- headers = responseHeaders[0].Clone()
- }
- statusCode, shouldDisable := s.handleOpenAIStreamTerminalAccountSideEffects(c, account, payload, message, headers, canonicalModel)
- // 流内 failed 事件承载于 HTTP 200;使用事件的语义状态更新账号健康,
- // 再由 failover 引擎按 StatusCode/RetryableOnSameAccount 决定恢复策略。
- message = s.recordOpenAIStreamUpstreamError(c, account, passthrough, upstreamRequestID, "failover", payload, message)
- errType := "upstream_error"
- if statusCode == http.StatusTooManyRequests {
- errType = "rate_limit_error"
- }
- body, _ := json.Marshal(gin.H{
- "error": gin.H{
- "type": errType,
- "message": message,
- },
- })
- retryableOnSameAccount := openAIStreamFailedEventRetryableOnSameAccount(account, payload, message)
- // 流终止事件承载在 HTTP 200 内,外层响应头描述的是成功流状态,而不是语义上的
- // 429 事件。仅在配额分类时忽略这些头;故障转移错误仍保留它们,使 Retry-After
- // 和请求 ID 能继续传递给后续处理。
- classificationHeaders := headers
- if statusCode == http.StatusTooManyRequests {
- classificationHeaders = nil
- }
- failoverErr := s.newOpenAIAccountFailoverErrorWithClassificationHeaders(account, statusCode, headers, classificationHeaders, payload, message, shouldDisable, retryableOnSameAccount)
- if failoverErr.IsCredentialFailure() || failoverErr.RequestScopedTransient {
- return failoverErr
- }
- // Preserve the existing generic envelope for unclassified stream failures;
- // only typed access/capacity failures need the original payload downstream.
- failoverErr.ResponseBody = body
- return failoverErr
- }
- // nonStreamingTerminalFailureFailover applies the streaming path's terminal-event
- // verdict to a stream=false request whose upstream answered with SSE anyway
- // (other sub2api instances and several OpenAI-compatible upstreams do this).
- //
- // Both handleSSEToJSON and handlePassthroughSSEToJSON collapsed every terminal
- // `response.failed` / `error` frame into writeOpenAINonStreamingProtocolError, a
- // fixed 502. The streaming readers sitting a few hundred lines away classify the
- // very same frame with openAIStreamFailedEventShouldFailover /
- // openAIStreamErrorEventShouldFailover and return an UpstreamFailoverError, so an
- // upstream capacity error switched accounts when stream=true and was handed to the
- // caller verbatim when stream=false — with other schedulable accounts still in the
- // pool. Same event, same upstream, opposite outcome, decided only by a request flag
- // the upstream never saw.
- //
- // Dispatching on terminalType keeps the two verdicts distinct exactly as the
- // streaming readers do: a bare `error` frame goes through the conservative
- // classifier that fails over only on positively transient markers, while
- // `response.failed` uses the fuller one.
- //
- // Replay is safe here because the body was fully buffered by
- // ReadUpstreamResponseBody and this runs before any semantic byte is written.
- // Whether a failover actually happens stays the handler's call:
- // openAIForwardMayFailover compares OpenAICompactKeepaliveAdjustedWrittenSize
- // against its pre-Forward snapshot, so a request that already emitted output is
- // refused there (#3887). This function only declines to propose failover once the
- // service has explicitly committed a response, and never re-implements the
- // keepalive accounting — a second copy of that rule would drift from the handler's.
- //
- // A nil account means there is nothing to fail over from: newOpenAIStreamFailoverError
- // records ops attribution and account health against it, so those callers keep the
- // protocol-error behaviour.
- func (s *OpenAIGatewayService) nonStreamingTerminalFailureFailover(
- c *gin.Context,
- resp *http.Response,
- account *Account,
- passthrough bool,
- terminalType string,
- payload []byte,
- message string,
- canonicalModel ...string,
- ) *UpstreamFailoverError {
- if account == nil || IsResponseCommitted(c) {
- return nil
- }
- shouldFailover := openAIStreamFailedEventShouldFailover(payload, message)
- if terminalType == "error" {
- shouldFailover = openAIStreamErrorEventShouldFailover(payload, message)
- }
- if !shouldFailover {
- return nil
- }
- var headers http.Header
- upstreamRequestID := ""
- if resp != nil {
- headers = resp.Header
- upstreamRequestID = strings.TrimSpace(resp.Header.Get("x-request-id"))
- }
- return s.newOpenAIStreamFailoverErrorWithModel(c, account, passthrough, upstreamRequestID, payload, message, firstNonEmpty(canonicalModel...), headers)
- }
- func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
- ctx context.Context,
- resp *http.Response,
- c *gin.Context,
- account *Account,
- startTime time.Time,
- originalModel string,
- mappedModel string,
- ) (*openaiStreamingResultPassthrough, error) {
- observer := upstreamResponseModelObserverFromContext(c)
- if observer == nil {
- observer = beginUpstreamResponseModelObservation(c)
- }
- writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
- // SSE headers
- c.Header("Content-Type", "text/event-stream")
- c.Header("Cache-Control", "no-cache")
- c.Header("Connection", "keep-alive")
- c.Header("X-Accel-Buffering", "no")
- if v := resp.Header.Get("x-request-id"); v != "" {
- c.Header("x-request-id", v)
- }
- w := c.Writer
- flusher, ok := w.(http.Flusher)
- if !ok {
- return nil, errors.New("streaming not supported")
- }
- usage := &OpenAIUsage{}
- imageCounter := newOpenAIImageOutputCounter()
- var firstTokenMs *int
- responseID := ""
- ttftMode := s.openAITTFTMode(ctx)
- clientDisconnected := false
- sawDone := false
- sawTerminalEvent := false
- sawFailedEvent := false
- sawBareError := false
- sawResponseFailed := false
- terminalEventType := ""
- semanticOutputSeen := false
- capacityFailoverSuppressedLogged := false
- failedMessage := ""
- clientOutputStarted := false
- codexFailureTerminal := account != nil && account.Platform == PlatformOpenAI
- failureDelivered := false
- suppressCurrentEvent := false
- responseFailedPending := false
- var bareErrorPayload []byte
- bareErrorAccountSideEffectsPending := false
- upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
- // pendingLines 在首个可见输出前保留前导事件,确保无输出失败仍可安全 failover。
- pendingLines := make([]string, 0, 8)
- // ── 首个可见输出之前的下游 keepalive ──────────────────────────────────
- //
- // 与 Forward 路径同源的问题。openai_gateway_response_handling.go 里那句注释
- // 说得最清楚:
- //
- // "Track downstream writes separately from upstream reads: pre-output
- // failover can buffer response.created / response.in_progress, so
- // keepalive must be based on downstream idle time."
- //
- // 上面的 pendingLines 正是同一种缓冲:首个可见输出到来之前,下游【一个字节
- // 都收不到】—— 连 HTTP 响应头都不会提交(gin 的 ResponseWriter 直到首次写入
- // 才发送 header)。Forward 路径为此加了心跳,透传路径漏了。
- //
- // 推理模型在首个可见输出前思考数百秒是常态,于是中间层代理会按空闲超时把
- // 连接判死。这不是假设:某生产部署实测 12 小时内 44 个 /v1/responses 请求在
- // 600~900s 才产出首个可见输出(每个 3~5 万 output token,上游其实算完了),
- // 全部被中间 nginx 的 proxy_read_timeout(600s) 判超时回 504,用户一个字没拿到。
- //
- // 心跳写出的 SSE 注释同时做到三件事:
- // 1. 提交 HTTP 响应头,让下游知道连接活着;
- // 2. 刷新中间层的空闲超时(proxy_read_timeout 衡量的是两次读之间的间隔,
- // 不是请求总时长),长推理因此不再被误杀;
- // 3. 不写出任何 pendingLines、不泄露账号相关的头,
- // 且心跳字节已由 OpenAICompactKeepaliveAdjustedWrittenSize 排除,
- // 所以 pre-output failover 的能力完全不受影响(#3887 的记账在此复用)。
- //
- // 用 startOpenAISSEKeepalive 而不是 StartOpenAICompactSSEKeepalive:后者会检查
- // compact 标记,而这里是普通 /v1/responses 透传。走到这一行时上游已回
- // text/event-stream、SSE 响应头也已设好,处于流式上下文是确定的。
- stopKeepalive := func() {}
- if s.cfg != nil && s.cfg.Gateway.StreamKeepaliveInterval > 0 {
- stopKeepalive = startOpenAISSEKeepalive(c,
- time.Duration(s.cfg.Gateway.StreamKeepaliveInterval)*time.Second)
- }
- // 任何返回路径都要停拍。Stop 与心跳 goroutine 之间有互斥锁,
- // 返回后不会再有字节写出。
- defer stopKeepalive()
- // flushPending 表示已写入但未到 SSE 空行边界的脏状态;defer 兜底函数退出前的残留,断连后不再 Flush。
- flushPending := false
- pendingSSEEventType := ""
- flushPendingOutput := func() {
- if clientDisconnected || !flushPending {
- return
- }
- flusher.Flush()
- flushPending = false
- }
- defer flushPendingOutput()
- writePendingLines := func() bool {
- for _, pending := range pendingLines {
- if _, err := fmt.Fprintln(w, pending); err != nil {
- clientDisconnected = true
- logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID)
- return false
- }
- }
- pendingLines = pendingLines[:0]
- return true
- }
- ensureResponseFailedTerminal := func() {
- if !sawBareError || sawResponseFailed || failureDelivered {
- return
- }
- if bareErrorAccountSideEffectsPending {
- s.handleOpenAIStreamTerminalAccountSideEffects(c, account, bareErrorPayload, failedMessage, resp.Header, mappedModel)
- bareErrorAccountSideEffectsPending = false
- }
- if clientDisconnected || !writePendingLines() {
- return
- }
- if _, err := fmt.Fprint(w, buildOpenAIResponseFailedSSE(responseID, originalModel, bareErrorPayload, failedMessage)); err != nil {
- clientDisconnected = true
- return
- }
- clientOutputStarted = true
- failureDelivered = true
- flushPending = true
- flushPendingOutput()
- }
- scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
- }
- scanBuf := getSSEScannerBuf64K()
- scanner.Buffer(scanBuf[:0], maxLineSize)
- defer putSSEScannerBuf64K(scanBuf)
- documentScanner := newOpenAISSEJSONDocumentScanner(scanner)
- needModelReplace := strings.TrimSpace(originalModel) != "" && strings.TrimSpace(mappedModel) != "" && strings.TrimSpace(originalModel) != strings.TrimSpace(mappedModel)
- resultWithUsage := func() *openaiStreamingResultPassthrough {
- return &openaiStreamingResultPassthrough{
- usage: usage,
- firstTokenMs: firstTokenMs,
- responseID: responseID,
- imageCount: imageCounter.Count(),
- imageOutputSizes: imageCounter.Sizes(),
- }
- }
- for documentScanner.Scan() {
- line := documentScanner.Text()
- if eventType, ok := extractOpenAISSEEventLine(line); ok {
- pendingSSEEventType = eventType
- eventType = strings.TrimSpace(eventType)
- suppressCurrentEvent = codexFailureTerminal && (eventType == "error" || (sawBareError && !sawResponseFailed && eventType != "response.failed"))
- }
- lineStartsClientOutput := false
- forceFlushFailedEvent := false
- if data, ok := extractOpenAISSEDataLine(line); ok {
- dataBytes := []byte(data)
- trimmedData := strings.TrimSpace(data)
- rawEventType := effectiveOpenAISSEEventType(dataBytes, pendingSSEEventType)
- observer.ObserveOpenAI(dataBytes, rawEventType)
- if needModelReplace && strings.Contains(data, mappedModel) {
- line = s.replaceModelInSSELine(line, mappedModel, originalModel)
- if replacedData, replaced := extractOpenAISSEDataLine(line); replaced {
- dataBytes = []byte(replacedData)
- trimmedData = strings.TrimSpace(replacedData)
- }
- }
- if normalizedData, normalized := normalizeOpenAIResponsesFunctionCallArguments(dataBytes); normalized {
- dataBytes = normalizedData
- trimmedData = strings.TrimSpace(string(normalizedData))
- line = "data: " + string(normalizedData)
- }
- if normalizedData, normalized := normalizeCompletedImageGenerationStatus(dataBytes); normalized {
- dataBytes = normalizedData
- trimmedData = strings.TrimSpace(string(normalizedData))
- line = "data: " + string(normalizedData)
- }
- if trimmedData != "[DONE]" {
- restoredData, restoreErr := restoreOpenAIResponsesNamespacePayload(c, dataBytes)
- if restoreErr != nil {
- return resultWithUsage(), fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr)
- }
- restoredData = restoreCodexToolNamesFromSSEContext(c, restoredData, rawEventType)
- if !bytes.Equal(restoredData, dataBytes) {
- dataBytes = restoredData
- trimmedData = strings.TrimSpace(string(restoredData))
- line = "data: " + string(restoredData)
- }
- }
- eventType := effectiveOpenAISSEEventType(dataBytes, rawEventType)
- if codexFailureTerminal && sawBareError && !sawResponseFailed && eventType != "response.failed" {
- suppressCurrentEvent = true
- }
- if !capacityFailoverSuppressedLogged && account != nil && account.Platform == PlatformOpenAI &&
- (eventType == "error" || eventType == "response.failed") &&
- openAIStreamClientOutputStarted(c, clientOutputStarted) &&
- isOpenAIUpstreamCapacityShedEvent(dataBytes) {
- logOpenAICapacityFailoverSuppressed(ctx, account, "passthrough_sse", upstreamRequestID, eventType)
- capacityFailoverSuppressedLogged = true
- }
- cyberHit := false
- if eventType == "response.failed" || eventType == "error" {
- if codexFailureTerminal && eventType == "error" {
- sawBareError = true
- bareErrorPayload = append(bareErrorPayload[:0], dataBytes...)
- suppressCurrentEvent = true
- } else if codexFailureTerminal && eventType == "response.failed" {
- sawResponseFailed = true
- }
- responseFailedPending = !codexFailureTerminal || eventType == "response.failed"
- failedMessage = extractOpenAISSEErrorMessage(dataBytes)
- if failedMessage == "" {
- failedMessage = "Upstream response failed"
- }
- // response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析
- // 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费
- // 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。
- s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
- if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit {
- cyberHit = true
- MarkOpsCyberPolicy(c, CyberPolicyMark{
- Code: code,
- Message: msg,
- Body: truncateString(string(dataBytes), 4096),
- UpstreamStatus: http.StatusOK,
- UpstreamInTok: usage.InputTokens,
- UpstreamOutTok: usage.OutputTokens,
- })
- }
- outputStarted := openAIStreamClientOutputStarted(c, clientOutputStarted)
- if !outputStarted && !cyberHit {
- if compactErr := newOpenAICompactFallbackSignal(c, dataBytes, failedMessage); compactErr != nil {
- return resultWithUsage(), compactErr
- }
- }
- if outputStarted && !cyberHit {
- if codexFailureTerminal && eventType == "error" {
- // Wait for the authoritative response.failed before mutating
- // account health; EOF synthesis applies the pending effect.
- bareErrorAccountSideEffectsPending = true
- } else {
- s.handleOpenAIStreamTerminalAccountSideEffects(c, account, dataBytes, failedMessage, resp.Header, mappedModel)
- bareErrorAccountSideEffectsPending = false
- }
- if eventType == "response.failed" {
- // The stream cannot be replayed after semantic output. Preserve the
- // terminal event, while making the upstream failure queryable.
- s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "stream_failed", dataBytes, failedMessage)
- }
- }
- if !outputStarted {
- shouldFailover := false
- if !cyberHit {
- if eventType == "error" {
- shouldFailover = openAIStreamErrorEventShouldFailover(dataBytes, failedMessage)
- } else {
- shouldFailover = openAIStreamFailedEventShouldFailover(dataBytes, failedMessage)
- }
- }
- if shouldFailover {
- return resultWithUsage(),
- s.newOpenAIStreamFailoverErrorWithModel(c, account, true, upstreamRequestID, dataBytes, failedMessage, mappedModel, resp.Header)
- }
- if !cyberHit && !sawBareError {
- if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
- // 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
- // antigravity 先例),否则透传命中的 failed 在监控中不可见。
- s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, failedMessage)
- MarkResponseCommitted(c)
- c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
- c.JSON(status, gin.H{
- "error": gin.H{
- "type": errType,
- "message": errMsg,
- },
- })
- return resultWithUsage(), fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg)
- }
- }
- }
- forceFlushFailedEvent = true
- sawFailedEvent = true
- }
- if trimmedData == "[DONE]" {
- sawDone = true
- terminalEventType = "[DONE]"
- }
- if openAIStreamEventIsTerminalWithType(trimmedData, eventType) {
- sawTerminalEvent = true
- if trimmedData != "[DONE]" {
- terminalEventType = eventType
- }
- }
- if responseID == "" {
- responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes)
- }
- imageCounter.AddSSEData(dataBytes)
- if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient(
- dataBytes,
- eventType,
- openAIStreamClientOutputStarted(c, clientOutputStarted),
- ); sanitized {
- dataBytes = sanitizedData
- trimmedData = strings.TrimSpace(string(sanitizedData))
- line = "data: " + string(sanitizedData)
- }
- lineStartsClientOutput = forceFlushFailedEvent || openAIStreamDataStartsClientOutput(trimmedData, eventType)
- if lineStartsClientOutput && trimmedData != "[DONE]" && !openAIStreamEventTypeIsTerminal(eventType) {
- semanticOutputSeen = true
- }
- // OpenAI Responses streams that terminate with an empty
- // response.completed (no output, no usage, no error, nothing sent
- // to the client) are silent upstream refusals: fail over instead of
- // recording a successful 0/0 usage turn (issue #5009).
- if (eventType == "response.completed" || eventType == "response.done") &&
- !sawFailedEvent && !semanticOutputSeen && !clientOutputStarted &&
- openAIResponsesCompletedEventIsEmpty(dataBytes, usage) {
- return resultWithUsage(), newOpenAIResponsesEmptyCompletedFailoverError(c, account, upstreamRequestID)
- }
- if firstTokenMs == nil && openAIStreamDataStartsTTFT(trimmedData, eventType, forceFlushFailedEvent, ttftMode) {
- ms := int(time.Since(startTime).Milliseconds())
- firstTokenMs = &ms
- }
- s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
- }
- if line == "" {
- pendingSSEEventType = ""
- if suppressCurrentEvent {
- suppressCurrentEvent = false
- responseFailedPending = false
- continue
- }
- }
- if !clientDisconnected && !failureDelivered && !suppressCurrentEvent {
- if !clientOutputStarted && !lineStartsClientOutput {
- pendingLines = append(pendingLines, line)
- continue
- }
- // 真实输出开始,心跳的使命结束。停拍是幂等的,且会与心跳 goroutine
- // 建立 happens-before —— 之后 ResponseWriter 由本循环独占。
- if !clientOutputStarted {
- stopKeepalive()
- }
- if !clientOutputStarted && len(pendingLines) > 0 {
- if !writePendingLines() {
- continue
- }
- }
- if _, err := fmt.Fprintln(w, line); err != nil {
- clientDisconnected = true
- logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID)
- } else {
- clientOutputStarted = true
- flushPending = true
- if line == "" {
- flushPendingOutput()
- }
- }
- }
- if line == "" && responseFailedPending {
- responseFailedPending = false
- failureDelivered = true
- }
- }
- ensureResponseFailedTerminal()
- if err := documentScanner.Err(); err != nil {
- if (sawDone || sawTerminalEvent) && !sawFailedEvent {
- s.clearOpenAIProxyStreamDisconnect(account)
- return resultWithUsage(), nil
- }
- if sawFailedEvent {
- return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
- }
- if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
- return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", err)
- }
- if errors.Is(err, bufio.ErrTooLong) {
- logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, err)
- return resultWithUsage(), err
- }
- if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
- msg := "OpenAI stream disconnected before completion"
- if errText := strings.TrimSpace(err.Error()); errText != "" {
- msg += ": " + errText
- }
- return resultWithUsage(),
- s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, nil, msg)
- }
- if clientDisconnected {
- return resultWithUsage(), fmt.Errorf("stream usage incomplete after disconnect: %w", err)
- }
- s.recordOpenAIProxyStreamDisconnect(account, err, upstreamRequestID)
- logger.LegacyPrintf("service.openai_gateway",
- "[OpenAI passthrough] 流读取异常中断: account=%d request_id=%s err=%v",
- account.ID,
- upstreamRequestID,
- err,
- )
- return resultWithUsage(), fmt.Errorf("stream read error: %w", err)
- }
- if sawFailedEvent {
- return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
- }
- if !clientDisconnected && !sawDone && !sawTerminalEvent && ctx.Err() == nil {
- logger.FromContext(ctx).With(
- zap.String("component", "service.openai_gateway"),
- zap.Int64("account_id", account.ID),
- zap.String("upstream_request_id", upstreamRequestID),
- ).Info("OpenAI passthrough 上游流在未收到 [DONE] 时结束,疑似断流")
- if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
- return resultWithUsage(),
- s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, nil, "OpenAI stream ended before a terminal event")
- }
- s.recordOpenAIProxyStreamDisconnect(account, errors.New("stream ended before terminal event"), upstreamRequestID)
- return resultWithUsage(), errors.New("stream usage incomplete: missing terminal event")
- }
- if (sawDone || sawTerminalEvent) && !sawFailedEvent {
- s.clearOpenAIProxyStreamDisconnect(account)
- }
- logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, terminalEventType, clientDisconnected)
- return resultWithUsage(), nil
- }
- func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
- ctx context.Context,
- resp *http.Response,
- c *gin.Context,
- account *Account,
- originalModel string,
- mappedModel string,
- ) (*openaiNonStreamingResultPassthrough, error) {
- body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
- if err != nil {
- return nil, err
- }
- observer := upstreamResponseModelObserverFromContext(c)
- if observer == nil {
- observer = beginUpstreamResponseModelObservation(c)
- }
- if bodyHasSSEFraming(body) {
- observeOpenAISSEBody(observer, string(body))
- } else {
- observer.ObserveOpenAI(body, strings.TrimSpace(gjson.GetBytes(body, "type").String()))
- }
- // Detect SSE responses from upstream and convert to JSON.
- // Some upstreams (e.g. other sub2api instances) may return SSE even when
- // stream=false was requested. Without this conversion the client would
- // receive raw SSE text or a terminal event with empty output.
- if isEventStreamResponse(resp.Header) {
- return s.handlePassthroughSSEToJSON(resp, c, account, body, originalModel, mappedModel)
- }
- usage := &OpenAIUsage{}
- usageParsed := false
- if len(body) > 0 {
- if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(body); ok {
- *usage = parsedUsage
- usageParsed = true
- }
- }
- if !usageParsed {
- // 兜底:尝试从 SSE 文本中解析 usage
- usage = s.parseSSEUsageFromBody(string(body))
- }
- logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, "json", false)
- writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
- contentType := resp.Header.Get("Content-Type")
- if contentType == "" {
- contentType = "application/json"
- }
- if originalModel != "" && mappedModel != "" && originalModel != mappedModel {
- body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
- }
- body, err = restoreOpenAIResponsesNamespacePayload(c, body)
- if err != nil {
- return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", err)
- }
- body = restoreCodexToolNamesFromContext(c, body)
- body, err = restoreOpenAIResponsesClientToolPayload(c, body)
- if err != nil {
- return nil, fmt.Errorf("restore OpenAI Responses client tools: %w", err)
- }
- if !writeOpenAICompactSSEBridge(c, resp.StatusCode, body) {
- c.Data(resp.StatusCode, contentType, body)
- }
- return &openaiNonStreamingResultPassthrough{
- OpenAIUsage: usage,
- usage: usage,
- responseID: extractOpenAIResponseIDFromJSONBytes(body),
- imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
- imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body),
- }, nil
- }
- // handlePassthroughSSEToJSON converts an SSE response body into a JSON
- // response for the passthrough path. It mirrors handleSSEToJSON while
- // preserving passthrough payloads, except compact-only model remapping may
- // rewrite model fields back to the original requested model.
- func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, account *Account, body []byte, originalModel string, mappedModel string) (*openaiNonStreamingResultPassthrough, error) {
- bodyText := string(body)
- terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
- if terminalOK && (terminalType == "response.failed" || terminalType == "error") {
- msg := extractOpenAISSEErrorMessage(terminalPayload)
- if msg == "" {
- msg = "Upstream compact response failed"
- }
- if compactErr := newOpenAICompactFallbackSignal(c, terminalPayload, msg); compactErr != nil {
- return nil, compactErr
- }
- if failoverErr := s.nonStreamingTerminalFailureFailover(c, resp, account, true, terminalType, terminalPayload, msg, mappedModel); failoverErr != nil {
- return nil, failoverErr
- }
- return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg)
- }
- finalResponse, ok := extractCodexFinalResponse(bodyText)
- usage := s.parseSSEUsageFromBody(bodyText)
- if ok {
- if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed {
- *usage = parsedUsage
- }
- // When the terminal event has an empty output array, reconstruct
- // output from accumulated delta events so the client gets full content.
- if len(gjson.GetBytes(finalResponse, "output").Array()) == 0 {
- if outputJSON, reconstructed := reconstructResponseOutputFromSSE(bodyText); reconstructed {
- if patched, err := sjson.SetRawBytes(finalResponse, "output", outputJSON); err == nil {
- finalResponse = patched
- }
- }
- }
- finalResponse = supplementCompactionItemFromSSE(c, finalResponse, bodyText)
- body = finalResponse
- if originalModel != "" && mappedModel != "" && originalModel != mappedModel {
- body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
- }
- // Correct tool calls in final response
- body = s.correctToolCallsInResponseBody(body)
- restoredBody, restoreErr := restoreOpenAIResponsesNamespacePayload(c, body)
- if restoreErr != nil {
- return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr)
- }
- restoredBody = restoreCodexToolNamesFromContext(c, restoredBody)
- body = restoredBody
- } else {
- if originalModel != "" && mappedModel != "" && originalModel != mappedModel {
- bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel)
- }
- body = []byte(bodyText)
- }
- writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
- logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, usage, terminalType, false)
- contentType := "application/json; charset=utf-8"
- if !ok {
- contentType = resp.Header.Get("Content-Type")
- if contentType == "" {
- contentType = "text/event-stream"
- }
- }
- if !writeOpenAICompactSSEBridge(c, resp.StatusCode, body) {
- c.Data(resp.StatusCode, contentType, body)
- }
- return &openaiNonStreamingResultPassthrough{
- OpenAIUsage: usage,
- usage: usage,
- responseID: extractOpenAIResponseIDFromJSONBytes(body),
- imageCount: countOpenAIImageOutputsFromSSEBody(bodyText),
- imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText),
- }, nil
- }
- func writeOpenAIPassthroughResponseHeaders(dst http.Header, src http.Header, filter *responseheaders.CompiledHeaderFilter) {
- if dst == nil || src == nil {
- return
- }
- if filter != nil {
- responseheaders.WriteFilteredHeaders(dst, src, filter)
- } else {
- // 兜底:尽量保留最基础的 content-type
- if v := strings.TrimSpace(src.Get("Content-Type")); v != "" {
- dst.Set("Content-Type", v)
- }
- }
- // 透传模式强制放行 x-codex-* 响应头(若上游返回)。
- // 注意:真实 http.Response.Header 的 key 一般会被 canonicalize;但为了兼容测试/自建响应,
- // 这里用 EqualFold 做一次大小写不敏感的查找。
- getCaseInsensitiveValues := func(h http.Header, want string) []string {
- if h == nil {
- return nil
- }
- for k, vals := range h {
- if strings.EqualFold(k, want) {
- return vals
- }
- }
- return nil
- }
- for _, rawKey := range []string{
- "x-codex-primary-used-percent",
- "x-codex-primary-reset-after-seconds",
- "x-codex-primary-window-minutes",
- "x-codex-secondary-used-percent",
- "x-codex-secondary-reset-after-seconds",
- "x-codex-secondary-window-minutes",
- "x-codex-primary-over-secondary-limit-percent",
- } {
- vals := getCaseInsensitiveValues(src, rawKey)
- if len(vals) == 0 {
- continue
- }
- key := http.CanonicalHeaderKey(rawKey)
- dst.Del(key)
- for _, v := range vals {
- dst.Add(key, v)
- }
- }
- // x-codex-turn-state:Codex 回合状态头,客户端会在同回合后续请求回带。
- // 与上面的用量头不同,这里在上游缺失时也主动清除——failover 换号后残留
- // 上一账号的 blob 会构成跨账号矛盾(openai_codex_turn_state.go)。
- turnStateKey := http.CanonicalHeaderKey(openAICodexTurnStateHeader)
- dst.Del(turnStateKey)
- for _, v := range getCaseInsensitiveValues(src, openAICodexTurnStateHeader) {
- dst.Add(turnStateKey, v)
- }
- }
|