Files
sub2api/backend/internal/service/openai_gateway_passthrough.go
T
qiu121 6aabbdf547 fix(openai): 记录流式失败的上游诊断信息
- 在 Responses 流已经输出语义内容后记录 response.failed 的上游事件,使运维可以查询原始错误、账号和上游 request ID,定位 OpenAI 临时处理失败而不丢失诊断上下文。
- 保留已提交语义内容后禁止切号重放的安全边界,避免客户端收到重复或混合响应;补充原生与透传路径的回归测试,确保该错误可观测且行为一致。

Refs: #5165, #6714
2026-09-07 11:27:08 +08:00

2472 lines
92 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
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)
}
}