Files
sub2api/backend/internal/service/openai_gateway_forward.go
T
shaw 8a51119e39 fix(openai): pair originator with final User-Agent for codex upstream
上游 /backend-api/codex 的 404 gating 是配套校验:originator 必须与
User-Agent 首段(首个 '/' 前的 client 名)配套且为官方 codex 身份,
错配(如 originator=codex_cli_rs + UA=codex-tui/...)一律 404;version
头若携带须 >= 0.144.0。

修复:
- pkg/openai 新增 PairCodexClientIdentity:由最终出站 UA 推导配套
  originator(含 CODEX_INTERNAL_ORIGINATOR_OVERRIDE 尾部括号组身份恢复、
  精确集合大小写归一、长度/字符集校验)
- service 新增 enforceCodexIdentityHeaders 终态收口:originator 按最终
  UA 重配,推导不出官方身份整体回退默认 Codex CLI 身份;version 低于
  0.144.0 提升为内置版本;originator 缺失时 no-op(保护 messages bridge)
- 接入非透传/透传/WS 三条转发路径(透传与 WS 删除会把 codex-tui 等官方
  UA 强改为 codex_cli_rs 造成错配的旧兜底)、用量探针(指纹缓存 UA 与硬
  编码 originator 错配)、账号 responses/图像测试(opencode 错配)
- messages 兼容桥构建前显式打 bridge 标记,防止映射到非 gpt-5/codex
  模型时收口误改其刻意最小化的请求形态

Fixes #3901
2026-07-10 19:11:26 +08:00

986 lines
37 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
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) {
startTime := time.Now()
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
}
originalBody := body
requestView := newOpenAIRequestView(body)
reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey
originalModel := reqModel
if account.Platform == PlatformGrok {
_ = promptCacheKey
return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime)
}
if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) {
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
}
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()
}
wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account)
clientTransport := GetOpenAIClientTransport(c)
// 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。
wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, clientTransport)
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")
}
passthroughEnabled := account.IsOpenAIPassthroughEnabled()
if passthroughEnabled {
if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip {
strippedBody, changed, stripErr := stripOpenAIImageGenerationToolsFromRawPayload(body)
if stripErr != nil {
return nil, stripErr
}
if changed {
body = strippedBody
originalBody = strippedBody
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, reqModel, 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 && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
var imageIntent bool
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 = IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body)
}
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 {
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 := isOpenAIResponsesCompactPath(c)
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_choice(400 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()
}
if codexResult.NormalizedModel != "" {
upstreamModel = codexResult.NormalizedModel
}
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:
if account.Type == AccountTypeAPIKey {
markPatchDelete("max_output_tokens")
}
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")
}
}
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"} {
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 := ""
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,
)
if wsErr == nil {
break
}
if c != nil && c.Writer != nil && c.Writer.Written() {
break
}
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
}
httpInvalidEncryptedContentRetryTried := false
for {
// Build upstream request
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, reqStream, promptCacheKey, isCodexCLI)
releaseUpstreamCtx()
if err != nil {
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 err != nil {
// 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)
}
// 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 !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
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 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,
})
s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
RetryableOnSameAccount: 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() }()
reasoningEffort := extractOpenAIReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel)
// 国产模型默认 effort 补充:此处 reqModel 已被 mapping 重写为 billingModel(见
// line 2510-2515 的 GetMappedModel + reqModel 赋值),可直接作为 mappedModel。
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, reqModel)
serviceTier := extractOpenAIServiceTierFromBody(body)
// 上游接受后只保留计费需要的标量,避免响应处理期间继续保活完整 input/tools map。
reqBody = nil
// Handle normal response
var usage *OpenAIUsage
var firstTokenMs *int
responseID := ""
imageCount := 0
var imageOutputSizes []string
if reqStream {
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel)
if err != nil {
return nil, err
}
usage = streamResult.usage
firstTokenMs = streamResult.firstTokenMs
responseID = strings.TrimSpace(streamResult.responseID)
imageCount = streamResult.imageCount
imageOutputSizes = streamResult.imageOutputSizes
} 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
}
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,
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 (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 baseURL == "" {
targetURL = openaiPlatformAPIURL
} else {
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
if err != nil {
return nil, err
}
targetURL = buildOpenAIResponsesURL(validatedURL)
}
default:
targetURL = openaiPlatformAPIURL
}
targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c))
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
// Set authentication header
req.Header.Set("authorization", "Bearer "+token)
// 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)
}
}
}
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("OpenAI-Beta", "responses=experimental")
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", codexCLIVersion)
}
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 CLI。
// 用于网关未透传/改写 User-Agent 时,仍能命中 Codex 侧识别逻辑。
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
req.Header.Set("user-agent", codexCLIUserAgent)
}
// 浏览器型 UA 兜底:仅 OAuth(ChatGPT 内部接口)账号生效,若最终 user-agent 仍为浏览器
// (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。
s.overrideBrowserUserAgent(ctx, account, req)
// 终态收口:originator 必须与最终 User-Agent 首段配套且为官方身份,否则上游 404(issue #3901)。
if account.Type == AccountTypeOAuth {
enforceCodexIdentityHeaders(req.Header)
}
// 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)
return req, nil
}
// overrideBrowserUserAgent 检查请求的最终 user-agent,若为浏览器 UA 则替换为后台配置的 Codex UA。
// 用于规避 Cloudflare 对浏览器型 UA 在 ChatGPT 内部接口上的访问质询。
// 影响范围严格限定:仅 OAuth(Codex/ChatGPT 内部接口)账号生效;API Key 等其他账号原样透传。
// 仅在识别为浏览器(Mozilla/...)时改写,其他 CLI/工具 UA 不动。
func (s *OpenAIGatewayService) overrideBrowserUserAgent(ctx context.Context, account *Account, req *http.Request) {
if req == nil || account == nil {
return
}
if account.Type != AccountTypeOAuth {
return
}
currentUA := req.Header.Get("user-agent")
if !openai.IsBrowserUserAgent(currentUA) {
return
}
codexUA := DefaultOpenAICodexUserAgent
if s != nil && s.settingService != nil {
if v := strings.TrimSpace(s.settingService.GetOpenAICodexUserAgent(ctx)); v != "" {
codexUA = v
}
}
req.Header.Set("user-agent", codexUA)
}