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
1216 lines
47 KiB
Go
1216 lines
47 KiB
Go
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_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()
|
||
}
|
||
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 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek 有原生
|
||
// Responses,Kimi/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()
|
||
}
|