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() }