Files
sub2api/backend/internal/service/openai_gateway_passthrough.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

1967 lines
70 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)
}
// 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 normalizebody 中的 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-streamcompact 上游是
// 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 自带上游已消耗的 usageinput 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-stateCodex 回合状态头,客户端会在同回合后续请求回带。
// 与上面的用量头不同,这里在上游缺失时也主动清除——failover 换号后残留
// 上一账号的 blob 会构成跨账号矛盾(openai_codex_turn_state.go)。
turnStateKey := http.CanonicalHeaderKey(openAICodexTurnStateHeader)
dst.Del(turnStateKey)
for _, v := range getCaseInsensitiveValues(src, openAICodexTurnStateHeader) {
dst.Add(turnStateKey, v)
}
}