mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-07 00:51:19 +00:00
WSv2 egress relays upstream events verbatim without the HTTP-path namespace restore, so flattening requests that take the WSv2 branch would surface flattened tool names the client cannot match. Resolve the WS transport decision before flattening and skip flattening only when the request will actually go WSv2 (passthrough accounts return via HTTP before the WSv2 branch and still flatten). Also check all type assertions in responses_namespace_test.go to satisfy golangci-lint errcheck.
998 lines
37 KiB
Go
998 lines
37 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) {
|
||
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
|
||
}
|
||
wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account)
|
||
// 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。
|
||
wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c))
|
||
passthroughEnabled := account.IsOpenAIPassthroughEnabled()
|
||
if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled) {
|
||
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
|
||
}
|
||
}
|
||
|
||
originalBody := body
|
||
requestView := newOpenAIRequestView(body)
|
||
reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey
|
||
originalModel := reqModel
|
||
|
||
if account.Platform == PlatformGrok {
|
||
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()
|
||
}
|
||
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 {
|
||
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 &&
|
||
!isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) &&
|
||
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)
|
||
}
|