Files
sub2api/backend/internal/service/openai_gateway_forward.go
T

1216 lines
47 KiB
Go
Raw Normal View History

package service
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
// Forward forwards request to OpenAI API
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
beginUpstreamResponseModelObservation(c)
clearGrokResponsesClientToolMapping(c)
clearOpenAIResponsesClientToolMapping(c)
clearOpenAIResponsesNamespaceNames(c)
startTime := time.Now()
// 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。
canonicalImageIntentBody := body
restrictionResult := s.detectCodexClientRestriction(c, account, body)
apiKeyID := getAPIKeyIDFromContext(c)
logCodexCLIOnlyDetection(ctx, c, account, apiKeyID, restrictionResult, body)
if restrictionResult.Enabled && !restrictionResult.Matched {
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
c.JSON(http.StatusForbidden, gin.H{
"error": gin.H{
"type": "forbidden_error",
"message": CodexClientRestrictionMessage(restrictionResult),
},
})
return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed")
}
normalizedBody, normalized, err := normalizeOpenAICodexCompactReasoningEffortForAccount(c, account, body)
if err != nil {
return nil, err
}
if normalized {
body = normalizedBody
}
// 在分流到 passthrough / Codex transform / 原生 ChatCompletions 之前统一修正
// 显式为 null 的工具 Schema type,否则 upstream 的 400 会被归一成可重试的 502,
// 同一份坏定义在账号池里反复重放。
sanitizedToolBody, toolSchemaSanitized, toolSchemaErr := sanitizeOpenAIResponsesToolParameterTypes(body)
if toolSchemaErr != nil {
return nil, fmt.Errorf("sanitize OpenAI Responses tool parameters: %w", toolSchemaErr)
}
if toolSchemaSanitized {
body = sanitizedToolBody
}
if account.IsOpenAIOAuth() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) {
liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body)
if liteErr != nil {
setOpsUpstreamError(c, http.StatusBadRequest, liteErr.Error(), "")
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error", "message": liteErr.Error(), "param": "tools",
}})
return nil, liteErr
}
if changed {
body = liteBody
}
}
wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account)
// 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。
wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c))
passthroughEnabled := account.IsOpenAIPassthroughEnabled()
compactPath := isOpenAIResponsesCompactPath(c)
if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled, compactPath) {
body, err = flattenOpenAIResponsesNamespaces(c, body)
if err != nil {
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error", "message": err.Error(), "param": "tools",
}})
return nil, err
}
}
if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) {
keepToolCallNamespaces := shouldKeepOpenAIResponsesToolCallNamespaces(
account, wsDecision.Transport, passthroughEnabled, compactPath,
)
body, err = stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
if err != nil {
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error", "message": err.Error(), "param": "input",
}})
return nil, err
}
}
originalBody := body
requestView := newOpenAIRequestView(body)
reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey
originalModel := reqModel
nativeDeepSeekResponses := account.Platform == PlatformDeepseek &&
(account.GetAPIProtocol() == APIProtocolResponses || account.IsAdaptiveAPIProtocol())
if account.Platform == PlatformGrok {
return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime)
}
// CN 供应商 anthropic 协议账号:/v1/responses 入站是交叉协议组合
// Responses 客户端 × Anthropic 上游),转成 Anthropic 请求走原生端点。
// 不能落到下面的 raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。
if account.IsAnthropicProtocol() {
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel)
}
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
}
if account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey {
sanitizedBody, changed, sanitizeErr := sanitizeOpenAIResponsesInputItemIDs(body)
if sanitizeErr != nil {
return nil, fmt.Errorf("sanitize OpenAI Responses input item IDs: %w", sanitizeErr)
}
if changed {
body = sanitizedBody
originalBody = sanitizedBody
requestView = newOpenAIRequestView(sanitizedBody)
reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
originalModel = reqModel
}
}
compatMessagesBridge := isOpenAICompatMessagesBridgeBody(body)
setOpenAICompatMessagesBridgeContext(c, compatMessagesBridge)
isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI)
codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow
if isCodexCLI {
codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy()
}
if c != nil {
c.Set("openai_ws_transport_decision", string(wsDecision.Transport))
c.Set("openai_ws_transport_reason", wsDecision.Reason)
}
if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 {
logOpenAIWSModeDebug(
"selected account_id=%d account_type=%s transport=%s reason=%s model=%s stream=%v",
account.ID,
account.Type,
normalizeOpenAIWSLogValue(string(wsDecision.Transport)),
normalizeOpenAIWSLogValue(wsDecision.Reason),
reqModel,
reqStream,
)
}
// 当前仅支持 WSv2;WSv1 命中时直接返回错误,避免出现“配置可开但行为不确定”。
if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocket {
if c != nil {
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"type": "invalid_request_error",
"message": "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.",
},
})
}
return nil, errors.New("openai ws v1 is temporarily unsupported; use ws v2")
}
if passthroughEnabled {
attemptImageIntentInvalidated := false
if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip {
strippedBody, changed, stripErr := stripOpenAIImageGenerationToolsFromRawPayload(body)
if stripErr != nil {
return nil, stripErr
}
if changed {
body = strippedBody
originalBody = strippedBody
attemptImageIntentInvalidated = true
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy")
}
}
// 透传分支只需要轻量提取字段,避免热路径全量 Unmarshal。
mappedModel := account.GetMappedModel(reqModel)
reasoningEffort := extractOpenAIReasoningEffortFromBody(body, mappedModel)
// 国产模型默认 effort 补充:也要用 mappedModel 判定是否是 passback-required 上游。
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, mappedModel)
return s.forwardOpenAIPassthrough(
ctx,
c,
account,
originalBody,
canonicalImageIntentBody,
reqModel,
attemptImageIntentInvalidated,
reasoningEffort,
reqStream,
startTime,
)
}
bodyModified := false
var reqBody map[string]any
ensureReqBody := func() (map[string]any, error) {
if requestView.HasPatches() {
patchedBody, patchErr := requestView.ApplyPatches()
if patchErr != nil {
return nil, patchErr
}
body = patchedBody
requestView = newOpenAIRequestView(body)
reqBody = nil
bodyModified = false
}
if reqBody != nil {
return reqBody, nil
}
decoded, decodeErr := requestView.Decode(c)
if decodeErr != nil {
return nil, decodeErr
}
reqBody = decoded
return reqBody, nil
}
markPatchSet := func(path string, value any) {
bodyModified = true
if requestView.patchesDisabled {
if reqBody != nil {
setOpenAIRequestMapPath(reqBody, path, value)
}
return
}
requestView.MarkPatchSet(path, value)
}
markPatchDelete := func(path string) {
bodyModified = true
if requestView.patchesDisabled {
if reqBody != nil {
deleteOpenAIRequestMapPath(reqBody, path)
}
return
}
requestView.MarkPatchDelete(path)
}
disablePatch := func() {
requestView.DisablePatches()
}
markDecodedModified := func() {
bodyModified = true
disablePatch()
}
apiKey := getAPIKeyFromContext(c)
imageGenerationAllowed := GroupAllowsImageGeneration(nil)
if apiKey != nil {
imageGenerationAllowed = GroupAllowsImageGeneration(apiKey.Group)
}
codexImageGenerationBridgeEnabled := isCodexCLI &&
!isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) &&
imageGenerationAllowed &&
codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip &&
s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
var imageIntent bool
canonicalImageIntent := resolveOpenAIImageIntentHint(c, reqModel, canonicalImageIntentBody, IsImageGenerationIntent)
if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
}
if stripOpenAIImageGenerationTools(decoded) {
markDecodedModified()
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy")
}
imageIntent = IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, decoded)
} else {
imageIntent = canonicalImageIntent
}
if imageIntent && !imageGenerationAllowed {
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")
}
instructions := gjson.GetBytes(body, "instructions")
instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == ""
if instructionsEmpty && !compatMessagesBridge && !nativeDeepSeekResponses {
markPatchSet("instructions", defaultCodexSynthInstructions(reqModel))
}
billingModel := account.GetMappedModel(reqModel)
if billingModel != reqModel {
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, billingModel, account.Name, isCodexCLI)
reqModel = billingModel
markPatchSet("model", billingModel)
}
upstreamModel := billingModel
isCompactRequest := compactPath
compactMapped := false
if isCompactRequest {
compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel)
if compactMappedModel != "" && compactMappedModel != billingModel {
compactMapped = true
upstreamModel = compactMappedModel
reqModel = compactMappedModel
markPatchSet("model", compactMappedModel)
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", billingModel, compactMappedModel, account.Name, isCodexCLI)
}
}
if !compactMapped {
modelForNormalize := reqModel
if modelForNormalize == "" {
modelForNormalize = requestView.Model
}
upstreamModel = normalizeOpenAIModelForUpstream(account, modelForNormalize)
if upstreamModel != "" && upstreamModel != modelForNormalize {
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", modelForNormalize, upstreamModel, account.Name, account.Type, isCodexCLI)
reqModel = upstreamModel
markPatchSet("model", upstreamModel)
}
}
if strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) == "minimal" {
markPatchSet("reasoning.effort", "none")
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized reasoning.effort: minimal -> none (account: %s)", account.Name)
}
imageIntent = imageIntent || IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, nil) || isOpenAIImageGenerationModel(upstreamModel)
if imageIntent && !imageGenerationAllowed {
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")
}
// /responses/compact 是会话压缩请求:上游不接受 tool_choice400 unknown_parameter),
// 注入 image_generation 工具也没有意义,整块豁免。
if imageGenerationAllowed && !isCompactRequest && (codexImageGenerationBridgeEnabled || isOpenAIImageGenerationModel(requestView.Model) || openAIRequestBodyImageGenerationToolNeedsNormalization(body) || isOpenAIImageGenerationModel(upstreamModel)) {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
}
if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationTool(decoded) {
markDecodedModified()
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Injected /responses image_generation tool for Codex client")
}
if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationToolChoiceAuto(decoded) {
markDecodedModified()
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Set /responses image_generation tool_choice=auto for Codex client")
}
if normalizeOpenAIResponsesImageGenerationTools(decoded) {
markDecodedModified()
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image_generation tool payload")
}
if normalizeOpenAIResponsesImageOnlyModel(decoded) {
markDecodedModified()
if model, ok := decoded["model"].(string); ok {
upstreamModel = strings.TrimSpace(model)
}
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image-only model request inbound_model=%s image_model=%s upstream_model=%s", requestView.Model, billingModel, upstreamModel)
}
if err := validateOpenAIResponsesImageModel(decoded, upstreamModel); err != nil {
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "model"}})
return nil, err
}
if hasOpenAIImageGenerationTool(decoded) {
imageIntent = true
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type)
}
if codexImageGenerationBridgeEnabled && applyCodexImageGenerationBridgeInstructions(decoded) {
markDecodedModified()
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Added Codex image_generation bridge instructions")
}
} else if imageGenerationAllowed && imageIntent && openAIRequestBodyHasImageGenerationDeclaration(body) {
// 完整 image_generation tool 只做 raw 计费读取,校验/桥接/旧字段迁移命中时才展开大 input map。
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type)
}
if isCodexSparkModel(upstreamModel) && openAIRequestBodyMayContainImageInput(body) {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
}
if err := validateCodexSparkInput(decoded, upstreamModel); err != nil {
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "input"}})
return nil, err
}
}
// gpt-5.3-codex-spark also rejects the image_generation tool (HTTP 400,
// param=tools). Strip it here so both APIKey and OAuth /responses paths are
// covered regardless of the image-generation feature gate.
if isCodexSparkModel(upstreamModel) && openAIRequestBodyHasImageGenerationDeclaration(body) {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
}
if stripCodexSparkImageGenerationTools(decoded) {
markDecodedModified()
}
}
if account.Type == AccountTypeOAuth {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
}
codexResult := codexTransformResult{}
if compatMessagesBridge {
codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{IsCodexCLI: isCodexCLI, IsCompact: isCompactRequest, SkipDefaultInstructions: true, PreserveToolCallIDs: true})
ensureCodexOAuthInstructionsField(decoded)
markDecodedModified()
} else {
codexResult = applyCodexOAuthTransform(decoded, isCodexCLI, isCompactRequest)
}
if codexResult.Modified {
markDecodedModified()
}
// 带真实 device_id 时补齐 client_metadata 安装标识,与真实 Codex 对齐(compact 形态不同,跳过)。
if !isCompactRequest && applyCodexClientMetadata(decoded, account) {
markDecodedModified()
}
stageCodexFingerprintIDs(c, nil)
// 指纹收敛:一次性解析收敛 ID,请求体和出站头共享同一份 IDs(保证 turn_id 等随机字段一致)。
// fingerprintIDs 在此处解析,后续 buildUpstreamRequest 中使用同一份。
if !isCompactRequest {
var clientHeaders http.Header
if c != nil && c.Request != nil {
clientHeaders = c.Request.Header
}
fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders)
if fpIDs != nil {
if applyCodexFingerprintClientMetadata(decoded, fpIDs) {
markDecodedModified()
}
}
// 将 fpIDs 存入 gin context,供 buildUpstreamRequest 中头改写使用。
// 无条件覆写(含 nil):failover 从收敛账号切到 off 账号时,上一
// 账号的 IDs 不得残留(stageCodexFingerprintIDs 注释)。
stageCodexFingerprintIDs(c, fpIDs)
}
if codexResult.NormalizedModel != "" {
upstreamModel = codexResult.NormalizedModel
}
if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" {
promptCacheKey = currentPromptCacheKey
} else if codexResult.PromptCacheKey != "" {
promptCacheKey = codexResult.PromptCacheKey
}
}
if !SupportsVerbosity(upstreamModel) && gjson.GetBytes(body, "text.verbosity").Exists() {
markPatchDelete("text.verbosity")
}
if !isCodexCLI {
maxOutputTokens := gjson.GetBytes(body, "max_output_tokens")
if maxOutputTokens.Exists() {
switch account.Platform {
case PlatformOpenAI, PlatformDeepseek:
// Preserve Responses-native output limits unless the selected upstream
// explicitly rejects the field in the bounded HTTP retry loop below.
case PlatformAnthropic:
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
}
delete(decoded, "max_output_tokens")
if _, hasMaxTokens := decoded["max_tokens"]; !hasMaxTokens {
decoded["max_tokens"] = maxOutputTokens.Value()
}
markDecodedModified()
case PlatformGemini:
markPatchDelete("max_output_tokens")
default:
markPatchDelete("max_output_tokens")
}
}
// /v1/responses 的规范输出上限字段是 max_output_tokens;部分客户端仍按
// Chat Completions 习惯发送 max_tokens,兼容 Responses 上游会拒绝该字段(#4417)。
// 仅对 OpenAI 平台归一化:Anthropic 合法使用 max_tokens,其 max_output_tokens
// 反向转换已在上方 switch 中处理。
if account.Platform == PlatformOpenAI {
if maxTokens := gjson.GetBytes(body, "max_tokens"); maxTokens.Exists() {
if !gjson.GetBytes(body, "max_output_tokens").Exists() {
markPatchSet("max_output_tokens", maxTokens.Value())
}
markPatchDelete("max_tokens")
}
}
if gjson.GetBytes(body, "max_completion_tokens").Exists() && (account.Type == AccountTypeAPIKey || account.Platform != PlatformOpenAI) {
markPatchDelete("max_completion_tokens")
}
for _, unsupportedField := range []string{"prompt_cache_retention", "safety_identifier", "prompt_cache_options"} {
if gjson.GetBytes(body, unsupportedField).Exists() {
markPatchDelete(unsupportedField)
}
}
}
if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 && gjson.GetBytes(body, "previous_response_id").Exists() {
markPatchDelete("previous_response_id")
}
if openAIRequestBodyMayContainEmptyBase64InputImage(body) {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
}
if sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(decoded) {
markDecodedModified()
}
}
if rawTier := requestView.ServiceTier; rawTier != "" {
if normTier := normalizedOpenAIServiceTierValue(rawTier); normTier != "" {
action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, upstreamModel, normTier)
switch action {
case BetaPolicyActionBlock:
msg := errMsg
if msg == "" {
msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, upstreamModel)
}
blocked := &OpenAIFastBlockedError{Message: msg}
writeOpenAIFastPolicyBlockedResponse(c, blocked)
return nil, blocked
case BetaPolicyActionFilter:
markPatchDelete("service_tier")
case OpenAIFastPolicyActionForcePriority:
if rawTier != OpenAIFastTierPriority {
markPatchSet("service_tier", OpenAIFastTierPriority)
}
default:
if normTier != rawTier {
markPatchSet("service_tier", normTier)
}
}
}
}
if bodyModified {
if requestView.HasPatches() {
if patchedBody, patchErr := requestView.ApplyPatches(); patchErr == nil {
body = patchedBody
requestView = newOpenAIRequestView(body)
reqBody = nil
bodyModified = false
}
}
if bodyModified {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
}
var marshalErr error
body, marshalErr = marshalOpenAIUpstreamJSON(decoded)
if marshalErr != nil {
return nil, fmt.Errorf("serialize request body: %w", marshalErr)
}
requestView = newOpenAIRequestView(body)
}
}
imageBillingModel := ""
imageSizeTier := ""
imageInputSize := ""
if imageIntent {
var imageCfg OpenAIResponsesImageBillingConfig
var imageCfgErr error
if reqBody != nil {
imageCfg, imageCfgErr = resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, billingModel)
} else {
imageCfg, imageCfgErr = resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, billingModel)
}
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
}
// Get access token
token, _, err := s.GetAccessToken(ctx, account)
if err != nil {
return nil, err
}
// 命中 WS 时仅走 WebSocket Mode;不再自动回退 HTTP。
if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 {
// WS 分支需要结构化 payload 与重连恢复,命中后再触发 full-map decode。
wsReqBody, err := ensureReqBody()
if err != nil {
return nil, err
}
_, hasPreviousResponseID := wsReqBody["previous_response_id"]
logOpenAIWSModeDebug(
"forward_start account_id=%d account_type=%s model=%s stream=%v has_previous_response_id=%v",
account.ID,
account.Type,
upstreamModel,
reqStream,
hasPreviousResponseID,
)
maxAttempts := openAIWSReconnectRetryLimit + 1
wsAttempts := 0
var wsResult *OpenAIForwardResult
var wsErr error
wsLastFailureReason := ""
agentTaskRecoveryTried := false
wsPrevResponseRecoveryTried := false
wsInvalidEncryptedContentRecoveryTried := false
recoverPrevResponseNotFound := func(attempt int) bool {
if wsPrevResponseRecoveryTried {
return false
}
previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id")
if previousResponseID == "" {
logOpenAIWSModeInfo(
"reconnect_prev_response_recovery_skip account_id=%d attempt=%d reason=missing_previous_response_id previous_response_id_present=false",
account.ID,
attempt,
)
return false
}
if HasFunctionCallOutput(wsReqBody) {
logOpenAIWSModeInfo(
"reconnect_prev_response_recovery_skip account_id=%d attempt=%d reason=has_function_call_output previous_response_id_present=true",
account.ID,
attempt,
)
return false
}
delete(wsReqBody, "previous_response_id")
wsPrevResponseRecoveryTried = true
logOpenAIWSModeInfo(
"reconnect_prev_response_recovery account_id=%d attempt=%d action=drop_previous_response_id retry=1 previous_response_id=%s previous_response_id_kind=%s",
account.ID,
attempt,
truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
normalizeOpenAIWSLogValue(ClassifyOpenAIPreviousResponseIDKind(previousResponseID)),
)
return true
}
recoverInvalidEncryptedContent := func(attempt int) bool {
if wsInvalidEncryptedContentRecoveryTried {
return false
}
removedReasoningItems := trimOpenAIEncryptedReasoningItems(wsReqBody)
if !removedReasoningItems {
logOpenAIWSModeInfo(
"reconnect_invalid_encrypted_content_recovery_skip account_id=%d attempt=%d reason=missing_encrypted_reasoning_items",
account.ID,
attempt,
)
return false
}
previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id")
hasFunctionCallOutput := HasFunctionCallOutput(wsReqBody)
if previousResponseID != "" && !hasFunctionCallOutput {
delete(wsReqBody, "previous_response_id")
}
wsInvalidEncryptedContentRecoveryTried = true
logOpenAIWSModeInfo(
"reconnect_invalid_encrypted_content_recovery account_id=%d attempt=%d action=drop_encrypted_reasoning_items retry=1 previous_response_id_present=%v previous_response_id=%s previous_response_id_kind=%s has_function_call_output=%v dropped_previous_response_id=%v",
account.ID,
attempt,
previousResponseID != "",
truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
normalizeOpenAIWSLogValue(ClassifyOpenAIPreviousResponseIDKind(previousResponseID)),
hasFunctionCallOutput,
previousResponseID != "" && !hasFunctionCallOutput,
)
return true
}
retryBudget := s.openAIWSRetryTotalBudget()
retryStartedAt := time.Now()
wsRetryLoop:
for attempt := 1; attempt <= maxAttempts; attempt++ {
wsAttempts = attempt
wsResult, wsErr = s.forwardOpenAIWSV2(
ctx,
c,
account,
wsReqBody,
token,
wsDecision,
isCodexCLI,
reqStream,
originalModel,
upstreamModel,
startTime,
attempt,
wsLastFailureReason,
&agentTaskRecoveryTried,
)
if wsErr == nil {
break
}
if c != nil && c.Writer != nil && c.Writer.Written() {
break
}
var taskRecoveredErr *agentIdentityTaskRecoveredError
if errors.As(wsErr, &taskRecoveredErr) {
continue
}
reason, retryable := classifyOpenAIWSReconnectReason(wsErr)
if reason != "" {
wsLastFailureReason = reason
}
// previous_response_not_found 说明续链锚点不可用:
// 对非 function_call_output 场景,允许一次“去掉 previous_response_id 后重放”。
if reason == "previous_response_not_found" && recoverPrevResponseNotFound(attempt) {
continue
}
if reason == "invalid_encrypted_content" && recoverInvalidEncryptedContent(attempt) {
continue
}
if retryable && attempt < maxAttempts {
backoff := s.openAIWSRetryBackoff(attempt)
if retryBudget > 0 && time.Since(retryStartedAt)+backoff > retryBudget {
s.recordOpenAIWSRetryExhausted()
logOpenAIWSModeInfo(
"reconnect_budget_exhausted account_id=%d attempts=%d max_retries=%d reason=%s elapsed_ms=%d budget_ms=%d",
account.ID,
attempt,
openAIWSReconnectRetryLimit,
normalizeOpenAIWSLogValue(reason),
time.Since(retryStartedAt).Milliseconds(),
retryBudget.Milliseconds(),
)
break
}
s.recordOpenAIWSRetryAttempt(backoff)
logOpenAIWSModeInfo(
"reconnect_retry account_id=%d retry=%d max_retries=%d reason=%s backoff_ms=%d",
account.ID,
attempt,
openAIWSReconnectRetryLimit,
normalizeOpenAIWSLogValue(reason),
backoff.Milliseconds(),
)
if backoff > 0 {
timer := time.NewTimer(backoff)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
wsErr = wrapOpenAIWSFallback("retry_backoff_canceled", ctx.Err())
break wsRetryLoop
case <-timer.C:
}
}
continue
}
if retryable {
s.recordOpenAIWSRetryExhausted()
logOpenAIWSModeInfo(
"reconnect_exhausted account_id=%d attempts=%d max_retries=%d reason=%s",
account.ID,
attempt,
openAIWSReconnectRetryLimit,
normalizeOpenAIWSLogValue(reason),
)
} else if reason != "" {
s.recordOpenAIWSNonRetryableFastFallback()
logOpenAIWSModeInfo(
"reconnect_stop account_id=%d attempt=%d reason=%s",
account.ID,
attempt,
normalizeOpenAIWSLogValue(reason),
)
}
break
}
if wsErr == nil {
firstTokenMs := int64(0)
hasFirstTokenMs := wsResult != nil && wsResult.FirstTokenMs != nil
if hasFirstTokenMs {
firstTokenMs = int64(*wsResult.FirstTokenMs)
}
requestID := ""
if wsResult != nil {
requestID = strings.TrimSpace(wsResult.RequestID)
}
logOpenAIWSModeDebug(
"forward_succeeded account_id=%d request_id=%s stream=%v has_first_token_ms=%v first_token_ms=%d ws_attempts=%d",
account.ID,
requestID,
reqStream,
hasFirstTokenMs,
firstTokenMs,
wsAttempts,
)
wsResult.UpstreamModel = upstreamModel
if wsResult.BillingModel == "" {
wsResult.BillingModel = billingModel
}
if wsResult.ImageCount > 0 {
wsResult.ImageSize = imageSizeTier
wsResult.ImageInputSize = imageInputSize
wsResult.BillingModel = imageBillingModel
}
return wsResult, nil
}
s.writeOpenAIWSFallbackErrorResponse(c, account, wsErr)
return nil, wsErr
}
reasoningEffort := extractOpenAIReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel)
// 国产模型默认 effort 补充:此处 reqModel 已被 mapping 重写为 billingModel。
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, reqModel)
reasoningEffortValue := ""
if reasoningEffort != nil {
reasoningEffortValue = *reasoningEffort
}
firstOutputTimeout := time.Duration(0)
if reqStream && account.Platform == PlatformOpenAI {
firstOutputTimeout = s.openAIFirstOutputTimeout(reasoningEffortValue)
}
httpInvalidEncryptedContentRetryTried := false
agentTaskRecoveryTried := false
rejectedFieldRetryState := newOpenAIResponsesRejectedFieldRetryState(body)
for {
// Build upstream request
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
var headerGuard *openAIFirstOutputHeaderGuard
if firstOutputTimeout > 0 {
upstreamCtx, headerGuard = newOpenAIFirstOutputHeaderGuard(
upstreamCtx, releaseUpstreamCtx, startTime.Add(firstOutputTimeout),
)
}
upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, reqStream, promptCacheKey, isCodexCLI)
if headerGuard == nil {
releaseUpstreamCtx()
}
if err != nil {
if headerGuard != nil {
headerGuard.close()
}
return nil, err
}
// Get proxy URL
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
// Send request
upstreamStart := time.Now()
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
if headerGuard != nil && headerGuard.stopHeaderWait() {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
headerGuard.close()
return nil, s.newOpenAIFirstOutputTimeoutError(
ctx, c, account, startTime, originalModel, reasoningEffortValue,
firstOutputTimeout, "response_headers", nil,
)
}
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
if headerGuard != nil {
headerGuard.close()
}
// Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to
// a failover so the handler switches to a healthy account, and temporarily
// unschedule the account on durable faults (e.g. rejected proxy credentials).
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
}
if headerGuard != nil {
resp.Body = &openAIRequestContextReadCloser{ReadCloser: resp.Body, cleanup: headerGuard.close}
}
// Handle error response
if resp.StatusCode >= 400 {
respBody := s.readUpstreamErrorBody(resp)
_ = resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(respBody))
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
upstreamCode := extractUpstreamErrorCode(respBody)
if !agentTaskRecoveryTried && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, respBody) {
agentTaskRecoveryTried = true
expectedTaskID := account.GetCredential("task_id")
if err := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); err != nil {
return nil, fmt.Errorf("agent identity task recovery failed: %w", err)
}
continue
}
respBody = s.redactAgentIdentitySensitiveBody(ctx, account, respBody)
resp.Body = io.NopCloser(bytes.NewReader(respBody))
if !httpInvalidEncryptedContentRetryTried && resp.StatusCode == http.StatusBadRequest && upstreamCode == "invalid_encrypted_content" {
decoded, decodeErr := ensureReqBody()
if decodeErr != nil {
return nil, decodeErr
}
if trimOpenAIEncryptedReasoningItems(decoded) {
body, err = marshalOpenAIUpstreamJSON(decoded)
if err != nil {
return nil, fmt.Errorf("serialize invalid_encrypted_content retry body: %w", err)
}
httpInvalidEncryptedContentRetryTried = true
rejectedFieldRetryState.remember(body)
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request once after invalid_encrypted_content (account: %s)", account.Name)
continue
}
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Skip non-WSv2 invalid_encrypted_content retry because encrypted reasoning items are missing (account: %s)", account.Name)
}
if retryBody, reason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, respBody); retryErr != nil {
return nil, fmt.Errorf("normalize rejected Responses field retry body: %w", retryErr)
} else if changed && rejectedFieldRetryState.Allow(retryBody) {
body = retryBody
requestView = newOpenAIRequestView(body)
reqBody = nil
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request after %s (account: %s)", reason, account.Name)
continue
}
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
upstreamDetail := ""
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
if maxBytes <= 0 {
maxBytes = 2048
}
upstreamDetail = truncateString(string(respBody), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
Kind: "failover",
Message: upstreamMsg,
Detail: upstreamDetail,
})
shouldDisable := s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
return nil, newOpenAIUpstreamFailoverError(
resp.StatusCode,
resp.Header,
respBody,
upstreamMsg,
!shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
)
}
return s.handleErrorResponse(ctx, resp, c, account, body, billingModel)
}
defer func() { _ = resp.Body.Close() }()
serviceTier := extractOpenAIServiceTierFromBody(body)
// 上游接受后只保留计费需要的标量,避免响应处理期间继续保活完整 input/tools map。
reqBody = nil
// Handle normal response
var usage *OpenAIUsage
var firstTokenMs *int
responseID := ""
imageCount := 0
searchCount := 0
var imageOutputSizes []string
if reqStream {
streamResult, err := s.handleStreamingResponseWithReasoning(ctx, resp, c, account, startTime, originalModel, upstreamModel, reasoningEffortValue)
if err != nil {
return nil, err
}
usage = streamResult.usage
firstTokenMs = streamResult.firstTokenMs
responseID = strings.TrimSpace(streamResult.responseID)
imageCount = streamResult.imageCount
imageOutputSizes = streamResult.imageOutputSizes
searchCount = streamResult.searchCount
} else {
nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel)
if err != nil {
return nil, err
}
usage = nonStreamResult.usage
responseID = strings.TrimSpace(nonStreamResult.responseID)
imageCount = nonStreamResult.imageCount
imageOutputSizes = nonStreamResult.imageOutputSizes
searchCount = nonStreamResult.searchCount
}
s.bindHTTPResponseAccount(ctx, c, account, responseID)
// Extract and save Codex usage snapshot from response headers (for OAuth accounts).
// 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
if account.Type == AccountTypeOAuth && !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: originalModel,
BillingModel: billingModel,
UpstreamModel: upstreamModel,
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
}
// Grok-native web_search / x_search / tool_search tool invocations (per-1k pricing).
// Token cost still applies separately when usage is present; search is additive only
// when search_price_per_1k is configured (nil price → $0 from CalculateSearchCost).
if searchCount > 0 && account != nil && account.IsGrok() {
forwardResult.SearchCount = searchCount
}
return forwardResult, nil
}
}
func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool {
if account == nil || account.Type != AccountTypeAPIKey {
return false
}
if account.IsCNProvider() {
// CN 的显式协议配置优先于异步探针 Extraadaptive 仅 DeepSeek 有原生
// ResponsesKimi/GLM 回退 Chat Completions。
switch account.GetAPIProtocol() {
case APIProtocolChatCompletions:
return true
case APIProtocolAdaptive:
return account.Platform != PlatformDeepseek
default:
return false
}
}
return !openai_compat.ShouldUseResponsesAPI(account.Extra)
}
func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) {
// Determine target URL based on account type
var targetURL string
switch account.Type {
case AccountTypeOAuth:
// OAuth accounts use ChatGPT internal API
targetURL = chatgptCodexURL
case AccountTypeAPIKey:
// API Key accounts use Platform API or custom base URL
baseURL := account.GetOpenAIBaseURL()
if account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol() {
baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses)
}
if baseURL == "" {
targetURL = openaiPlatformAPIURL
} else {
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
if err != nil {
return nil, err
}
targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL)
}
default:
targetURL = openaiPlatformAPIURL
}
targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c))
// DeepSeek 原生 Responses 端点为无状态实现:强制 store=false、清除
// previous_response_id,避免携带状态字段被上游拒绝。
body = normalizeDeepSeekResponsesRequestBody(account, body)
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
// Build authentication for this request. Agent Identity signs a fresh
// assertion here; OAuth/PAT/API-key keep their existing Bearer behavior.
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)
}
}
// Set headers specific to OAuth accounts (ChatGPT internal API)
if account.Type == AccountTypeOAuth {
// Required: set Host for ChatGPT API (must use req.Host, not Header.Set)
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)
}
}
// Whitelist passthrough headers
for key, values := range c.Request.Header {
lowerKey := strings.ToLower(key)
if openaiAllowedHeaders[lowerKey] {
for _, v := range values {
req.Header.Add(key, v)
}
}
}
// 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号),
// 剥离后再出站——异账号 blob 与本账号的(指纹收敛后)出站身份自相矛盾。
s.guardOpenAICodexTurnStateEcho(c, account, req.Header)
if account.Type == AccountTypeOAuth {
compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body)
// 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。
clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id"))
req.Header.Del("conversation_id")
req.Header.Del("session_id")
if compatMessagesBridge {
req.Header.Del("OpenAI-Beta")
req.Header.Del("originator")
} else {
req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI))
}
apiKeyID := getAPIKeyIDFromContext(c)
if isOpenAIResponsesCompactPath(c) {
req.Header.Set("accept", "application/json")
if req.Header.Get("version") == "" {
req.Header.Set("version", CodexCanonicalClientVersion())
}
compactSession := resolveOpenAICompactSessionID(c)
req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession))
} else {
req.Header.Set("accept", "text/event-stream")
}
if promptCacheKey != "" {
isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey)
req.Header.Set("session_id", isolated)
if !compatMessagesBridge || clientConversationID != "" {
req.Header.Set("conversation_id", isolated)
}
}
} else if isOpenAIResponsesCompactPath(c) {
// compact 上游是 unary JSON 协议:API-key 账号也显式声明 Accept
// 避免 OpenAI 兼容网关按 SSE 返回(#3777 期望行为 4)。
req.Header.Set("accept", "application/json")
}
// Apply custom User-Agent if configured
customUA := account.GetOpenAIUserAgent()
if customUA != "" {
req.Header.Set("user-agent", customUA)
}
// 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为规范 Codex 身份。
// 用于网关未透传/改写 User-Agent 时,仍能命中 Codex 侧识别逻辑。
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
req.Header.Set("user-agent", CodexCanonicalUserAgent())
}
// 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。
applyStagedCodexFingerprintHeaders(c, account, req.Header)
// 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。
// 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。
if account.Type == AccountTypeOAuth {
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
}
// Ensure required headers exist
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", req.Header, body, "not_applicable")
return req, nil
}
// codexIdentityOverrideUA 返回账号级显式配置的出站 User-Agent,供强制统一身份时作为覆写来源。
// ForceCodexCLI 语义是「强制使用 Codex CLI 身份」,等价于使用网关规范身份,故返回空串;
// 该优先级与历史行为一致(ForceCodexCLI 在账号自定义 UA 之后生效)。
func (s *OpenAIGatewayService) codexIdentityOverrideUA(account *Account) string {
if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
return ""
}
return account.GetOpenAIUserAgent()
}