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) } // 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 (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) { upstreamPassthroughModel := "" if isOpenAIResponsesCompactPath(c) { compactMappedModel := resolveOpenAICompactForwardModel(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.Type == AccountTypeOAuth { 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() 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.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && !isOpenAIResponsesCompactPath(c) && needsOpenAIResponsesClientToolAdaptation(body) { adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body) if adaptErr != nil { return nil, adaptErr } body = adaptedBody c.Set(openAIResponsesClientToolMappingContextKey, 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 var resp *http.Response for { 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.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) 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 { break } // 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 !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 } // 透传模式默认保持原样代理;容量错误以及 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) } defer func() { _ = resp.Body.Close() }() 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) } serviceTier := extractOpenAIServiceTierFromBody(body) // x-codex-turn-state 溯源:下游回传由 writeOpenAIPassthroughResponseHeaders // 在各 handler 的写头点强制放行,铸造账号在此统一记录,供出站守卫剥离 // failover 换号后的跨账号回带(openai_codex_turn_state.go)。 if extractOpenAICodexTurnState(resp.Header) != "" { s.noteOpenAICodexTurnStateProvenance(c, account) } var usage *OpenAIUsage var firstTokenMs *int responseID := "" imageCount := 0 var imageOutputSizes []string if reqStream { result, err := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel) if err != nil { return nil, err } usage = result.usage firstTokenMs = result.firstTokenMs responseID = strings.TrimSpace(result.responseID) imageCount = result.imageCount imageOutputSizes = result.imageOutputSizes } else { result, err := s.handleNonStreamingResponsePassthrough(ctx, resp, c, reqModel, upstreamPassthroughModel) if err != nil { return nil, err } usage = result.usage responseID = strings.TrimSpace(result.responseID) imageCount = result.imageCount imageOutputSizes = result.imageOutputSizes } 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) } } if usage == nil { usage = &OpenAIUsage{} } forwardResult := &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), ResponseID: responseID, Usage: *usage, Model: reqModel, UpstreamModel: upstreamPassthroughModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), ServiceTier: 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) { targetURL := openaiPlatformAPIURL switch account.Type { case AccountTypeOAuth: targetURL = chatgptCodexURL case AccountTypeAPIKey: baseURL := account.GetOpenAIBaseURL() if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { return nil, err } targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL) } } targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) // DeepSeek 原生 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.Type == AccountTypeOAuth { // 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", isolateOpenAISessionID(apiKeyID, clientSessionID)) } if clientConversationID != "" { req.Header.Set("conversation_id", isolateOpenAISessionID(apiKeyID, 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()) } // 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头, // 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置: // 会话隔离之后、终态身份收口之前)。 applyStagedCodexFingerprintHeaders(c, account, req.Header) // 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份 // (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。 if account.Type == AccountTypeOAuth { 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) // 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 isOpenAIContextWindowError("", responseBody) { return false } 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{ 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 newOpenAIUpstreamFailoverError( resp.StatusCode, resp.Header, body, upstreamMsg, !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{ 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 } // 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))) 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 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 } // Keep the existing 502 failover behavior for other response.failed events. // Only rate limits need promotion because they participate in the account's // configurable 429 same-account retry policy. if openAIStreamFailedEventSemanticStatus(payload, message) == http.StatusTooManyRequests { return http.StatusTooManyRequests } return http.StatusBadGateway } 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 isOpenAIContextWindowError(message, payload) { return false } // 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 openAIStreamFailureStatus(payload, message) == 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 strings.TrimSpace(gjson.GetBytes(payload, "type").String()) != "error" { return false } if isOpenAIContextWindowError(message, payload) { return false } return isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) } 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{ 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 { message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message)) if message == "" { message = "OpenAI stream disconnected before completion" } statusCode := openAIStreamFailureStatus(payload, message) var headers http.Header if len(responseHeaders) > 0 && responseHeaders[0] != nil { headers = responseHeaders[0].Clone() } // 流内 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, }, }) return &UpstreamFailoverError{ StatusCode: statusCode, ResponseBody: body, ResponseHeaders: headers, RetryableOnSameAccount: openAIStreamFailedEventRetryableOnSameAccount(account, payload, message), RequestScopedTransient: isOpenAIUpstreamCapacityShedEvent(payload), } } 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 := "" clientDisconnected := false sawDone := false sawTerminalEvent := false sawFailedEvent := false semanticOutputSeen := false capacityFailoverSuppressedLogged := false failedMessage := "" clientOutputStarted := false upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) // pendingLines 在首个可见输出前保留前导事件,确保无输出失败仍可安全 failover。 pendingLines := make([]string, 0, 8) // flushPending 表示已写入但未到 SSE 空行边界的脏状态;defer 兜底函数退出前的残留,断连后不再 Flush。 flushPending := false 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 } 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() lineStartsClientOutput := false forceFlushFailedEvent := false if data, ok := extractOpenAISSEDataLine(line); ok { dataBytes := []byte(data) trimmedData := strings.TrimSpace(data) rawEventType := strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) 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) } if !bytes.Equal(restoredData, dataBytes) { dataBytes = restoredData trimmedData = strings.TrimSpace(string(restoredData)) line = "data: " + string(restoredData) } } eventType := strings.TrimSpace(gjson.Get(trimmedData, "type").String()) 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 } if eventType == "error" && !openAIStreamClientOutputStarted(c, clientOutputStarted) { errorMessage := extractOpenAISSEErrorMessage(dataBytes) if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, errorMessage); matched { s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, errorMessage) 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 error event: passthrough rule matched message=%s", errMsg) } if openAIStreamErrorEventShouldFailover(dataBytes, errorMessage) { return resultWithUsage(), s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, errorMessage, resp.Header) } } if eventType == "response.failed" { failedMessage = extractOpenAISSEErrorMessage(dataBytes) // response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析 // 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费 // 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。 s.parseSSEUsageBytes(dataBytes, usage) if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit { MarkOpsCyberPolicy(c, CyberPolicyMark{ Code: code, Message: msg, Body: truncateString(string(dataBytes), 4096), UpstreamStatus: http.StatusOK, UpstreamInTok: usage.InputTokens, UpstreamOutTok: usage.OutputTokens, }) } if !openAIStreamClientOutputStarted(c, clientOutputStarted) { 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) } if openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) { return resultWithUsage(), s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, failedMessage, resp.Header) } } forceFlushFailedEvent = true sawFailedEvent = true } if trimmedData == "[DONE]" { sawDone = true } if openAIStreamEventIsTerminal(trimmedData) { sawTerminalEvent = true } 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 && openAIStreamDataStartsVisibleOutput(trimmedData, eventType) { ms := int(time.Since(startTime).Milliseconds()) firstTokenMs = &ms } s.parseSSEUsageBytes(dataBytes, usage) } if !clientDisconnected { if !clientOutputStarted && !lineStartsClientOutput { pendingLines = append(pendingLines, line) continue } 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 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) } return resultWithUsage(), nil } func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( ctx context.Context, resp *http.Response, c *gin.Context, 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, 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)) } 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) } if mapping, ok := openAIResponsesClientToolMapping(c); ok && json.Valid(body) { body, _, err = apicompat.RestoreResponsesClientToolPayload(body, mapping) 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, body []byte, originalModel string, mappedModel string) (*openaiNonStreamingResultPassthrough, error) { bodyText := string(body) finalResponse, ok := extractCodexFinalResponse(bodyText) usage := &OpenAIUsage{} 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) } body = restoredBody } else { terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText) if terminalOK && terminalType == "response.failed" { msg := extractOpenAISSEErrorMessage(terminalPayload) if msg == "" { msg = "Upstream compact response failed" } return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg) } usage = s.parseSSEUsageFromBody(bodyText) if originalModel != "" && mappedModel != "" && originalModel != mappedModel { bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel) } body = []byte(bodyText) } writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) 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) } }