From 4d23ad4bac38dea348acd2cec10b7b4b009fd7e8 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 7 Jul 2026 23:34:26 +0800 Subject: [PATCH] =?UTF-8?q?refactor(service):=20=E7=BA=AF=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E6=8B=86=E5=88=86=20openai=5Fgateway=5Fservice.go?= =?UTF-8?q?=EF=BC=884872=E2=86=921095=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/openai_gateway_forward.go | 956 +++++ .../service/openai_gateway_request_body.go | 1164 +++++ .../openai_gateway_response_handling.go | 1125 +++++ .../service/openai_gateway_service.go | 3777 ----------------- .../service/openai_gateway_upstream_errors.go | 597 +++ 5 files changed, 3842 insertions(+), 3777 deletions(-) create mode 100644 backend/internal/service/openai_gateway_forward.go create mode 100644 backend/internal/service/openai_gateway_request_body.go create mode 100644 backend/internal/service/openai_gateway_response_handling.go create mode 100644 backend/internal/service/openai_gateway_upstream_errors.go diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go new file mode 100644 index 0000000000..6fdabeb90f --- /dev/null +++ b/backend/internal/service/openai_gateway_forward.go @@ -0,0 +1,956 @@ +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") + } + + originalBody := body + requestView := newOpenAIRequestView(body) + reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey + originalModel := reqModel + + if account.Platform == PlatformGrok { + _ = promptCacheKey + return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime) + } + + if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { + return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) + } + + compatMessagesBridge := isOpenAICompatMessagesBridgeBody(body) + setOpenAICompatMessagesBridgeContext(c, compatMessagesBridge) + + isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI) + wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) + clientTransport := GetOpenAIClientTransport(c) + // 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。 + wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, clientTransport) + if c != nil { + c.Set("openai_ws_transport_decision", string(wsDecision.Transport)) + c.Set("openai_ws_transport_reason", wsDecision.Reason) + } + if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 { + logOpenAIWSModeDebug( + "selected account_id=%d account_type=%s transport=%s reason=%s model=%s stream=%v", + account.ID, + account.Type, + normalizeOpenAIWSLogValue(string(wsDecision.Transport)), + normalizeOpenAIWSLogValue(wsDecision.Reason), + reqModel, + reqStream, + ) + } + // 当前仅支持 WSv2;WSv1 命中时直接返回错误,避免出现“配置可开但行为不确定”。 + if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocket { + if c != nil { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusBadRequest, gin.H{ + "error": gin.H{ + "type": "invalid_request_error", + "message": "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.", + }, + }) + } + return nil, errors.New("openai ws v1 is temporarily unsupported; use ws v2") + } + passthroughEnabled := account.IsOpenAIPassthroughEnabled() + if passthroughEnabled { + // 透传分支只需要轻量提取字段,避免热路径全量 Unmarshal。 + reasoningEffort := extractOpenAIReasoningEffortFromBody(body, reqModel) + // 国产模型默认 effort 补充:也要用 mappedModel 判定是否是 passback-required 上游。 + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, account.GetMappedModel(reqModel)) + 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) + } + codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow + if isCodexCLI { + codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy() + } + codexImageGenerationBridgeEnabled := isCodexCLI && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) + var imageIntent bool + if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + if stripOpenAIImageGenerationTools(decoded) { + markDecodedModified() + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy") + } + imageIntent = IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, decoded) + } else { + imageIntent = IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) + } + if imageIntent && !imageGenerationAllowed { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}}) + return nil, errors.New("image generation disabled for group") + } + + instructions := gjson.GetBytes(body, "instructions") + instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" + if instructionsEmpty && !compatMessagesBridge { + markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) + } + + billingModel := account.GetMappedModel(reqModel) + if billingModel != reqModel { + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, billingModel, account.Name, isCodexCLI) + reqModel = billingModel + markPatchSet("model", billingModel) + } + upstreamModel := billingModel + isCompactRequest := isOpenAIResponsesCompactPath(c) + compactMapped := false + if isCompactRequest { + compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel) + if compactMappedModel != "" && compactMappedModel != billingModel { + compactMapped = true + upstreamModel = compactMappedModel + reqModel = compactMappedModel + markPatchSet("model", compactMappedModel) + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", billingModel, compactMappedModel, account.Name, isCodexCLI) + } + } + if !compactMapped { + modelForNormalize := reqModel + if modelForNormalize == "" { + modelForNormalize = requestView.Model + } + upstreamModel = normalizeOpenAIModelForUpstream(account, modelForNormalize) + if upstreamModel != "" && upstreamModel != modelForNormalize { + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", modelForNormalize, upstreamModel, account.Name, account.Type, isCodexCLI) + reqModel = upstreamModel + markPatchSet("model", upstreamModel) + } + } + if strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) == "minimal" { + markPatchSet("reasoning.effort", "none") + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized reasoning.effort: minimal -> none (account: %s)", account.Name) + } + + imageIntent = imageIntent || IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, nil) || isOpenAIImageGenerationModel(upstreamModel) + if imageIntent && !imageGenerationAllowed { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}}) + return nil, errors.New("image generation disabled for group") + } + + // /responses/compact 是会话压缩请求:上游不接受 tool_choice(400 unknown_parameter), + // 注入 image_generation 工具也没有意义,整块豁免。 + if imageGenerationAllowed && !isCompactRequest && (codexImageGenerationBridgeEnabled || isOpenAIImageGenerationModel(requestView.Model) || openAIRequestBodyImageGenerationToolNeedsNormalization(body) || isOpenAIImageGenerationModel(upstreamModel)) { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationTool(decoded) { + markDecodedModified() + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Injected /responses image_generation tool for Codex client") + } + if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationToolChoiceAuto(decoded) { + markDecodedModified() + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Set /responses image_generation tool_choice=auto for Codex client") + } + if normalizeOpenAIResponsesImageGenerationTools(decoded) { + markDecodedModified() + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image_generation tool payload") + } + if normalizeOpenAIResponsesImageOnlyModel(decoded) { + markDecodedModified() + if model, ok := decoded["model"].(string); ok { + upstreamModel = strings.TrimSpace(model) + } + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image-only model request inbound_model=%s image_model=%s upstream_model=%s", requestView.Model, billingModel, upstreamModel) + } + if err := validateOpenAIResponsesImageModel(decoded, upstreamModel); err != nil { + setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "model"}}) + return nil, err + } + if hasOpenAIImageGenerationTool(decoded) { + imageIntent = true + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type) + } + if codexImageGenerationBridgeEnabled && applyCodexImageGenerationBridgeInstructions(decoded) { + markDecodedModified() + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Added Codex image_generation bridge instructions") + } + } else if imageGenerationAllowed && imageIntent && openAIRequestBodyHasImageGenerationTool(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) && openAIRequestBodyHasImageGenerationTool(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, 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) + } + } + } + + // 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) + + // 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) +} diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go new file mode 100644 index 0000000000..b48b7510eb --- /dev/null +++ b/backend/internal/service/openai_gateway_request_body.go @@ -0,0 +1,1164 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) { + if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled { + normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) + if err != nil { + return "", fmt.Errorf("invalid base_url: %w", err) + } + return normalized, nil + } + normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{ + AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts, + RequireAllowlist: true, + AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts, + }) + if err != nil { + return "", fmt.Errorf("invalid base_url: %w", err) + } + return normalized, nil +} + +// buildOpenAIResponsesURL 组装 OpenAI Responses 端点。 +// - base 以 /v1 结尾:追加 /responses +// - base 以其他版本段结尾(如 /v4):追加 /responses +// - base 已是 /responses:原样返回 +// - 其他情况:追加 /v1/responses +func buildOpenAIResponsesURL(base string) string { + return buildOpenAIEndpointURL(base, "/v1/responses") +} + +func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool { + if len(reqBody) == 0 { + return false + } + + inputValue, has := reqBody["input"] + if !has { + return false + } + + switch input := inputValue.(type) { + case []any: + filtered := input[:0] + changed := false + for _, item := range input { + nextItem, itemChanged, keep := sanitizeEncryptedReasoningInputItem(item) + if itemChanged { + changed = true + } + if !keep { + continue + } + filtered = append(filtered, nextItem) + } + if !changed { + return false + } + if len(filtered) == 0 { + delete(reqBody, "input") + return true + } + reqBody["input"] = filtered + return true + case []map[string]any: + filtered := input[:0] + changed := false + for _, item := range input { + nextItem, itemChanged, keep := sanitizeEncryptedReasoningInputItem(item) + if itemChanged { + changed = true + } + if !keep { + continue + } + nextMap, ok := nextItem.(map[string]any) + if !ok { + filtered = append(filtered, item) + continue + } + filtered = append(filtered, nextMap) + } + if !changed { + return false + } + if len(filtered) == 0 { + delete(reqBody, "input") + return true + } + reqBody["input"] = filtered + return true + case map[string]any: + nextItem, changed, keep := sanitizeEncryptedReasoningInputItem(input) + if !changed { + return false + } + if !keep { + delete(reqBody, "input") + return true + } + nextMap, ok := nextItem.(map[string]any) + if !ok { + return false + } + reqBody["input"] = nextMap + return true + default: + return false + } +} + +func sanitizeEncryptedReasoningInputItem(item any) (next any, changed bool, keep bool) { + inputItem, ok := item.(map[string]any) + if !ok { + return item, false, true + } + + itemType, _ := inputItem["type"].(string) + if strings.TrimSpace(itemType) != "reasoning" { + return item, false, true + } + + _, hasEncryptedContent := inputItem["encrypted_content"] + if !hasEncryptedContent { + return item, false, true + } + + delete(inputItem, "encrypted_content") + if len(inputItem) == 1 { + return nil, true, false + } + return inputItem, true, true +} + +func IsOpenAIResponsesCompactPathForTest(c *gin.Context) bool { + return isOpenAIResponsesCompactPath(c) +} + +func OpenAICompactSessionSeedKeyForTest() string { + return openAICompactSessionSeedKey +} + +func NormalizeOpenAICompactRequestBodyForTest(body []byte) ([]byte, bool, error) { + return normalizeOpenAICompactRequestBody(body) +} + +func isOpenAIResponsesCompactPath(c *gin.Context) bool { + suffix := strings.TrimSpace(openAIResponsesRequestPathSuffix(c)) + return suffix == "/compact" || strings.HasPrefix(suffix, "/compact/") +} + +func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) { + if len(body) == 0 { + return body, false, nil + } + + normalized := []byte(`{}`) + // Keep the current Codex /compact schema while still dropping request-scoped + // fields such as prompt_cache_key, store, and stream. + for _, field := range []string{ + "model", + "input", + "instructions", + "tools", + "parallel_tool_calls", + "reasoning", + "text", + "previous_response_id", + } { + value := gjson.GetBytes(body, field) + if !value.Exists() { + continue + } + next, err := sjson.SetRawBytes(normalized, field, []byte(value.Raw)) + if err != nil { + return body, false, fmt.Errorf("normalize compact body %s: %w", field, err) + } + normalized = next + } + + if bytes.Equal(bytes.TrimSpace(body), bytes.TrimSpace(normalized)) { + return body, false, nil + } + return normalized, true, nil +} + +func resolveOpenAICompactSessionID(c *gin.Context) string { + if c != nil { + if sessionID := strings.TrimSpace(c.GetHeader("session_id")); sessionID != "" { + return sessionID + } + if conversationID := strings.TrimSpace(c.GetHeader("conversation_id")); conversationID != "" { + return conversationID + } + if seed, ok := c.Get(openAICompactSessionSeedKey); ok { + if seedStr, ok := seed.(string); ok && strings.TrimSpace(seedStr) != "" { + return strings.TrimSpace(seedStr) + } + } + } + return uuid.NewString() +} + +func openAIResponsesRequestPathSuffix(c *gin.Context) string { + if c == nil || c.Request == nil || c.Request.URL == nil { + return "" + } + normalizedPath := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/") + if normalizedPath == "" { + return "" + } + idx := strings.LastIndex(normalizedPath, "/responses") + if idx < 0 { + return "" + } + suffix := normalizedPath[idx+len("/responses"):] + if suffix == "" || suffix == "/" { + return "" + } + if !strings.HasPrefix(suffix, "/") { + return "" + } + return suffix +} + +func appendOpenAIResponsesRequestPathSuffix(baseURL, suffix string) string { + trimmedBase := strings.TrimRight(strings.TrimSpace(baseURL), "/") + trimmedSuffix := strings.TrimSpace(suffix) + if trimmedBase == "" || trimmedSuffix == "" { + return trimmedBase + } + return trimmedBase + trimmedSuffix +} + +func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte { + // 使用 gjson/sjson 精确替换 model 字段,避免全量 JSON 反序列化 + if m := gjson.GetBytes(body, "model"); m.Exists() && m.Str == fromModel { + newBody, err := sjson.SetBytes(body, "model", toModel) + if err != nil { + return body + } + return newBody + } + return body +} + +func getOpenAIReasoningEffortFromReqBody(reqBody map[string]any) (value string, present bool) { + if reqBody == nil { + return "", false + } + + // Primary: reasoning.effort + if reasoning, ok := reqBody["reasoning"].(map[string]any); ok { + if effort, ok := reasoning["effort"].(string); ok { + return normalizeOpenAIReasoningEffort(effort), true + } + } + + // Fallback: some clients may use a flat field. + if effort, ok := reqBody["reasoning_effort"].(string); ok { + return normalizeOpenAIReasoningEffort(effort), true + } + + return "", false +} + +func deriveOpenAIReasoningEffortFromModel(model string) string { + if strings.TrimSpace(model) == "" { + return "" + } + + modelID := strings.TrimSpace(model) + if strings.Contains(modelID, "/") { + parts := strings.Split(modelID, "/") + modelID = parts[len(parts)-1] + } + + parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool { + switch r { + case '-', '_', ' ': + return true + default: + return false + } + }) + if len(parts) == 0 { + return "" + } + + return normalizeOpenAIReasoningEffort(parts[len(parts)-1]) +} + +type openAIRequestView struct { + body []byte + Model string + Stream bool + PromptCacheKey string + PreviousResponseID string + ServiceTier string + ReasoningEffort string + patches []openAIRequestPatch + patchesDisabled bool +} + +type openAIRequestPatch struct { + path string + delete bool + value any +} + +func newOpenAIRequestView(body []byte) openAIRequestView { + if len(body) == 0 { + return openAIRequestView{} + } + return openAIRequestView{ + body: body, + Model: strings.TrimSpace(gjson.GetBytes(body, "model").String()), + Stream: gjson.GetBytes(body, "stream").Bool(), + PromptCacheKey: strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()), + PreviousResponseID: strings.TrimSpace(gjson.GetBytes(body, "previous_response_id").String()), + ServiceTier: strings.TrimSpace(gjson.GetBytes(body, "service_tier").String()), + ReasoningEffort: strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()), + } +} + +// Decode 保留阶段一既有 full-map 行为;后续阶段会把调用点下沉到复杂分支。 +func (v openAIRequestView) Decode(c *gin.Context) (map[string]any, error) { + return getOpenAIRequestBodyMap(c, v.body) +} + +func (v *openAIRequestView) MarkPatchSet(path string, value any) { + if v == nil || v.patchesDisabled { + return + } + path = strings.TrimSpace(path) + if !isSimpleOpenAIRequestPatchPath(path) { + v.DisablePatches() + return + } + v.patches = append(v.patches, openAIRequestPatch{path: path, value: value}) +} + +func (v *openAIRequestView) MarkPatchDelete(path string) { + if v == nil || v.patchesDisabled { + return + } + path = strings.TrimSpace(path) + if !isSimpleOpenAIRequestPatchPath(path) { + v.DisablePatches() + return + } + v.patches = append(v.patches, openAIRequestPatch{path: path, delete: true}) +} + +func isSimpleOpenAIRequestPatchPath(path string) bool { + if path == "" || strings.ContainsRune(path, '\\') { + return false + } + for _, part := range strings.Split(path, ".") { + if strings.TrimSpace(part) == "" { + return false + } + } + return true +} + +func (v *openAIRequestView) DisablePatches() { + if v == nil { + return + } + v.patchesDisabled = true + v.patches = nil +} + +func (v openAIRequestView) HasPatches() bool { + return !v.patchesDisabled && len(v.patches) > 0 +} + +func (v openAIRequestView) ApplyPatches() ([]byte, error) { + if v.patchesDisabled || len(v.patches) == 0 { + return nil, errors.New("openai request patches disabled") + } + body := v.body + for _, patch := range v.patches { + var err error + if patch.delete { + body, err = sjson.DeleteBytes(body, patch.path) + } else { + body, err = sjson.SetBytes(body, patch.path, patch.value) + } + if err != nil { + return nil, err + } + } + return body, nil +} + +func setOpenAIRequestMapPath(reqBody map[string]any, path string, value any) { + path = strings.TrimSpace(path) + if reqBody == nil || path == "" { + return + } + parts := strings.Split(path, ".") + current := reqBody + for _, part := range parts[:len(parts)-1] { + part = strings.TrimSpace(part) + if part == "" { + return + } + next, _ := current[part].(map[string]any) + if next == nil { + next = map[string]any{} + current[part] = next + } + current = next + } + last := strings.TrimSpace(parts[len(parts)-1]) + if last != "" { + current[last] = value + } +} + +func deleteOpenAIRequestMapPath(reqBody map[string]any, path string) { + path = strings.TrimSpace(path) + if reqBody == nil || path == "" { + return + } + parts := strings.Split(path, ".") + current := reqBody + for _, part := range parts[:len(parts)-1] { + part = strings.TrimSpace(part) + if part == "" { + return + } + next, _ := current[part].(map[string]any) + if next == nil { + return + } + current = next + } + last := strings.TrimSpace(parts[len(parts)-1]) + if last != "" { + delete(current, last) + } +} + +func extractOpenAIRequestMetaFromBody(body []byte) (model string, stream bool, promptCacheKey string) { + view := newOpenAIRequestView(body) + return view.Model, view.Stream, view.PromptCacheKey +} + +// normalizeOpenAIPassthroughOAuthBody 将透传 OAuth 请求体收敛为旧链路关键行为: +// 1) 删除 ChatGPT internal API 不支持的顶层 Responses 参数 +// 2) store=false 3) 非 compact 保持 stream=true;compact 强制 stream=false +func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, bool, error) { + if len(body) == 0 { + return body, false, nil + } + + normalized := body + changed := false + + for _, field := range openAIChatGPTInternalUnsupportedFields { + if value := gjson.GetBytes(normalized, field); !value.Exists() { + continue + } + next, err := sjson.DeleteBytes(normalized, field) + if err != nil { + return body, false, fmt.Errorf("normalize passthrough body delete %s: %w", field, err) + } + normalized = next + changed = true + } + + if compact { + if store := gjson.GetBytes(normalized, "store"); store.Exists() { + next, err := sjson.DeleteBytes(normalized, "store") + if err != nil { + return body, false, fmt.Errorf("normalize passthrough body delete store: %w", err) + } + normalized = next + changed = true + } + if stream := gjson.GetBytes(normalized, "stream"); stream.Exists() { + next, err := sjson.DeleteBytes(normalized, "stream") + if err != nil { + return body, false, fmt.Errorf("normalize passthrough body delete stream: %w", err) + } + normalized = next + changed = true + } + } else { + if store := gjson.GetBytes(normalized, "store"); !store.Exists() || store.Type != gjson.False { + next, err := sjson.SetBytes(normalized, "store", false) + if err != nil { + return body, false, fmt.Errorf("normalize passthrough body store=false: %w", err) + } + normalized = next + changed = true + } + if stream := gjson.GetBytes(normalized, "stream"); !stream.Exists() || stream.Type != gjson.True { + next, err := sjson.SetBytes(normalized, "stream", true) + if err != nil { + return body, false, fmt.Errorf("normalize passthrough body stream=true: %w", err) + } + normalized = next + changed = true + } + } + + return normalized, changed, nil +} + +func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byte) string { + model := strings.ToLower(strings.TrimSpace(reqModel)) + if !strings.Contains(model, "codex") { + return "" + } + + instructions := gjson.GetBytes(body, "instructions") + if !instructions.Exists() { + return "instructions_missing" + } + if instructions.Type != gjson.String { + return "instructions_not_string" + } + if strings.TrimSpace(instructions.String()) == "" { + return "instructions_empty" + } + return "" +} + +func extractOpenAIReasoningEffortFromBody(body []byte, requestedModel string) *string { + reasoningEffort := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) + if reasoningEffort == "" { + reasoningEffort = strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String()) + } + if reasoningEffort != "" { + normalized := normalizeOpenAIReasoningEffort(reasoningEffort) + if normalized == "" { + return nil + } + return &normalized + } + + value := deriveOpenAIReasoningEffortFromModel(requestedModel) + if value == "" { + return nil + } + return &value +} + +func extractOpenAIServiceTier(reqBody map[string]any) *string { + if reqBody == nil { + return nil + } + raw, ok := reqBody["service_tier"].(string) + if !ok { + return nil + } + return normalizeOpenAIServiceTier(raw) +} + +func extractOpenAIServiceTierFromBody(body []byte) *string { + if len(body) == 0 { + return nil + } + return normalizeOpenAIServiceTier(gjson.GetBytes(body, "service_tier").String()) +} + +func normalizeOpenAIServiceTier(raw string) *string { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + return nil + } + if value == "fast" { + value = "priority" + } + // 放过 OpenAI 官方文档定义的所有合法 tier 值:priority/flex/auto/default/scale。 + // 对 Codex 客户端零影响(Codex 只发 priority 或 flex,见 codex-rs/core/src/client.rs), + // 但能让直连 OpenAI SDK 的用户透传 auto/default/scale 以便抓包/调试。 + // 真未知值仍返回 nil,由 normalizeResponsesBodyServiceTier 从 body 中删除。 + switch value { + case "priority", "flex", "auto", "default", "scale": + return &value + default: + return nil + } +} + +// OpenAIFastBlockedError indicates a request was rejected by the OpenAI fast +// policy (action=block). Mirrors BetaBlockedError on the Claude side. +type OpenAIFastBlockedError struct { + Message string +} + +func (e *OpenAIFastBlockedError) Error() string { return e.Message } + +// evaluateOpenAIFastPolicy returns the action and error message that should be +// applied for a request with the given account/model/service_tier. When the +// policy service is unavailable or no rule matches, it returns +// (BetaPolicyActionPass, "") so callers can short-circuit safely. +// +// Matching rules: +// - Scope filters by account type (all / oauth / apikey / bedrock) +// - ServiceTier must be empty (= any), "all", or equal the normalized tier +// - ModelWhitelist narrows the rule to specific models; FallbackAction +// handles the non-matching case (default: pass) +// +// 与 Claude BetaPolicy 的差异(保留首条匹配 short-circuit): +// - BetaPolicy 处理的是 anthropic-beta header 中的 token 集合,不同 +// 规则可能针对不同 token,filter 需要累加成 set;block 则 first-match。 +// - OpenAI fast policy 操作的是单个字段 service_tier:filter 即删字段, +// 没有可累加的对象。一次请求只携带一个 service_tier,规则的 tier +// 维度天然互斥;同一 (scope, tier) 下若多条规则的 model whitelist +// 发生重叠,admin 可通过规则顺序明确意图。因此采用 first-match 而 +// 非 BetaPolicy 那样的"block 覆盖 filter 覆盖 pass"语义。 +func (s *OpenAIGatewayService) evaluateOpenAIFastPolicy(ctx context.Context, account *Account, model, serviceTier string) (action, errMsg string) { + if s == nil || s.settingService == nil { + return BetaPolicyActionPass, "" + } + tier := strings.ToLower(strings.TrimSpace(serviceTier)) + if tier == "" { + return BetaPolicyActionPass, "" + } + settings := openAIFastPolicySettingsFromContext(ctx) + if settings == nil { + fetched, err := s.settingService.GetOpenAIFastPolicySettings(ctx) + if err != nil || fetched == nil { + return BetaPolicyActionPass, "" + } + settings = fetched + } + return evaluateOpenAIFastPolicyWithSettings(settings, account, model, tier) +} + +// evaluateOpenAIFastPolicyWithSettings is the pure-function core extracted so +// long-lived sessions (e.g. WS) can prefetch settings once and avoid hitting +// the settingService on every frame. See WSSession entry and +// openAIFastPolicySettingsFromContext for the caching glue. +func evaluateOpenAIFastPolicyWithSettings(settings *OpenAIFastPolicySettings, account *Account, model, tier string) (action, errMsg string) { + if settings == nil { + return BetaPolicyActionPass, "" + } + isOAuth := account != nil && account.IsOAuth() + isBedrock := account != nil && account.IsBedrock() + for _, rule := range settings.Rules { + if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) { + continue + } + ruleTier := strings.ToLower(strings.TrimSpace(rule.ServiceTier)) + if ruleTier != "" && ruleTier != OpenAIFastTierAny && ruleTier != tier { + continue + } + eff := BetaPolicyRule{ + Action: rule.Action, + ErrorMessage: rule.ErrorMessage, + ModelWhitelist: rule.ModelWhitelist, + FallbackAction: rule.FallbackAction, + FallbackErrorMessage: rule.FallbackErrorMessage, + } + return resolveRuleAction(eff, model) + } + return BetaPolicyActionPass, "" +} + +// openAIFastPolicyCtxKey 是 context 中预取的 OpenAIFastPolicySettings 缓存 +// 键,仅用于 WebSocket 长会话内多帧复用同一份策略快照,避免每帧 DB 命中。 +// +// Trade-off:策略变更不会影响当前 WS session(只影响新 session)。这是 +// 有意为之 —— 对长会话来说,"策略一致性"比"立刻生效"更重要,且 Claude +// BetaPolicy 的 gin.Context 缓存也是同样取舍。需要 hot-reload 时管理员 +// 可以通过踢断 session 强制刷新。 +type openAIFastPolicyCtxKeyType struct{} + +var openAIFastPolicyCtxKey = openAIFastPolicyCtxKeyType{} + +// withOpenAIFastPolicyContext 将一份 settings 快照绑定到 context,供该 ctx +// 衍生 goroutine 中的 evaluateOpenAIFastPolicy 复用。 +func withOpenAIFastPolicyContext(ctx context.Context, settings *OpenAIFastPolicySettings) context.Context { + if ctx == nil || settings == nil { + return ctx + } + return context.WithValue(ctx, openAIFastPolicyCtxKey, settings) +} + +func openAIFastPolicySettingsFromContext(ctx context.Context) *OpenAIFastPolicySettings { + if ctx == nil { + return nil + } + if v, ok := ctx.Value(openAIFastPolicyCtxKey).(*OpenAIFastPolicySettings); ok { + return v + } + return nil +} + +// applyOpenAIFastPolicyToBody applies the OpenAI fast policy to a raw request +// body. When action=filter it removes the service_tier field; when +// action=block it returns (body, *OpenAIFastBlockedError). On pass it +// normalizes the service_tier value (e.g. client alias "fast" → "priority"). +// action=force_priority rewrites any matched known tier to "priority". +// +// Rationale for normalize-on-pass: chat-completions / messages 入口在调用本 +// 函数之前已经通过 normalizeResponsesBodyServiceTier 把 service_tier 归一化 +// 到了上游可识别值;passthrough(OpenAI 自动透传) / native /responses 等 +// 入口没有这一前置步骤,pass 路径下若不在此处归一化,"fast" 就会被原样 +// 透传到 OpenAI 上游导致 400/拒绝。把归一化收敛到本函数,所有入口行为一致。 +func (s *OpenAIGatewayService) applyOpenAIFastPolicyToBody(ctx context.Context, account *Account, model string, body []byte) ([]byte, error) { + if len(body) == 0 { + return body, nil + } + rawTier := gjson.GetBytes(body, "service_tier").String() + if rawTier == "" { + return body, nil + } + normTier := normalizedOpenAIServiceTierValue(rawTier) + if normTier == "" { + return body, nil + } + action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, model, normTier) + switch action { + case BetaPolicyActionBlock: + msg := errMsg + if msg == "" { + msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, model) + } + return body, &OpenAIFastBlockedError{Message: msg} + case BetaPolicyActionFilter: + trimmed, err := sjson.DeleteBytes(body, "service_tier") + if err != nil { + return body, fmt.Errorf("strip service_tier from body: %w", err) + } + return trimmed, nil + case OpenAIFastPolicyActionForcePriority: + updated, err := sjson.SetBytes(body, "service_tier", OpenAIFastTierPriority) + if err != nil { + return body, fmt.Errorf("force service_tier priority on body: %w", err) + } + return updated, nil + default: + // pass:把别名(如 "fast")写回为规范值("priority")。 + if normTier == rawTier { + return body, nil + } + updated, err := sjson.SetBytes(body, "service_tier", normTier) + if err != nil { + return body, fmt.Errorf("normalize service_tier on pass: %w", err) + } + return updated, nil + } +} + +// writeOpenAIFastPolicyBlockedResponse writes a 403 JSON response for a +// request blocked by the OpenAI fast policy. +func writeOpenAIFastPolicyBlockedResponse(c *gin.Context, err *OpenAIFastBlockedError) { + if c == nil || err == nil { + return + } + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) + c.JSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "type": "permission_error", + "message": err.Message, + }, + }) +} + +// applyOpenAIFastPolicyToWSResponseCreate evaluates the OpenAI fast policy +// against a single client→upstream WebSocket frame whose top-level +// "type"=="response.create". It mirrors the HTTP-side +// applyOpenAIFastPolicyToBody contract but operates on a Realtime/Responses +// WS payload: +// +// - pass: keeps service_tier, normalizing aliases such as "fast" to "priority" +// - filter: returns a copy with top-level service_tier removed +// - force_priority: keeps service_tier and rewrites it to "priority" +// - block: returns (frame, *OpenAIFastBlockedError) +// +// Only frames whose "type" field strictly equals "response.create" are +// inspected/mutated. Any other frame type — including the empty string — +// passes through untouched. The OpenAI Realtime client-event spec requires +// "type" to be set, so an empty type is treated as a malformed frame we do +// not police; the upstream is the source of truth for rejecting it. +// +// service_tier lives at the top level of response.create — same as the +// Responses HTTP body shape (see openai_gateway_chat_completions.go:304 + +// extractOpenAIServiceTierFromBody at line 5593, and the test fixture at +// openai_ws_forwarder_ingress_session_test.go:402). We therefore only need +// to inspect / strip the top-level field; there is no nested form in the +// schema today. +// +// The caller is responsible for choosing the upstream model passed in — +// this helper does not re-derive it. +func (s *OpenAIGatewayService) applyOpenAIFastPolicyToWSResponseCreate( + ctx context.Context, + account *Account, + model string, + frame []byte, +) ([]byte, *OpenAIFastBlockedError, error) { + if len(frame) == 0 { + return frame, nil, nil + } + if !gjson.ValidBytes(frame) { + return frame, nil, nil + } + frameType := strings.TrimSpace(gjson.GetBytes(frame, "type").String()) + // Strict match: only response.create is policy-checked. Empty / other + // types pass through untouched so we never accidentally strip fields + // from response.cancel, conversation.item.create, or any future + // client-event the spec adds. The Realtime spec requires "type" on + // every client event, so an empty type is malformed input — let the + // upstream reject it rather than guessing at our layer. + if frameType != "response.create" { + return frame, nil, nil + } + rawTier := gjson.GetBytes(frame, "service_tier").String() + if rawTier == "" { + return frame, nil, nil + } + normTier := normalizedOpenAIServiceTierValue(rawTier) + if normTier == "" { + return frame, nil, nil + } + action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, model, normTier) + switch action { + case BetaPolicyActionBlock: + msg := errMsg + if msg == "" { + msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, model) + } + return frame, &OpenAIFastBlockedError{Message: msg}, nil + case BetaPolicyActionFilter: + trimmed, err := sjson.DeleteBytes(frame, "service_tier") + if err != nil { + return frame, nil, fmt.Errorf("strip service_tier from ws frame: %w", err) + } + return trimmed, nil, nil + case OpenAIFastPolicyActionForcePriority: + updated, err := sjson.SetBytes(frame, "service_tier", OpenAIFastTierPriority) + if err != nil { + return frame, nil, fmt.Errorf("force service_tier priority in ws frame: %w", err) + } + return updated, nil, nil + default: + if normTier == rawTier { + return frame, nil, nil + } + updated, err := sjson.SetBytes(frame, "service_tier", normTier) + if err != nil { + return frame, nil, fmt.Errorf("normalize service_tier in ws frame: %w", err) + } + return updated, nil, nil + } +} + +// newOpenAIFastPolicyWSEventID returns a Realtime-style event_id for a +// server-emitted error event. Matches the loose "evt_" convention used +// by upstream Realtime servers; the exact value is not load-bearing and is +// only required for client-side log correlation. We reuse the existing +// google/uuid dependency rather than pulling a new one. +func newOpenAIFastPolicyWSEventID() string { + id, err := uuid.NewRandom() + if err != nil { + // Extremely unlikely; fall back to a fixed prefix so the field is + // still non-empty and the schema stays self-consistent. + return "evt_openai_fast_policy" + } + // Strip dashes so it visually matches "evt_" rather than UUID v4 + // canonical form, mirroring what real Realtime traces look like. + return "evt_" + strings.ReplaceAll(id.String(), "-", "") +} + +// buildOpenAIFastPolicyBlockedWSEvent renders an OpenAI Realtime/Responses +// style "error" event payload for a request blocked by the OpenAI fast +// policy. The shape mirrors Realtime error events as observed in upstream +// traces and per the spec's server "error" event: +// +// { +// "event_id": "evt_", +// "type": "error", +// "error": { +// "type": "invalid_request_error", +// "code": "policy_violation", +// "message": "..." +// } +// } +// +// event_id lets clients correlate the rejection in their logs; "code" gives +// programmatic clients a stable identifier (HTTP-side equivalent is the +// 403 permission_error JSON body). +func buildOpenAIFastPolicyBlockedWSEvent(err *OpenAIFastBlockedError) []byte { + if err == nil { + return nil + } + eventID := newOpenAIFastPolicyWSEventID() + payload, mErr := json.Marshal(map[string]any{ + "event_id": eventID, + "type": "error", + "error": map[string]any{ + "type": "invalid_request_error", + "code": "policy_violation", + "message": err.Message, + }, + }) + if mErr != nil { + // Fallback to a minimal hand-rolled payload; Marshal of the literal + // shape above should never fail in practice. + return []byte(`{"event_id":"` + eventID + `","type":"error","error":{"type":"invalid_request_error","code":"policy_violation","message":"openai fast policy blocked this request"}}`) + } + return payload +} + +func openAIRequestBodyMayContainImageInput(body []byte) bool { + if len(body) == 0 { + return false + } + input := gjson.GetBytes(body, "input") + messages := gjson.GetBytes(body, "messages.#-1") + return openAIJSONValueMayContainImageInput(input) || openAIJSONValueMayContainImageInput(messages) +} + +func openAIJSONValueMayContainImageInput(value gjson.Result) bool { + if !value.Exists() { + return false + } + if value.IsArray() { + found := false + value.ForEach(func(_, item gjson.Result) bool { + if openAIJSONValueMayContainImageInput(item) { + found = true + return false + } + return true + }) + return found + } + if value.IsObject() { + if strings.TrimSpace(value.Get("type").String()) == "input_image" || value.Get("image_url").Exists() { + return true + } + return openAIJSONValueMayContainImageInput(value.Get("content")) + } + return false +} + +func openAIRequestBodyMayContainEmptyBase64InputImage(body []byte) bool { + if len(body) == 0 || !openAIRequestBodyMayContainInputImageToken(body) { + return false + } + input := gjson.GetBytes(body, "input") + if !input.Exists() { + return false + } + return openAIJSONValueMayContainEmptyBase64InputImage(input) +} + +func openAIRequestBodyMayContainInputImageToken(body []byte) bool { + if bytes.Contains(body, []byte("input_image")) { + return true + } + // JSON 字符串任意字符都可能被 unicode escape,遇到 \u 时交给 gjson 解码后的结构扫描兜底。 + return bytes.Contains(body, []byte("\\u")) +} + +func openAIJSONValueMayContainEmptyBase64InputImage(value gjson.Result) bool { + if !value.Exists() { + return false + } + if value.IsArray() { + found := false + value.ForEach(func(_, item gjson.Result) bool { + if openAIJSONValueMayContainEmptyBase64InputImage(item) { + found = true + return false + } + return true + }) + return found + } + if value.IsObject() { + if strings.TrimSpace(value.Get("type").String()) == "input_image" && isEmptyBase64DataURI(value.Get("image_url").String()) { + return true + } + return openAIJSONValueMayContainEmptyBase64InputImage(value.Get("content")) + } + return false +} + +func sanitizeEmptyBase64InputImagesInOpenAIBody(body []byte) ([]byte, bool, error) { + if !openAIRequestBodyMayContainEmptyBase64InputImage(body) { + return body, false, nil + } + + var reqBody map[string]any + if err := json.Unmarshal(body, &reqBody); err != nil { + return body, false, fmt.Errorf("sanitize request body: %w", err) + } + if !sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody) { + return body, false, nil + } + normalized, err := marshalOpenAIUpstreamJSON(reqBody) + if err != nil { + return body, false, fmt.Errorf("serialize sanitized request body: %w", err) + } + return normalized, true, nil +} + +func sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody map[string]any) bool { + if reqBody == nil { + return false + } + input, ok := reqBody["input"] + if !ok { + return false + } + normalizedInput, changed := sanitizeEmptyBase64InputImagesInOpenAIInput(input) + if !changed { + return false + } + reqBody["input"] = normalizedInput + return true +} + +func sanitizeEmptyBase64InputImagesInOpenAIInput(input any) (any, bool) { + items, ok := input.([]any) + if !ok { + return input, false + } + + normalizedItems := make([]any, 0, len(items)) + changed := false + for _, item := range items { + itemMap, ok := item.(map[string]any) + if !ok { + normalizedItems = append(normalizedItems, item) + continue + } + if shouldDropEmptyBase64InputImagePart(itemMap) { + changed = true + continue + } + content, ok := itemMap["content"] + if !ok { + normalizedItems = append(normalizedItems, itemMap) + continue + } + parts, ok := content.([]any) + if !ok { + normalizedItems = append(normalizedItems, itemMap) + continue + } + + normalizedParts := make([]any, 0, len(parts)) + itemChanged := false + for _, part := range parts { + if shouldDropEmptyBase64InputImagePart(part) { + changed = true + itemChanged = true + continue + } + normalizedParts = append(normalizedParts, part) + } + if itemChanged { + if len(normalizedParts) == 0 { + continue + } + itemMap["content"] = normalizedParts + } + normalizedItems = append(normalizedItems, itemMap) + } + if !changed { + return input, false + } + return normalizedItems, true +} + +func shouldDropEmptyBase64InputImagePart(part any) bool { + partMap, ok := part.(map[string]any) + if !ok { + return false + } + typeValue, _ := partMap["type"].(string) + if strings.TrimSpace(typeValue) != "input_image" { + return false + } + imageURL, _ := partMap["image_url"].(string) + return isEmptyBase64DataURI(imageURL) +} + +func isEmptyBase64DataURI(raw string) bool { + if !strings.HasPrefix(raw, "data:") { + return false + } + rest := strings.TrimPrefix(raw, "data:") + semicolonIdx := strings.Index(rest, ";") + if semicolonIdx < 0 { + return false + } + rest = rest[semicolonIdx+1:] + if !strings.HasPrefix(rest, "base64,") { + return false + } + return strings.TrimSpace(strings.TrimPrefix(rest, "base64,")) == "" +} + +func getOpenAIRequestBodyMap(_ *gin.Context, body []byte) (map[string]any, error) { + var reqBody map[string]any + if err := json.Unmarshal(body, &reqBody); err != nil { + return nil, fmt.Errorf("parse request: %w", err) + } + return reqBody, nil +} + +func extractOpenAIReasoningEffort(reqBody map[string]any, requestedModel string) *string { + if value, present := getOpenAIReasoningEffortFromReqBody(reqBody); present { + if value == "" { + return nil + } + return &value + } + + value := deriveOpenAIReasoningEffortFromModel(requestedModel) + if value == "" { + return nil + } + return &value +} + +func normalizeOpenAIReasoningEffort(raw string) string { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + return "" + } + + // Normalize separators for "x-high"/"x_high" variants. + value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) + + switch value { + case "none", "minimal": + return "" + case "low", "medium", "high": + return value + case "xhigh", "extrahigh", "max": + return "xhigh" + default: + // Only store known effort levels for now to keep UI consistent. + return "" + } +} diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go new file mode 100644 index 0000000000..d17fe410e0 --- /dev/null +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -0,0 +1,1125 @@ +package service + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// openaiStreamingResult streaming response result +type openaiStreamingResult struct { + usage *OpenAIUsage + firstTokenMs *int + responseID string + imageCount int + imageOutputSizes []string +} + +type openaiNonStreamingResult struct { + *OpenAIUsage + usage *OpenAIUsage + responseID string + imageCount int + imageOutputSizes []string +} + +func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) { + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + } + + // Set SSE response headers + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + // Pass through other headers + if v := resp.Header.Get("x-request-id"); v != "" { + c.Header("x-request-id", v) + } + + w := c.Writer + flusher, ok := w.(http.Flusher) + if !ok { + return nil, errors.New("streaming not supported") + } + bufferedWriter := bufio.NewWriterSize(w, 4*1024) + flushBuffered := func() error { + if err := bufferedWriter.Flush(); err != nil { + return err + } + flusher.Flush() + return nil + } + + usage := &OpenAIUsage{} + imageCounter := newOpenAIImageOutputCounter() + var firstTokenMs *int + responseID := "" + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanBuf := getSSEScannerBuf64K() + scanner.Buffer(scanBuf[:0], maxLineSize) + + streamInterval := time.Duration(0) + if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 { + streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second + } + // 仅监控上游数据间隔超时,不被下游写入阻塞影响 + var intervalTicker *time.Ticker + if streamInterval > 0 { + intervalTicker = time.NewTicker(streamInterval) + defer intervalTicker.Stop() + } + var intervalCh <-chan time.Time + if intervalTicker != nil { + intervalCh = intervalTicker.C + } + + keepaliveInterval := time.Duration(0) + if s.cfg != nil && s.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + // 下游 keepalive 仅用于防止代理空闲断开 + var keepaliveTicker *time.Ticker + if keepaliveInterval > 0 { + keepaliveTicker = time.NewTicker(keepaliveInterval) + defer keepaliveTicker.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTicker != nil { + keepaliveCh = keepaliveTicker.C + } + // Track downstream writes separately from upstream reads: pre-output failover + // can buffer response.created / response.in_progress, so keepalive must be + // based on downstream idle time. + lastDownstreamWriteAt := time.Now() + + // 仅发送一次错误事件,避免多次写入导致协议混乱。 + // 注意:OpenAI `/v1/responses` streaming 事件必须符合 OpenAI Responses schema; + // 否则下游 SDK(例如 OpenCode)会因为类型校验失败而报错。 + errorEventSent := false + clientDisconnected := false // 客户端断开后继续 drain 上游以收集 usage + sawTerminalEvent := false + sawFailedEvent := false + failedMessage := "" + clientOutputStarted := false + upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) + var streamFailoverErr error + sendErrorEvent := func(reason string) { + if errorEventSent || clientDisconnected { + return + } + errorEventSent = true + payload := `{"type":"error","sequence_number":0,"error":{"type":"upstream_error","message":` + strconv.Quote(reason) + `,"code":` + strconv.Quote(reason) + `}}` + if err := flushBuffered(); err != nil { + clientDisconnected = true + return + } + if _, err := bufferedWriter.WriteString("data: " + payload + "\n\n"); err != nil { + clientDisconnected = true + return + } + if err := flushBuffered(); err != nil { + clientDisconnected = true + return + } + clientOutputStarted = true + lastDownstreamWriteAt = time.Now() + } + + needModelReplace := originalModel != mappedModel + streamOutputAccumulator := apicompat.NewBufferedResponseAccumulator() + streamImageOutputs := make([]json.RawMessage, 0, 1) + streamSeenImages := make(map[string]struct{}) + resultWithUsage := func() *openaiStreamingResult { + return &openaiStreamingResult{ + usage: usage, + firstTokenMs: firstTokenMs, + responseID: responseID, + imageCount: imageCounter.Count(), + imageOutputSizes: imageCounter.Sizes(), + } + } + finalizeStream := func() (*openaiStreamingResult, error) { + if !sawTerminalEvent { + if !openAIStreamClientOutputStarted(c, clientOutputStarted) { + return resultWithUsage(), s.newOpenAIStreamFailoverError( + c, + account, + false, + upstreamRequestID, + nil, + "OpenAI stream ended before a terminal event", + ) + } + return resultWithUsage(), fmt.Errorf("stream usage incomplete: missing terminal event") + } + if sawFailedEvent { + return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage) + } + if !clientDisconnected { + hadBufferedData := bufferedWriter.Buffered() > 0 + if err := flushBuffered(); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during final flush, returning collected usage") + } else if hadBufferedData { + clientOutputStarted = true + lastDownstreamWriteAt = time.Now() + } + } + return resultWithUsage(), nil + } + handleScanErr := func(scanErr error) (*openaiStreamingResult, error, bool) { + if scanErr == nil { + return nil, nil, false + } + if sawTerminalEvent && !sawFailedEvent { + logger.LegacyPrintf("service.openai_gateway", "Upstream scan ended after terminal event: %v", scanErr) + return resultWithUsage(), nil, true + } + if sawFailedEvent { + return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage), true + } + // 客户端断开/取消请求时,上游读取往往会返回 context canceled。 + // /v1/responses 的 SSE 事件必须符合 OpenAI 协议;这里不注入自定义 error event,避免下游 SDK 解析失败。 + if errors.Is(scanErr, context.Canceled) || errors.Is(scanErr, context.DeadlineExceeded) { + return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", scanErr), true + } + if errors.Is(scanErr, bufio.ErrTooLong) { + logger.LegacyPrintf("service.openai_gateway", "SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, scanErr) + sendErrorEvent("response_too_large") + return resultWithUsage(), scanErr, true + } + if !openAIStreamClientOutputStarted(c, clientOutputStarted) { + msg := "OpenAI stream disconnected before completion" + if errText := strings.TrimSpace(scanErr.Error()); errText != "" { + msg += ": " + errText + } + return resultWithUsage(), s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, nil, msg), true + } + // 客户端已断开时,上游出错仅影响体验,不影响计费;返回已收集 usage + if clientDisconnected { + return resultWithUsage(), fmt.Errorf("stream usage incomplete after disconnect: %w", scanErr), true + } + sendErrorEvent("stream_read_error") + return resultWithUsage(), fmt.Errorf("stream read error: %w", scanErr), true + } + processSSELine := func(line string, queueDrained bool) { + if streamFailoverErr != nil { + return + } + // Extract data from SSE line (supports both "data: " and "data:" formats) + if data, ok := extractOpenAISSEDataLine(line); ok { + dataBytes := []byte(data) + if openAIStreamEventIsTerminal(data) { + sawTerminalEvent = true + } + eventType := strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) + if responseID == "" { + responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes) + } + forceFlushFailedEvent := false + if eventType == "response.failed" { + failedMessage = extractOpenAISSEErrorMessage(dataBytes) + // response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析 + // 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费 + // 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。 + s.parseSSEUsageBytes(dataBytes, usage) + if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit { + MarkOpsCyberPolicy(c, CyberPolicyMark{ + Code: code, + Message: msg, + Body: truncateString(string(dataBytes), 4096), + UpstreamStatus: http.StatusOK, + UpstreamInTok: usage.InputTokens, + UpstreamOutTok: usage.OutputTokens, + }) + } else if !openAIStreamClientOutputStarted(c, clientOutputStarted) && openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) { + sawFailedEvent = true + streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, failedMessage) + return + } + forceFlushFailedEvent = true + sawFailedEvent = true + } + imageCounter.AddSSEData(dataBytes) + + // Correct Codex tool calls if needed (apply_patch -> edit, etc.) + if correctedData, corrected := s.toolCorrector.CorrectToolCallsInSSEBytes(dataBytes); corrected { + dataBytes = correctedData + data = string(correctedData) + line = "data: " + data + eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) + } + if imageOutput, ok := extractImageGenerationOutputFromSSEData(dataBytes, streamSeenImages); ok { + streamImageOutputs = append(streamImageOutputs, imageOutput) + } + if responsesStreamEventMayContributeToOutput(eventType) { + var streamEvent apicompat.ResponsesStreamEvent + if err := json.Unmarshal(dataBytes, &streamEvent); err == nil { + streamOutputAccumulator.ProcessEvent(&streamEvent) + } + } + if normalizedData, normalized := normalizeResponsesStreamingTerminalOutput(dataBytes, streamOutputAccumulator, streamImageOutputs); normalized { + dataBytes = normalizedData + data = string(normalizedData) + line = "data: " + data + eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) + } + if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient(dataBytes, eventType); sanitized { + dataBytes = sanitizedData + data = string(sanitizedData) + line = "data: " + data + } + // Replace model in response if needed. + // Fast path: most events do not contain model field values. + if needModelReplace && mappedModel != "" && strings.Contains(line, mappedModel) { + line = s.replaceModelInSSELine(line, mappedModel, originalModel) + } + startsClientOutput := forceFlushFailedEvent || openAIStreamDataStartsClientOutput(data, eventType) + + // 写入客户端(客户端断开后继续 drain 上游) + if !clientDisconnected { + shouldFlush := queueDrained && (clientOutputStarted || startsClientOutput) + if firstTokenMs == nil && startsClientOutput { + // 保证首个 token 事件尽快出站,避免影响 TTFT。 + shouldFlush = true + } + if _, err := bufferedWriter.WriteString(line); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") + } else if _, err := bufferedWriter.WriteString("\n"); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") + } else if shouldFlush { + if err := flushBuffered(); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming flush, continuing to drain upstream for billing") + } else { + clientOutputStarted = true + lastDownstreamWriteAt = time.Now() + } + } + } + + // Record first token time + if firstTokenMs == nil && startsClientOutput { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + s.parseSSEUsageBytes(dataBytes, usage) + return + } + + // Forward non-data lines as-is + if !clientDisconnected { + if _, err := bufferedWriter.WriteString(line); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") + } else if _, err := bufferedWriter.WriteString("\n"); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") + } else if queueDrained && clientOutputStarted { + if err := flushBuffered(); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming flush, continuing to drain upstream for billing") + } else { + clientOutputStarted = true + lastDownstreamWriteAt = time.Now() + } + } + } + } + + // 无超时/无 keepalive 的常见路径走同步扫描,减少 goroutine 与 channel 开销。 + if streamInterval <= 0 && keepaliveInterval <= 0 { + defer putSSEScannerBuf64K(scanBuf) + for scanner.Scan() { + processSSELine(scanner.Text(), true) + if streamFailoverErr != nil { + return resultWithUsage(), streamFailoverErr + } + } + if result, err, done := handleScanErr(scanner.Err()); done { + return result, err + } + return finalizeStream() + } + + type scanEvent struct { + line string + err error + } + // 独立 goroutine 读取上游,避免读取阻塞影响 keepalive/超时处理 + events := make(chan scanEvent, 16) + done := make(chan struct{}) + sendEvent := func(ev scanEvent) bool { + select { + case events <- ev: + return true + case <-done: + return false + } + } + var lastReadAt int64 + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + go func(scanBuf *sseScannerBuf64K) { + defer putSSEScannerBuf64K(scanBuf) + defer close(events) + for scanner.Scan() { + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + if !sendEvent(scanEvent{line: scanner.Text()}) { + return + } + } + if err := scanner.Err(); err != nil { + _ = sendEvent(scanEvent{err: err}) + } + }(scanBuf) + defer close(done) + + for { + select { + case ev, ok := <-events: + if !ok { + return finalizeStream() + } + if result, err, done := handleScanErr(ev.err); done { + return result, err + } + processSSELine(ev.line, len(events) == 0) + if streamFailoverErr != nil { + return resultWithUsage(), streamFailoverErr + } + + case <-intervalCh: + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) < streamInterval { + continue + } + if clientDisconnected { + return resultWithUsage(), fmt.Errorf("stream usage incomplete after timeout") + } + logger.LegacyPrintf("service.openai_gateway", "Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval) + // 处理流超时,可能标记账户为临时不可调度或错误状态 + if s.rateLimitService != nil { + s.rateLimitService.HandleStreamTimeout(ctx, account, originalModel) + } + sendErrorEvent("stream_timeout") + return resultWithUsage(), fmt.Errorf("stream data interval timeout") + + case <-keepaliveCh: + if clientDisconnected { + continue + } + if time.Since(lastDownstreamWriteAt) < keepaliveInterval { + continue + } + if _, err := bufferedWriter.WriteString(":\n\n"); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") + continue + } + if err := flushBuffered(); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during keepalive flush, continuing to drain upstream for billing") + } else { + lastDownstreamWriteAt = time.Now() + } + } + } + +} + +// extractOpenAISSEDataLine 低开销提取 SSE `data:` 行内容。 +// 兼容 `data: xxx` 与 `data:xxx` 两种格式。 +func extractOpenAISSEDataLine(line string) (string, bool) { + if !strings.HasPrefix(line, "data:") { + return "", false + } + start := len("data:") + for start < len(line) { + if line[start] != ' ' && line[start] != ' ' { + break + } + start++ + } + return line[start:], true +} + +func extractOpenAISSEEventLine(line string) (string, bool) { + if !strings.HasPrefix(line, "event:") { + return "", false + } + start := len("event:") + for start < len(line) { + if line[start] != ' ' && line[start] != ' ' { + break + } + start++ + } + return strings.TrimSpace(line[start:]), true +} + +type openAICompatSSEFrame struct { + EventType string + Data string +} + +type openAICompatSSEFrameParser struct { + eventType string + dataLines []string +} + +func (p *openAICompatSSEFrameParser) AddLine(line string) (openAICompatSSEFrame, bool) { + if line == "" { + return p.dispatch() + } + if strings.HasPrefix(line, ":") { + return openAICompatSSEFrame{}, false + } + if eventType, ok := extractOpenAISSEEventLine(line); ok { + p.eventType = eventType + return openAICompatSSEFrame{}, false + } + if data, ok := extractOpenAISSEDataLine(line); ok { + p.dataLines = append(p.dataLines, data) + } + return openAICompatSSEFrame{}, false +} + +func (p *openAICompatSSEFrameParser) Finish() (openAICompatSSEFrame, bool) { + return p.dispatch() +} + +func (p *openAICompatSSEFrameParser) dispatch() (openAICompatSSEFrame, bool) { + frame := openAICompatSSEFrame{ + EventType: p.eventType, + Data: strings.Join(p.dataLines, "\n"), + } + p.eventType = "" + p.dataLines = nil + return frame, frame.Data != "" +} + +func openAICompatPayloadWithEventType(payload, eventType string) string { + eventType = strings.TrimSpace(eventType) + if eventType == "" || strings.TrimSpace(payload) == "" || strings.TrimSpace(payload) == "[DONE]" { + return payload + } + if gjson.Get(payload, "type").Exists() { + return payload + } + patched, err := sjson.Set(payload, "type", eventType) + if err != nil { + return payload + } + return patched +} + +func (s *OpenAIGatewayService) replaceModelInSSELine(line, fromModel, toModel string) string { + data, ok := extractOpenAISSEDataLine(line) + if !ok { + return line + } + if data == "" || data == "[DONE]" { + return line + } + + // 使用 gjson 精确检查 model 字段,避免全量 JSON 反序列化 + if m := gjson.Get(data, "model"); m.Exists() && m.Str == fromModel { + newData, err := sjson.Set(data, "model", toModel) + if err != nil { + return line + } + return "data: " + newData + } + + // 检查嵌套的 response.model 字段 + if m := gjson.Get(data, "response.model"); m.Exists() && m.Str == fromModel { + newData, err := sjson.Set(data, "response.model", toModel) + if err != nil { + return line + } + return "data: " + newData + } + + return line +} + +// correctToolCallsInResponseBody 修正响应体中的工具调用 +func (s *OpenAIGatewayService) correctToolCallsInResponseBody(body []byte) []byte { + if len(body) == 0 { + return body + } + + updated := body + if s != nil && s.toolCorrector != nil { + if corrected, changed := s.toolCorrector.CorrectToolCallsInSSEBytes(updated); changed { + updated = corrected + } + } + if normalized, changed := normalizeOpenAIResponsesFunctionCallArguments(updated); changed { + updated = normalized + } + return updated +} + +func normalizeOpenAIResponsesFunctionCallArguments(data []byte) ([]byte, bool) { + if len(bytes.TrimSpace(data)) == 0 || !bytes.Contains(data, []byte(`"arguments"`)) { + return data, false + } + if !gjson.ValidBytes(data) { + return data, false + } + + updated := data + changed := false + setDedupedArgument := func(path string) { + arg := gjson.GetBytes(updated, path) + if !arg.Exists() || arg.Type != gjson.String { + return + } + deduped, ok := dedupeRepeatedJSONArgumentString(arg.Str) + if !ok { + return + } + next, err := sjson.SetBytes(updated, path, deduped) + if err != nil { + return + } + updated = next + changed = true + } + + eventType := strings.TrimSpace(gjson.GetBytes(updated, "type").String()) + if eventType == "response.function_call_arguments.done" { + setDedupedArgument("arguments") + } + if itemType := strings.TrimSpace(gjson.GetBytes(updated, "item.type").String()); isResponsesFunctionCallItemType(itemType) { + setDedupedArgument("item.arguments") + } + dedupeResponsesFunctionCallOutputArguments(updated, "response.output", setDedupedArgument) + dedupeResponsesFunctionCallOutputArguments(updated, "output", setDedupedArgument) + + return updated, changed +} + +func dedupeResponsesFunctionCallOutputArguments(data []byte, outputPath string, setDedupedArgument func(string)) { + output := gjson.GetBytes(data, outputPath) + if !output.Exists() || !output.IsArray() { + return + } + for i, item := range output.Array() { + if !isResponsesFunctionCallItemType(strings.TrimSpace(item.Get("type").String())) { + continue + } + setDedupedArgument(outputPath + "." + strconv.Itoa(i) + ".arguments") + } +} + +func isResponsesFunctionCallItemType(itemType string) bool { + return itemType == "function_call" || itemType == "custom_tool_call" +} + +func dedupeRepeatedJSONArgumentString(arguments string) (string, bool) { + if len(arguments) == 0 || len(arguments)%2 != 0 { + return "", false + } + halfLen := len(arguments) / 2 + first := arguments[:halfLen] + if first != arguments[halfLen:] { + return "", false + } + trimmed := strings.TrimSpace(first) + if trimmed == "" || (!strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[")) { + return "", false + } + if !json.Valid([]byte(first)) { + return "", false + } + return first, true +} + +func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) { + s.parseSSEUsageBytes([]byte(data), usage) +} + +func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsage) { + if usage == nil || len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { + return + } + // 选择性解析:仅在数据中包含终止事件标识时才进入字段提取。 + if len(data) < 72 { + return + } + eventType := gjson.GetBytes(data, "type").String() + if eventType != "response.completed" && eventType != "response.done" && eventType != "response.failed" && + eventType != "response.incomplete" && eventType != "response.cancelled" && eventType != "response.canceled" { + return + } + + if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(data); ok { + *usage = parsedUsage + } +} + +func extractOpenAIUsageFromJSONBytes(body []byte) (OpenAIUsage, bool) { + if len(body) == 0 || !gjson.ValidBytes(body) { + return OpenAIUsage{}, false + } + if usage, ok := openAIUsageFromGJSON(gjson.GetBytes(body, "usage")); ok { + return usage, true + } + return openAIUsageFromGJSON(gjson.GetBytes(body, "response.usage")) +} + +func extractOpenAIResponseIDFromJSONBytes(body []byte) string { + if len(body) == 0 || !gjson.ValidBytes(body) { + return "" + } + if id := strings.TrimSpace(gjson.GetBytes(body, "id").String()); id != "" { + return id + } + return strings.TrimSpace(gjson.GetBytes(body, "response.id").String()) +} + +func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *gin.Context, account *Account, responseID string) { + if s == nil || account == nil || account.ID <= 0 { + return + } + responseID = strings.TrimSpace(responseID) + if responseID == "" { + return + } + store := s.getOpenAIWSStateStore() + if store == nil { + return + } + groupID := getOpenAIGroupIDFromContext(c) + ttl := s.openAIWSResponseStickyTTL() + logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) +} + +func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { + if !value.Exists() || !value.IsObject() { + return OpenAIUsage{}, false + } + inputTokens := value.Get("input_tokens").Int() + if inputTokens == 0 { + inputTokens = value.Get("prompt_tokens").Int() + } + outputTokens := value.Get("output_tokens").Int() + if outputTokens == 0 { + outputTokens = value.Get("completion_tokens").Int() + } + cacheReadTokens := value.Get("input_tokens_details.cached_tokens").Int() + if cacheReadTokens == 0 { + cacheReadTokens = value.Get("prompt_tokens_details.cached_tokens").Int() + } + imageOutputTokens := value.Get("output_tokens_details.image_tokens").Int() + if imageOutputTokens == 0 { + imageOutputTokens = value.Get("completion_tokens_details.image_tokens").Int() + } + return OpenAIUsage{ + InputTokens: int(inputTokens), + OutputTokens: int(outputTokens), + CacheCreationInputTokens: int(value.Get("cache_creation_input_tokens").Int()), + CacheReadInputTokens: int(cacheReadTokens), + ImageOutputTokens: int(imageOutputTokens), + }, true +} + +func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { + body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) + if err != nil { + return nil, err + } + + // Detect SSE responses for ALL account types via Content-Type header. + // Some OpenAI-compatible upstreams (including other sub2api instances) + // may return SSE even when stream=false was requested. + if isEventStreamResponse(resp.Header) { + return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) + } + // bodyLooksLikeSSE is a line-level heuristic: real SSE framing requires + // "data:"/"event:" field names at the very start of a physical line. A + // plain bytes.Contains scan would also match ordinary JSON responses + // whose string content merely echoes the literal text "data:" or + // "event:" (e.g. compact tool output), causing those JSON bodies to be + // misrouted into handleSSEToJSON and lose their usage accounting. + bodyLooksLikeSSE := bodyHasSSEFraming(body) + + // For OAuth accounts, also fall back to a body-content heuristic because + // the upstream may omit the Content-Type header while still sending SSE. + // This heuristic is NOT applied to API-key accounts to avoid false + // positives on JSON responses that coincidentally contain "data:" or + // "event:" in their text content. + if account.Type == AccountTypeOAuth && bodyLooksLikeSSE { + return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) + } + + usageValue, usageOK := extractOpenAIUsageFromJSONBytes(body) + if !usageOK { + if bodyLooksLikeSSE { + return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) + } + return nil, fmt.Errorf("parse response: invalid json response") + } + usage := &usageValue + + // Replace model in response if needed + if originalModel != mappedModel { + body = s.replaceModelInResponseBody(body, mappedModel, originalModel) + } + + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + + contentType := "application/json" + if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled { + if upstreamType := resp.Header.Get("Content-Type"); upstreamType != "" { + contentType = upstreamType + } + } + + c.Data(resp.StatusCode, contentType, body) + + return &openaiNonStreamingResult{ + OpenAIUsage: usage, + usage: usage, + responseID: extractOpenAIResponseIDFromJSONBytes(body), + imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body), + imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body), + }, nil +} + +func isEventStreamResponse(header http.Header) bool { + contentType := strings.ToLower(header.Get("Content-Type")) + return strings.Contains(contentType, "text/event-stream") +} + +// bodyHasSSEFraming reports whether body contains genuine SSE framing by +// scanning for physical lines that begin with the "data:" or "event:" +// field names, per the SSE spec. Unlike a raw substring scan, this does not +// match when those strings only appear embedded inside JSON string values +// (e.g. "data: foo" quoted as part of an assistant text field), since such +// occurrences never start a physical line in a valid JSON encoding. +func bodyHasSSEFraming(body []byte) bool { + for _, line := range bytes.Split(body, []byte("\n")) { + line = bytes.TrimRight(line, "\r") + if bytes.HasPrefix(line, []byte("data:")) || bytes.HasPrefix(line, []byte("event:")) { + return true + } + } + return false +} + +func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { + bodyText := string(body) + finalResponse, ok := extractCodexFinalResponse(bodyText) + + usage := &OpenAIUsage{} + if ok { + if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed { + *usage = parsedUsage + } + // When the terminal event has an empty output array, reconstruct + // output from accumulated delta events so the client gets full content. + // gjson Array() returns empty slice for null, missing, or empty arrays. + if len(gjson.GetBytes(finalResponse, "output").Array()) == 0 { + if outputJSON, reconstructed := reconstructResponseOutputFromSSE(bodyText); reconstructed { + if patched, err := sjson.SetRawBytes(finalResponse, "output", outputJSON); err == nil { + finalResponse = patched + } + } + } + body = finalResponse + if originalModel != mappedModel { + body = s.replaceModelInResponseBody(body, mappedModel, originalModel) + } + // Correct tool calls in final response + body = s.correctToolCallsInResponseBody(body) + } else { + terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText) + if terminalOK && terminalType == "response.failed" { + msg := extractOpenAISSEErrorMessage(terminalPayload) + if msg == "" { + msg = "Upstream compact response failed" + } + return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg) + } + usage = s.parseSSEUsageFromBody(bodyText) + if originalModel != mappedModel { + bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel) + } + body = []byte(bodyText) + } + + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + + contentType := "application/json; charset=utf-8" + if !ok { + contentType = resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "text/event-stream" + } + } + c.Data(resp.StatusCode, contentType, body) + + return &openaiNonStreamingResult{ + OpenAIUsage: usage, + usage: usage, + responseID: extractOpenAIResponseIDFromJSONBytes(body), + imageCount: countOpenAIImageOutputsFromSSEBody(bodyText), + imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText), + }, nil +} + +func extractOpenAISSETerminalEvent(body string) (string, []byte, bool) { + var terminalType string + var terminalPayload []byte + forEachOpenAISSEDataPayload(body, func(data []byte) { + if terminalPayload != nil { + return + } + eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) + switch eventType { + case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + terminalType = eventType + terminalPayload = append([]byte(nil), data...) + } + }) + if terminalPayload != nil { + return terminalType, terminalPayload, true + } + return "", nil, false +} + +func extractOpenAISSEErrorMessage(payload []byte) string { + if len(payload) == 0 { + return "" + } + for _, path := range []string{"response.error.message", "error.message", "message"} { + if msg := strings.TrimSpace(gjson.GetBytes(payload, path).String()); msg != "" { + return sanitizeUpstreamErrorMessage(msg) + } + } + return sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(payload))) +} + +func sanitizeOpenAIResponseFailedEventForClient(payload []byte, eventType string) ([]byte, bool) { + if eventType != "response.failed" || len(payload) == 0 || !gjson.ValidBytes(payload) { + return payload, false + } + if !gjson.GetBytes(payload, "response").Exists() { + return payload, false + } + updated := payload + for _, path := range []string{ + "response.instructions", + "response.output", + "response.usage", + "response.metadata", + "response.reasoning", + "response.tools", + "response.tool_choice", + "response.parallel_tool_calls", + "response.text", + "response.truncation", + "response.max_output_tokens", + "response.incomplete_details", + } { + next, err := sjson.DeleteBytes(updated, path) + if err != nil { + return payload, false + } + updated = next + } + return updated, !bytes.Equal(updated, payload) +} + +func (s *OpenAIGatewayService) writeOpenAINonStreamingProtocolError(resp *http.Response, c *gin.Context, message string) error { + message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message)) + if message == "" { + message = "Upstream returned an invalid non-streaming response" + } + setOpsUpstreamError(c, http.StatusBadGateway, message, "") + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + c.JSON(http.StatusBadGateway, gin.H{ + "error": gin.H{ + "type": "upstream_error", + "message": message, + }, + }) + return fmt.Errorf("non-streaming openai protocol error: %s", message) +} + +func extractCodexFinalResponse(body string) ([]byte, bool) { + var finalResponse []byte + forEachOpenAISSEDataPayload(body, func(data []byte) { + if finalResponse != nil { + return + } + eventType := gjson.GetBytes(data, "type").String() + if eventType == "response.done" || eventType == "response.completed" { + if response := gjson.GetBytes(data, "response"); response.Exists() && response.Type == gjson.JSON && response.Raw != "" { + finalResponse = []byte(response.Raw) + } + } + }) + if finalResponse != nil { + return finalResponse, true + } + return nil, false +} + +func normalizeResponsesStreamingTerminalOutput(data []byte, acc *apicompat.BufferedResponseAccumulator, imageOutputs []json.RawMessage) ([]byte, bool) { + eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) + switch eventType { + case "response.completed", "response.done", "response.incomplete", "response.cancelled", "response.canceled": + default: + return data, false + } + + output := gjson.GetBytes(data, "response.output") + hasAccumulatedOutput := (acc != nil && acc.HasContent()) || len(imageOutputs) > 0 + if output.Exists() && output.IsArray() { + if len(output.Array()) > 0 || !hasAccumulatedOutput { + return data, false + } + } + + outputJSON := []byte("[]") + if reconstructed, ok := buildResponsesOutputJSON(acc, imageOutputs); ok { + outputJSON = reconstructed + } + updated, err := sjson.SetRawBytes(data, "response.output", outputJSON) + if err != nil { + return data, false + } + return updated, true +} + +func responsesStreamEventMayContributeToOutput(eventType string) bool { + switch eventType { + case "response.output_text.delta", + "response.output_item.added", + "response.function_call_arguments.delta", + "response.reasoning_summary_text.delta": + return true + default: + return false + } +} + +// reconstructResponseOutputFromSSE scans raw SSE body text for delta events and +// returns a JSON-encoded output array reconstructed from accumulated deltas. +// Returns (nil, false) if no content was found in deltas. +func reconstructResponseOutputFromSSE(bodyText string) ([]byte, bool) { + acc := apicompat.NewBufferedResponseAccumulator() + imageOutputs := make([]json.RawMessage, 0, 1) + seenImages := make(map[string]struct{}) + forEachOpenAISSEDataPayload(bodyText, func(data []byte) { + if imageOutput, ok := extractImageGenerationOutputFromSSEData(data, seenImages); ok { + imageOutputs = append(imageOutputs, imageOutput) + } + eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) + if responsesStreamEventMayContributeToOutput(eventType) { + var event apicompat.ResponsesStreamEvent + if err := json.Unmarshal(data, &event); err == nil { + acc.ProcessEvent(&event) + } + } + }) + return buildResponsesOutputJSON(acc, imageOutputs) +} + +func buildResponsesOutputJSON(acc *apicompat.BufferedResponseAccumulator, imageOutputs []json.RawMessage) ([]byte, bool) { + if (acc == nil || !acc.HasContent()) && len(imageOutputs) == 0 { + return nil, false + } + var output []json.RawMessage + if acc != nil && acc.HasContent() { + outputJSON, err := json.Marshal(acc.BuildOutput()) + if err == nil { + _ = json.Unmarshal(outputJSON, &output) + } + } + output = append(output, imageOutputs...) + if len(output) == 0 { + return nil, false + } + + outputJSON, err := json.Marshal(output) + if err != nil { + return nil, false + } + return outputJSON, true +} + +func extractImageGenerationOutputFromSSEData(data []byte, seen map[string]struct{}) (json.RawMessage, bool) { + if len(data) == 0 || !gjson.ValidBytes(data) { + return nil, false + } + if gjson.GetBytes(data, "type").String() != "response.output_item.done" { + return nil, false + } + item := gjson.GetBytes(data, "item") + if !item.Exists() || !item.IsObject() || item.Get("type").String() != "image_generation_call" { + return nil, false + } + if strings.TrimSpace(item.Get("result").String()) == "" { + return nil, false + } + key := strings.TrimSpace(item.Get("id").String()) + if key == "" { + key = strings.TrimSpace(item.Get("output_format").String()) + "|" + strings.TrimSpace(item.Get("result").String()) + } + if key != "" && seen != nil { + if _, exists := seen[key]; exists { + return nil, false + } + seen[key] = struct{}{} + } + return json.RawMessage(item.Raw), true +} + +func (s *OpenAIGatewayService) parseSSEUsageFromBody(body string) *OpenAIUsage { + usage := &OpenAIUsage{} + forEachOpenAISSEDataPayload(body, func(data []byte) { + s.parseSSEUsageBytes(data, usage) + }) + return usage +} + +func (s *OpenAIGatewayService) replaceModelInSSEBody(body, fromModel, toModel string) string { + lines := strings.Split(body, "\n") + for i, line := range lines { + if _, ok := extractOpenAISSEDataLine(line); !ok { + continue + } + lines[i] = s.replaceModelInSSELine(line, fromModel, toModel) + } + return strings.Join(lines, "\n") +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 935f4d9c45..c1d1670e07 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1,37 +1,27 @@ package service import ( - "bufio" - "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" - "io" "log/slog" "math/rand" "net/http" - "strconv" "strings" "sync" "sync/atomic" "time" "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "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/Wei-Shaw/sub2api/internal/util/responseheaders" - "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" "github.com/cespare/xxhash/v2" "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" "go.uber.org/zap" ) @@ -1047,198 +1037,6 @@ func hashSensitiveValueForLog(raw string) string { return hex.EncodeToString(sum[:8]) } -func logOpenAIInstructionsRequiredDebug( - ctx context.Context, - c *gin.Context, - account *Account, - upstreamStatusCode int, - upstreamMsg string, - requestBody []byte, - upstreamBody []byte, -) { - msg := strings.TrimSpace(upstreamMsg) - if !isOpenAIInstructionsRequiredError(upstreamStatusCode, msg, upstreamBody) { - return - } - if ctx == nil { - ctx = context.Background() - } - - accountID := int64(0) - accountName := "" - if account != nil { - accountID = account.ID - accountName = strings.TrimSpace(account.Name) - } - - userAgent := "" - originator := "" - if c != nil { - userAgent = strings.TrimSpace(c.GetHeader("User-Agent")) - originator = strings.TrimSpace(c.GetHeader("originator")) - } - - fields := []zap.Field{ - zap.String("component", "service.openai_gateway"), - zap.Int64("account_id", accountID), - zap.String("account_name", accountName), - zap.Int("upstream_status_code", upstreamStatusCode), - zap.String("upstream_error_message", msg), - zap.String("request_user_agent", userAgent), - zap.Bool("codex_official_client_match", openai.IsCodexOfficialClientByHeaders(userAgent, originator)), - } - fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, requestBody) - - logger.FromContext(ctx).With(fields...).Warn("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查") -} - -func isOpenAIInstructionsRequiredError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool { - if upstreamStatusCode != http.StatusBadRequest { - return false - } - - hasInstructionRequired := func(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - if strings.Contains(lower, "instructions are required") { - return true - } - if strings.Contains(lower, "required parameter: 'instructions'") { - return true - } - if strings.Contains(lower, "required parameter: instructions") { - return true - } - if strings.Contains(lower, "missing required parameter") && strings.Contains(lower, "instructions") { - return true - } - return strings.Contains(lower, "instruction") && strings.Contains(lower, "required") - } - - if hasInstructionRequired(upstreamMsg) { - return true - } - if len(upstreamBody) == 0 { - return false - } - - errMsg := gjson.GetBytes(upstreamBody, "error.message").String() - errMsgLower := strings.ToLower(strings.TrimSpace(errMsg)) - errCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.code").String())) - errParam := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.param").String())) - errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.type").String())) - - if errParam == "instructions" { - return true - } - if hasInstructionRequired(errMsg) { - return true - } - if strings.Contains(errCode, "missing_required_parameter") && strings.Contains(errMsgLower, "instructions") { - return true - } - if strings.Contains(errType, "invalid_request") && strings.Contains(errMsgLower, "instructions") && strings.Contains(errMsgLower, "required") { - return true - } - - return false -} - -func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool { - if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable { - return false - } - - hasOpenAIServerOverloadedCode := func(payload []byte) bool { - code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String())) - if code == "" { - code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String())) - } - return code == "server_is_overloaded" || code == "slow_down" - } - - if len(upstreamBody) > 0 && hasOpenAIServerOverloadedCode(upstreamBody) { - return true - } - if upstreamStatusCode != http.StatusBadRequest { - return false - } - - match := func(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - if strings.Contains(lower, "an error occurred while processing your request") { - return true - } - if strings.Contains(lower, "selected model is at capacity") { - return true - } - return strings.Contains(lower, "you can retry your request") && - strings.Contains(lower, "help.openai.com") && - strings.Contains(lower, "request id") - } - - if match(upstreamMsg) { - return true - } - if len(upstreamBody) == 0 { - return false - } - if match(gjson.GetBytes(upstreamBody, "error.message").String()) { - return true - } - return match(string(upstreamBody)) -} - -func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool { - match := func(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - if strings.Contains(lower, "context_too_large") || strings.Contains(lower, "context_length_exceeded") { - return true - } - if strings.Contains(lower, "maximum context length") || strings.Contains(lower, "max context length") { - return true - } - hasExceeded := strings.Contains(lower, "exceed") || strings.Contains(lower, "too large") || strings.Contains(lower, "too long") - if strings.Contains(lower, "context window") && hasExceeded { - return true - } - if strings.Contains(lower, "context length") && hasExceeded { - return true - } - return strings.Contains(lower, "token limit") && - strings.Contains(lower, "context") && - hasExceeded - } - - if match(upstreamMsg) { - return true - } - if len(upstreamBody) == 0 { - return false - } - for _, path := range []string{ - "error.message", - "response.error.message", - "message", - "error.code", - "response.error.code", - "code", - } { - if match(gjson.GetBytes(upstreamBody, path).String()) { - return true - } - } - return match(string(upstreamBody)) -} - // GetAccessToken gets the access token for an OpenAI account func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Account) (string, string, error) { if account.IsShadow() { @@ -1295,3578 +1093,3 @@ func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Acco return "", "", fmt.Errorf("unsupported account type: %s", account.Type) } } - -func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool { - switch statusCode { - case 401, 402, 403, 429, 529: - return true - default: - return statusCode >= 500 - } -} - -func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode int, upstreamMsg string, upstreamBody []byte) bool { - if isOpenAIContextWindowError(upstreamMsg, upstreamBody) { - return false - } - if s.shouldFailoverUpstreamError(statusCode) { - return true - } - return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody) -} - -func marshalOpenAIUpstreamJSON(v any) ([]byte, error) { - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) - if err := enc.Encode(v); err != nil { - return nil, err - } - out := buf.Bytes() - if len(out) > 0 && out[len(out)-1] == '\n' { - out = out[:len(out)-1] - } - return out, nil -} - -func openAIUpstreamErrorBodyReadLimitForConfig(cfg *config.Config) int64 { - limit := openAIUpstreamErrorBodyReadLimit - if cfg != nil && cfg.Gateway.LogUpstreamErrorBody && cfg.Gateway.LogUpstreamErrorBodyMaxBytes > int(limit) { - limit = int64(cfg.Gateway.LogUpstreamErrorBodyMaxBytes) - } - return limit -} - -func (s *OpenAIGatewayService) readUpstreamErrorBody(resp *http.Response) []byte { - if resp == nil || resp.Body == nil { - return nil - } - cfg := (*config.Config)(nil) - if s != nil { - cfg = s.cfg - } - body, _ := io.ReadAll(io.LimitReader(resp.Body, openAIUpstreamErrorBodyReadLimitForConfig(cfg))) - return body -} - -func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, responseBody []byte, requestedModel ...string) { - if len(requestedModel) > 0 { - s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody, requestedModel[0]) - return - } - s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody) -} - -// 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") - } - - originalBody := body - requestView := newOpenAIRequestView(body) - reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey - originalModel := reqModel - - if account.Platform == PlatformGrok { - _ = promptCacheKey - return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime) - } - - if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { - return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) - } - - compatMessagesBridge := isOpenAICompatMessagesBridgeBody(body) - setOpenAICompatMessagesBridgeContext(c, compatMessagesBridge) - - isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI) - wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) - clientTransport := GetOpenAIClientTransport(c) - // 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。 - wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, clientTransport) - if c != nil { - c.Set("openai_ws_transport_decision", string(wsDecision.Transport)) - c.Set("openai_ws_transport_reason", wsDecision.Reason) - } - if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 { - logOpenAIWSModeDebug( - "selected account_id=%d account_type=%s transport=%s reason=%s model=%s stream=%v", - account.ID, - account.Type, - normalizeOpenAIWSLogValue(string(wsDecision.Transport)), - normalizeOpenAIWSLogValue(wsDecision.Reason), - reqModel, - reqStream, - ) - } - // 当前仅支持 WSv2;WSv1 命中时直接返回错误,避免出现“配置可开但行为不确定”。 - if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocket { - if c != nil { - MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) - c.JSON(http.StatusBadRequest, gin.H{ - "error": gin.H{ - "type": "invalid_request_error", - "message": "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.", - }, - }) - } - return nil, errors.New("openai ws v1 is temporarily unsupported; use ws v2") - } - passthroughEnabled := account.IsOpenAIPassthroughEnabled() - if passthroughEnabled { - // 透传分支只需要轻量提取字段,避免热路径全量 Unmarshal。 - reasoningEffort := extractOpenAIReasoningEffortFromBody(body, reqModel) - // 国产模型默认 effort 补充:也要用 mappedModel 判定是否是 passback-required 上游。 - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, account.GetMappedModel(reqModel)) - 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) - } - codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow - if isCodexCLI { - codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy() - } - codexImageGenerationBridgeEnabled := isCodexCLI && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) - var imageIntent bool - if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { - decoded, decodeErr := ensureReqBody() - if decodeErr != nil { - return nil, decodeErr - } - if stripOpenAIImageGenerationTools(decoded) { - markDecodedModified() - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy") - } - imageIntent = IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, decoded) - } else { - imageIntent = IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) - } - if imageIntent && !imageGenerationAllowed { - MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) - c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}}) - return nil, errors.New("image generation disabled for group") - } - - instructions := gjson.GetBytes(body, "instructions") - instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" - if instructionsEmpty && !compatMessagesBridge { - markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) - } - - billingModel := account.GetMappedModel(reqModel) - if billingModel != reqModel { - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, billingModel, account.Name, isCodexCLI) - reqModel = billingModel - markPatchSet("model", billingModel) - } - upstreamModel := billingModel - isCompactRequest := isOpenAIResponsesCompactPath(c) - compactMapped := false - if isCompactRequest { - compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel) - if compactMappedModel != "" && compactMappedModel != billingModel { - compactMapped = true - upstreamModel = compactMappedModel - reqModel = compactMappedModel - markPatchSet("model", compactMappedModel) - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", billingModel, compactMappedModel, account.Name, isCodexCLI) - } - } - if !compactMapped { - modelForNormalize := reqModel - if modelForNormalize == "" { - modelForNormalize = requestView.Model - } - upstreamModel = normalizeOpenAIModelForUpstream(account, modelForNormalize) - if upstreamModel != "" && upstreamModel != modelForNormalize { - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", modelForNormalize, upstreamModel, account.Name, account.Type, isCodexCLI) - reqModel = upstreamModel - markPatchSet("model", upstreamModel) - } - } - if strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) == "minimal" { - markPatchSet("reasoning.effort", "none") - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized reasoning.effort: minimal -> none (account: %s)", account.Name) - } - - imageIntent = imageIntent || IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, nil) || isOpenAIImageGenerationModel(upstreamModel) - if imageIntent && !imageGenerationAllowed { - MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) - c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}}) - return nil, errors.New("image generation disabled for group") - } - - // /responses/compact 是会话压缩请求:上游不接受 tool_choice(400 unknown_parameter), - // 注入 image_generation 工具也没有意义,整块豁免。 - if imageGenerationAllowed && !isCompactRequest && (codexImageGenerationBridgeEnabled || isOpenAIImageGenerationModel(requestView.Model) || openAIRequestBodyImageGenerationToolNeedsNormalization(body) || isOpenAIImageGenerationModel(upstreamModel)) { - decoded, decodeErr := ensureReqBody() - if decodeErr != nil { - return nil, decodeErr - } - if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationTool(decoded) { - markDecodedModified() - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Injected /responses image_generation tool for Codex client") - } - if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationToolChoiceAuto(decoded) { - markDecodedModified() - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Set /responses image_generation tool_choice=auto for Codex client") - } - if normalizeOpenAIResponsesImageGenerationTools(decoded) { - markDecodedModified() - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image_generation tool payload") - } - if normalizeOpenAIResponsesImageOnlyModel(decoded) { - markDecodedModified() - if model, ok := decoded["model"].(string); ok { - upstreamModel = strings.TrimSpace(model) - } - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image-only model request inbound_model=%s image_model=%s upstream_model=%s", requestView.Model, billingModel, upstreamModel) - } - if err := validateOpenAIResponsesImageModel(decoded, upstreamModel); err != nil { - setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") - c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "model"}}) - return nil, err - } - if hasOpenAIImageGenerationTool(decoded) { - imageIntent = true - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type) - } - if codexImageGenerationBridgeEnabled && applyCodexImageGenerationBridgeInstructions(decoded) { - markDecodedModified() - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Added Codex image_generation bridge instructions") - } - } else if imageGenerationAllowed && imageIntent && openAIRequestBodyHasImageGenerationTool(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) && openAIRequestBodyHasImageGenerationTool(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, 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) - } - } - } - - // 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) - - // 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) -} - -func (s *OpenAIGatewayService) handleErrorResponse( - ctx context.Context, - resp *http.Response, - c *gin.Context, - account *Account, - requestBody []byte, - requestedModel ...string, -) (*OpenAIForwardResult, error) { - body := s.readUpstreamErrorBody(resp) - - // cyber_policy 硬阻断:透传上游原始错误体给客户端(不重包成通用 502),不冷却账号。 - // 当前请求恒透传(需求1);标记供 handler 事后写风控/邮件。400 cyber 不可 failover - // (shouldFailoverUpstreamError(400)=false),故走到此处即可安全早返回。 - if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit { - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: cyberMsg, - Body: truncateString(string(body), 4096), - UpstreamStatus: resp.StatusCode, - }) - setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048)) - writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/json" - } - c.Data(resp.StatusCode, contentType, body) - if cyberMsg == "" { - return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode) - } - return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg) - } - - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(body), maxBytes) - } - setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) - logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body) - - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - logger.LegacyPrintf("service.openai_gateway", - "OpenAI upstream error %d (account=%d platform=%s type=%s): %s", - resp.StatusCode, - account.ID, - account.Platform, - account.Type, - truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), - ) - } - - if status, errType, errMsg, matched := applyErrorPassthroughRule( - c, - PlatformOpenAI, - resp.StatusCode, - body, - http.StatusBadGateway, - "upstream_error", - "Upstream request failed", - ); matched { - MarkResponseCommitted(c) - c.JSON(status, gin.H{ - "error": gin.H{ - "type": errType, - "message": errMsg, - }, - }) - if upstreamMsg == "" { - upstreamMsg = errMsg - } - if upstreamMsg == "" { - return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg) - } - - // Check custom error codes - if !account.ShouldHandleErrorCode(resp.StatusCode) { - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "http_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - MarkResponseCommitted(c) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": gin.H{ - "type": "upstream_error", - "message": "Upstream gateway error", - }, - }) - if upstreamMsg == "" { - return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg) - } - - // Handle upstream error (mark account status) - var reqModel string - if len(requestedModel) > 0 { - reqModel = strings.TrimSpace(requestedModel[0]) - } - if reqModel == "" { - reqModel, _, _ = extractOpenAIRequestMetaFromBody(requestBody) - } - shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel) - kind := "http_error" - if shouldDisable { - kind = "failover" - } - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: kind, - Message: upstreamMsg, - Detail: upstreamDetail, - }) - if shouldDisable { - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: body, - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), - } - } - - MarkResponseCommitted(c) - - // Return appropriate error response - var errType, errMsg string - var statusCode int - - switch resp.StatusCode { - case 401: - statusCode = http.StatusBadGateway - errType = "upstream_error" - errMsg = "Upstream authentication failed, please contact administrator" - case 402: - statusCode = http.StatusBadGateway - errType = "upstream_error" - errMsg = "Upstream payment required: insufficient balance or billing issue" - case 403: - statusCode = http.StatusBadGateway - errType = "upstream_error" - errMsg = "Upstream access forbidden, please contact administrator" - case 429: - statusCode = http.StatusTooManyRequests - errType = "rate_limit_error" - errMsg = "Upstream rate limit exceeded, please retry later" - default: - statusCode = http.StatusBadGateway - errType = "upstream_error" - errMsg = "Upstream request failed" - } - if isOpenAIContextWindowError(upstreamMsg, body) && upstreamMsg != "" { - errMsg = upstreamMsg - } - - c.JSON(statusCode, gin.H{ - "error": gin.H{ - "type": errType, - "message": errMsg, - }, - }) - - if upstreamMsg == "" { - return nil, fmt.Errorf("upstream error: %d", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg) -} - -// compatErrorWriter is the signature for format-specific error writers used by -// the compat paths (Chat Completions and Anthropic Messages). -type compatErrorWriter func(c *gin.Context, statusCode int, errType, message string) - -// handleCompatErrorResponse is the shared non-failover error handler for the -// Chat Completions and Anthropic Messages compat paths. It mirrors the logic of -// handleErrorResponse (passthrough rules, ShouldHandleErrorCode, rate-limit -// tracking, secondary failover) but delegates the final error write to the -// format-specific writer function. -func (s *OpenAIGatewayService) handleCompatErrorResponse( - resp *http.Response, - c *gin.Context, - account *Account, - writeError compatErrorWriter, - requestedModel ...string, -) (*OpenAIForwardResult, error) { - body := s.readUpstreamErrorBody(resp) - - // cyber_policy:兼容路径(Chat Completions / Anthropic)以各自格式回写错误, - // 不原样透传 responses 格式的 cyber body(否则对下游格式不合法)。cyber 是上游网络 - // 安全策略拦截,不冷却账号,故标记后直接以兼容格式回写错误并返回,跳过下方 - // handleOpenAIAccountUpstreamError(避免自定义 temp-unschedulable 规则误冷却)。 - if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit { - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: cyberMsg, - Body: truncateString(string(body), 4096), - UpstreamStatus: resp.StatusCode, - }) - setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048)) - clientMsg := cyberMsg - if clientMsg == "" { - clientMsg = "Request blocked by upstream cyber-security policy" - } - writeError(c, resp.StatusCode, "invalid_request_error", clientMsg) - if cyberMsg == "" { - return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode) - } - return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg) - } - - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) - if upstreamMsg == "" { - upstreamMsg = fmt.Sprintf("Upstream error: %d", resp.StatusCode) - } - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(body), maxBytes) - } - setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) - - // Apply error passthrough rules - if status, errType, errMsg, matched := applyErrorPassthroughRule( - c, account.Platform, resp.StatusCode, body, - http.StatusBadGateway, "api_error", "Upstream request failed", - ); matched { - MarkResponseCommitted(c) - writeError(c, status, errType, errMsg) - if upstreamMsg == "" { - upstreamMsg = errMsg - } - if upstreamMsg == "" { - return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg) - } - - // Check custom error codes — if the account does not handle this status, - // return a generic error without exposing upstream details. - if !account.ShouldHandleErrorCode(resp.StatusCode) { - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "http_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - MarkResponseCommitted(c) - writeError(c, http.StatusInternalServerError, "api_error", "Upstream gateway error") - if upstreamMsg == "" { - return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg) - } - - // Track rate limits and decide whether to trigger secondary failover. - var modelForCooldown string - if len(requestedModel) > 0 { - modelForCooldown = requestedModel[0] - } - shouldDisable := s.handleOpenAIAccountUpstreamError( - c.Request.Context(), account, resp.StatusCode, resp.Header, body, modelForCooldown, - ) - kind := "http_error" - if shouldDisable { - kind = "failover" - } - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: kind, - Message: upstreamMsg, - Detail: upstreamDetail, - }) - if shouldDisable { - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: body, - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), - } - } - - MarkResponseCommitted(c) - - // Map status code to error type and write response - errType := "api_error" - switch { - case resp.StatusCode == 400: - errType = "invalid_request_error" - case resp.StatusCode == 404: - errType = "not_found_error" - case resp.StatusCode == 429: - errType = "rate_limit_error" - case resp.StatusCode >= 500: - errType = "api_error" - } - - writeError(c, resp.StatusCode, errType, upstreamMsg) - return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg) -} - -// openaiStreamingResult streaming response result -type openaiStreamingResult struct { - usage *OpenAIUsage - firstTokenMs *int - responseID string - imageCount int - imageOutputSizes []string -} - -type openaiNonStreamingResult struct { - *OpenAIUsage - usage *OpenAIUsage - responseID string - imageCount int - imageOutputSizes []string -} - -func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) { - if s.responseHeaderFilter != nil { - responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - } - - // Set SSE response headers - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") - - // Pass through other headers - if v := resp.Header.Get("x-request-id"); v != "" { - c.Header("x-request-id", v) - } - - w := c.Writer - flusher, ok := w.(http.Flusher) - if !ok { - return nil, errors.New("streaming not supported") - } - bufferedWriter := bufio.NewWriterSize(w, 4*1024) - flushBuffered := func() error { - if err := bufferedWriter.Flush(); err != nil { - return err - } - flusher.Flush() - return nil - } - - usage := &OpenAIUsage{} - imageCounter := newOpenAIImageOutputCounter() - var firstTokenMs *int - responseID := "" - scanner := bufio.NewScanner(resp.Body) - maxLineSize := defaultMaxLineSize - if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { - maxLineSize = s.cfg.Gateway.MaxLineSize - } - scanBuf := getSSEScannerBuf64K() - scanner.Buffer(scanBuf[:0], maxLineSize) - - streamInterval := time.Duration(0) - if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 { - streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second - } - // 仅监控上游数据间隔超时,不被下游写入阻塞影响 - var intervalTicker *time.Ticker - if streamInterval > 0 { - intervalTicker = time.NewTicker(streamInterval) - defer intervalTicker.Stop() - } - var intervalCh <-chan time.Time - if intervalTicker != nil { - intervalCh = intervalTicker.C - } - - keepaliveInterval := time.Duration(0) - if s.cfg != nil && s.cfg.Gateway.StreamKeepaliveInterval > 0 { - keepaliveInterval = time.Duration(s.cfg.Gateway.StreamKeepaliveInterval) * time.Second - } - // 下游 keepalive 仅用于防止代理空闲断开 - var keepaliveTicker *time.Ticker - if keepaliveInterval > 0 { - keepaliveTicker = time.NewTicker(keepaliveInterval) - defer keepaliveTicker.Stop() - } - var keepaliveCh <-chan time.Time - if keepaliveTicker != nil { - keepaliveCh = keepaliveTicker.C - } - // Track downstream writes separately from upstream reads: pre-output failover - // can buffer response.created / response.in_progress, so keepalive must be - // based on downstream idle time. - lastDownstreamWriteAt := time.Now() - - // 仅发送一次错误事件,避免多次写入导致协议混乱。 - // 注意:OpenAI `/v1/responses` streaming 事件必须符合 OpenAI Responses schema; - // 否则下游 SDK(例如 OpenCode)会因为类型校验失败而报错。 - errorEventSent := false - clientDisconnected := false // 客户端断开后继续 drain 上游以收集 usage - sawTerminalEvent := false - sawFailedEvent := false - failedMessage := "" - clientOutputStarted := false - upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) - var streamFailoverErr error - sendErrorEvent := func(reason string) { - if errorEventSent || clientDisconnected { - return - } - errorEventSent = true - payload := `{"type":"error","sequence_number":0,"error":{"type":"upstream_error","message":` + strconv.Quote(reason) + `,"code":` + strconv.Quote(reason) + `}}` - if err := flushBuffered(); err != nil { - clientDisconnected = true - return - } - if _, err := bufferedWriter.WriteString("data: " + payload + "\n\n"); err != nil { - clientDisconnected = true - return - } - if err := flushBuffered(); err != nil { - clientDisconnected = true - return - } - clientOutputStarted = true - lastDownstreamWriteAt = time.Now() - } - - needModelReplace := originalModel != mappedModel - streamOutputAccumulator := apicompat.NewBufferedResponseAccumulator() - streamImageOutputs := make([]json.RawMessage, 0, 1) - streamSeenImages := make(map[string]struct{}) - resultWithUsage := func() *openaiStreamingResult { - return &openaiStreamingResult{ - usage: usage, - firstTokenMs: firstTokenMs, - responseID: responseID, - imageCount: imageCounter.Count(), - imageOutputSizes: imageCounter.Sizes(), - } - } - finalizeStream := func() (*openaiStreamingResult, error) { - if !sawTerminalEvent { - if !openAIStreamClientOutputStarted(c, clientOutputStarted) { - return resultWithUsage(), s.newOpenAIStreamFailoverError( - c, - account, - false, - upstreamRequestID, - nil, - "OpenAI stream ended before a terminal event", - ) - } - return resultWithUsage(), fmt.Errorf("stream usage incomplete: missing terminal event") - } - if sawFailedEvent { - return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage) - } - if !clientDisconnected { - hadBufferedData := bufferedWriter.Buffered() > 0 - if err := flushBuffered(); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during final flush, returning collected usage") - } else if hadBufferedData { - clientOutputStarted = true - lastDownstreamWriteAt = time.Now() - } - } - return resultWithUsage(), nil - } - handleScanErr := func(scanErr error) (*openaiStreamingResult, error, bool) { - if scanErr == nil { - return nil, nil, false - } - if sawTerminalEvent && !sawFailedEvent { - logger.LegacyPrintf("service.openai_gateway", "Upstream scan ended after terminal event: %v", scanErr) - return resultWithUsage(), nil, true - } - if sawFailedEvent { - return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage), true - } - // 客户端断开/取消请求时,上游读取往往会返回 context canceled。 - // /v1/responses 的 SSE 事件必须符合 OpenAI 协议;这里不注入自定义 error event,避免下游 SDK 解析失败。 - if errors.Is(scanErr, context.Canceled) || errors.Is(scanErr, context.DeadlineExceeded) { - return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", scanErr), true - } - if errors.Is(scanErr, bufio.ErrTooLong) { - logger.LegacyPrintf("service.openai_gateway", "SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, scanErr) - sendErrorEvent("response_too_large") - return resultWithUsage(), scanErr, true - } - if !openAIStreamClientOutputStarted(c, clientOutputStarted) { - msg := "OpenAI stream disconnected before completion" - if errText := strings.TrimSpace(scanErr.Error()); errText != "" { - msg += ": " + errText - } - return resultWithUsage(), s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, nil, msg), true - } - // 客户端已断开时,上游出错仅影响体验,不影响计费;返回已收集 usage - if clientDisconnected { - return resultWithUsage(), fmt.Errorf("stream usage incomplete after disconnect: %w", scanErr), true - } - sendErrorEvent("stream_read_error") - return resultWithUsage(), fmt.Errorf("stream read error: %w", scanErr), true - } - processSSELine := func(line string, queueDrained bool) { - if streamFailoverErr != nil { - return - } - // Extract data from SSE line (supports both "data: " and "data:" formats) - if data, ok := extractOpenAISSEDataLine(line); ok { - dataBytes := []byte(data) - if openAIStreamEventIsTerminal(data) { - sawTerminalEvent = true - } - eventType := strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) - if responseID == "" { - responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes) - } - forceFlushFailedEvent := false - if eventType == "response.failed" { - failedMessage = extractOpenAISSEErrorMessage(dataBytes) - // response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析 - // 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费 - // 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。 - s.parseSSEUsageBytes(dataBytes, usage) - if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit { - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: msg, - Body: truncateString(string(dataBytes), 4096), - UpstreamStatus: http.StatusOK, - UpstreamInTok: usage.InputTokens, - UpstreamOutTok: usage.OutputTokens, - }) - } else if !openAIStreamClientOutputStarted(c, clientOutputStarted) && openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) { - sawFailedEvent = true - streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, failedMessage) - return - } - forceFlushFailedEvent = true - sawFailedEvent = true - } - imageCounter.AddSSEData(dataBytes) - - // Correct Codex tool calls if needed (apply_patch -> edit, etc.) - if correctedData, corrected := s.toolCorrector.CorrectToolCallsInSSEBytes(dataBytes); corrected { - dataBytes = correctedData - data = string(correctedData) - line = "data: " + data - eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) - } - if imageOutput, ok := extractImageGenerationOutputFromSSEData(dataBytes, streamSeenImages); ok { - streamImageOutputs = append(streamImageOutputs, imageOutput) - } - if responsesStreamEventMayContributeToOutput(eventType) { - var streamEvent apicompat.ResponsesStreamEvent - if err := json.Unmarshal(dataBytes, &streamEvent); err == nil { - streamOutputAccumulator.ProcessEvent(&streamEvent) - } - } - if normalizedData, normalized := normalizeResponsesStreamingTerminalOutput(dataBytes, streamOutputAccumulator, streamImageOutputs); normalized { - dataBytes = normalizedData - data = string(normalizedData) - line = "data: " + data - eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) - } - if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient(dataBytes, eventType); sanitized { - dataBytes = sanitizedData - data = string(sanitizedData) - line = "data: " + data - } - // Replace model in response if needed. - // Fast path: most events do not contain model field values. - if needModelReplace && mappedModel != "" && strings.Contains(line, mappedModel) { - line = s.replaceModelInSSELine(line, mappedModel, originalModel) - } - startsClientOutput := forceFlushFailedEvent || openAIStreamDataStartsClientOutput(data, eventType) - - // 写入客户端(客户端断开后继续 drain 上游) - if !clientDisconnected { - shouldFlush := queueDrained && (clientOutputStarted || startsClientOutput) - if firstTokenMs == nil && startsClientOutput { - // 保证首个 token 事件尽快出站,避免影响 TTFT。 - shouldFlush = true - } - if _, err := bufferedWriter.WriteString(line); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") - } else if _, err := bufferedWriter.WriteString("\n"); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") - } else if shouldFlush { - if err := flushBuffered(); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming flush, continuing to drain upstream for billing") - } else { - clientOutputStarted = true - lastDownstreamWriteAt = time.Now() - } - } - } - - // Record first token time - if firstTokenMs == nil && startsClientOutput { - ms := int(time.Since(startTime).Milliseconds()) - firstTokenMs = &ms - } - s.parseSSEUsageBytes(dataBytes, usage) - return - } - - // Forward non-data lines as-is - if !clientDisconnected { - if _, err := bufferedWriter.WriteString(line); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") - } else if _, err := bufferedWriter.WriteString("\n"); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") - } else if queueDrained && clientOutputStarted { - if err := flushBuffered(); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming flush, continuing to drain upstream for billing") - } else { - clientOutputStarted = true - lastDownstreamWriteAt = time.Now() - } - } - } - } - - // 无超时/无 keepalive 的常见路径走同步扫描,减少 goroutine 与 channel 开销。 - if streamInterval <= 0 && keepaliveInterval <= 0 { - defer putSSEScannerBuf64K(scanBuf) - for scanner.Scan() { - processSSELine(scanner.Text(), true) - if streamFailoverErr != nil { - return resultWithUsage(), streamFailoverErr - } - } - if result, err, done := handleScanErr(scanner.Err()); done { - return result, err - } - return finalizeStream() - } - - type scanEvent struct { - line string - err error - } - // 独立 goroutine 读取上游,避免读取阻塞影响 keepalive/超时处理 - events := make(chan scanEvent, 16) - done := make(chan struct{}) - sendEvent := func(ev scanEvent) bool { - select { - case events <- ev: - return true - case <-done: - return false - } - } - var lastReadAt int64 - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - go func(scanBuf *sseScannerBuf64K) { - defer putSSEScannerBuf64K(scanBuf) - defer close(events) - for scanner.Scan() { - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - if !sendEvent(scanEvent{line: scanner.Text()}) { - return - } - } - if err := scanner.Err(); err != nil { - _ = sendEvent(scanEvent{err: err}) - } - }(scanBuf) - defer close(done) - - for { - select { - case ev, ok := <-events: - if !ok { - return finalizeStream() - } - if result, err, done := handleScanErr(ev.err); done { - return result, err - } - processSSELine(ev.line, len(events) == 0) - if streamFailoverErr != nil { - return resultWithUsage(), streamFailoverErr - } - - case <-intervalCh: - lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) - if time.Since(lastRead) < streamInterval { - continue - } - if clientDisconnected { - return resultWithUsage(), fmt.Errorf("stream usage incomplete after timeout") - } - logger.LegacyPrintf("service.openai_gateway", "Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval) - // 处理流超时,可能标记账户为临时不可调度或错误状态 - if s.rateLimitService != nil { - s.rateLimitService.HandleStreamTimeout(ctx, account, originalModel) - } - sendErrorEvent("stream_timeout") - return resultWithUsage(), fmt.Errorf("stream data interval timeout") - - case <-keepaliveCh: - if clientDisconnected { - continue - } - if time.Since(lastDownstreamWriteAt) < keepaliveInterval { - continue - } - if _, err := bufferedWriter.WriteString(":\n\n"); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") - continue - } - if err := flushBuffered(); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during keepalive flush, continuing to drain upstream for billing") - } else { - lastDownstreamWriteAt = time.Now() - } - } - } - -} - -// extractOpenAISSEDataLine 低开销提取 SSE `data:` 行内容。 -// 兼容 `data: xxx` 与 `data:xxx` 两种格式。 -func extractOpenAISSEDataLine(line string) (string, bool) { - if !strings.HasPrefix(line, "data:") { - return "", false - } - start := len("data:") - for start < len(line) { - if line[start] != ' ' && line[start] != ' ' { - break - } - start++ - } - return line[start:], true -} - -func extractOpenAISSEEventLine(line string) (string, bool) { - if !strings.HasPrefix(line, "event:") { - return "", false - } - start := len("event:") - for start < len(line) { - if line[start] != ' ' && line[start] != ' ' { - break - } - start++ - } - return strings.TrimSpace(line[start:]), true -} - -type openAICompatSSEFrame struct { - EventType string - Data string -} - -type openAICompatSSEFrameParser struct { - eventType string - dataLines []string -} - -func (p *openAICompatSSEFrameParser) AddLine(line string) (openAICompatSSEFrame, bool) { - if line == "" { - return p.dispatch() - } - if strings.HasPrefix(line, ":") { - return openAICompatSSEFrame{}, false - } - if eventType, ok := extractOpenAISSEEventLine(line); ok { - p.eventType = eventType - return openAICompatSSEFrame{}, false - } - if data, ok := extractOpenAISSEDataLine(line); ok { - p.dataLines = append(p.dataLines, data) - } - return openAICompatSSEFrame{}, false -} - -func (p *openAICompatSSEFrameParser) Finish() (openAICompatSSEFrame, bool) { - return p.dispatch() -} - -func (p *openAICompatSSEFrameParser) dispatch() (openAICompatSSEFrame, bool) { - frame := openAICompatSSEFrame{ - EventType: p.eventType, - Data: strings.Join(p.dataLines, "\n"), - } - p.eventType = "" - p.dataLines = nil - return frame, frame.Data != "" -} - -func openAICompatPayloadWithEventType(payload, eventType string) string { - eventType = strings.TrimSpace(eventType) - if eventType == "" || strings.TrimSpace(payload) == "" || strings.TrimSpace(payload) == "[DONE]" { - return payload - } - if gjson.Get(payload, "type").Exists() { - return payload - } - patched, err := sjson.Set(payload, "type", eventType) - if err != nil { - return payload - } - return patched -} - -func (s *OpenAIGatewayService) replaceModelInSSELine(line, fromModel, toModel string) string { - data, ok := extractOpenAISSEDataLine(line) - if !ok { - return line - } - if data == "" || data == "[DONE]" { - return line - } - - // 使用 gjson 精确检查 model 字段,避免全量 JSON 反序列化 - if m := gjson.Get(data, "model"); m.Exists() && m.Str == fromModel { - newData, err := sjson.Set(data, "model", toModel) - if err != nil { - return line - } - return "data: " + newData - } - - // 检查嵌套的 response.model 字段 - if m := gjson.Get(data, "response.model"); m.Exists() && m.Str == fromModel { - newData, err := sjson.Set(data, "response.model", toModel) - if err != nil { - return line - } - return "data: " + newData - } - - return line -} - -// correctToolCallsInResponseBody 修正响应体中的工具调用 -func (s *OpenAIGatewayService) correctToolCallsInResponseBody(body []byte) []byte { - if len(body) == 0 { - return body - } - - updated := body - if s != nil && s.toolCorrector != nil { - if corrected, changed := s.toolCorrector.CorrectToolCallsInSSEBytes(updated); changed { - updated = corrected - } - } - if normalized, changed := normalizeOpenAIResponsesFunctionCallArguments(updated); changed { - updated = normalized - } - return updated -} - -func normalizeOpenAIResponsesFunctionCallArguments(data []byte) ([]byte, bool) { - if len(bytes.TrimSpace(data)) == 0 || !bytes.Contains(data, []byte(`"arguments"`)) { - return data, false - } - if !gjson.ValidBytes(data) { - return data, false - } - - updated := data - changed := false - setDedupedArgument := func(path string) { - arg := gjson.GetBytes(updated, path) - if !arg.Exists() || arg.Type != gjson.String { - return - } - deduped, ok := dedupeRepeatedJSONArgumentString(arg.Str) - if !ok { - return - } - next, err := sjson.SetBytes(updated, path, deduped) - if err != nil { - return - } - updated = next - changed = true - } - - eventType := strings.TrimSpace(gjson.GetBytes(updated, "type").String()) - if eventType == "response.function_call_arguments.done" { - setDedupedArgument("arguments") - } - if itemType := strings.TrimSpace(gjson.GetBytes(updated, "item.type").String()); isResponsesFunctionCallItemType(itemType) { - setDedupedArgument("item.arguments") - } - dedupeResponsesFunctionCallOutputArguments(updated, "response.output", setDedupedArgument) - dedupeResponsesFunctionCallOutputArguments(updated, "output", setDedupedArgument) - - return updated, changed -} - -func dedupeResponsesFunctionCallOutputArguments(data []byte, outputPath string, setDedupedArgument func(string)) { - output := gjson.GetBytes(data, outputPath) - if !output.Exists() || !output.IsArray() { - return - } - for i, item := range output.Array() { - if !isResponsesFunctionCallItemType(strings.TrimSpace(item.Get("type").String())) { - continue - } - setDedupedArgument(outputPath + "." + strconv.Itoa(i) + ".arguments") - } -} - -func isResponsesFunctionCallItemType(itemType string) bool { - return itemType == "function_call" || itemType == "custom_tool_call" -} - -func dedupeRepeatedJSONArgumentString(arguments string) (string, bool) { - if len(arguments) == 0 || len(arguments)%2 != 0 { - return "", false - } - halfLen := len(arguments) / 2 - first := arguments[:halfLen] - if first != arguments[halfLen:] { - return "", false - } - trimmed := strings.TrimSpace(first) - if trimmed == "" || (!strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[")) { - return "", false - } - if !json.Valid([]byte(first)) { - return "", false - } - return first, true -} - -func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) { - s.parseSSEUsageBytes([]byte(data), usage) -} - -func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsage) { - if usage == nil || len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { - return - } - // 选择性解析:仅在数据中包含终止事件标识时才进入字段提取。 - if len(data) < 72 { - return - } - eventType := gjson.GetBytes(data, "type").String() - if eventType != "response.completed" && eventType != "response.done" && eventType != "response.failed" && - eventType != "response.incomplete" && eventType != "response.cancelled" && eventType != "response.canceled" { - return - } - - if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(data); ok { - *usage = parsedUsage - } -} - -func extractOpenAIUsageFromJSONBytes(body []byte) (OpenAIUsage, bool) { - if len(body) == 0 || !gjson.ValidBytes(body) { - return OpenAIUsage{}, false - } - if usage, ok := openAIUsageFromGJSON(gjson.GetBytes(body, "usage")); ok { - return usage, true - } - return openAIUsageFromGJSON(gjson.GetBytes(body, "response.usage")) -} - -func extractOpenAIResponseIDFromJSONBytes(body []byte) string { - if len(body) == 0 || !gjson.ValidBytes(body) { - return "" - } - if id := strings.TrimSpace(gjson.GetBytes(body, "id").String()); id != "" { - return id - } - return strings.TrimSpace(gjson.GetBytes(body, "response.id").String()) -} - -func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *gin.Context, account *Account, responseID string) { - if s == nil || account == nil || account.ID <= 0 { - return - } - responseID = strings.TrimSpace(responseID) - if responseID == "" { - return - } - store := s.getOpenAIWSStateStore() - if store == nil { - return - } - groupID := getOpenAIGroupIDFromContext(c) - ttl := s.openAIWSResponseStickyTTL() - logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) -} - -func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { - if !value.Exists() || !value.IsObject() { - return OpenAIUsage{}, false - } - inputTokens := value.Get("input_tokens").Int() - if inputTokens == 0 { - inputTokens = value.Get("prompt_tokens").Int() - } - outputTokens := value.Get("output_tokens").Int() - if outputTokens == 0 { - outputTokens = value.Get("completion_tokens").Int() - } - cacheReadTokens := value.Get("input_tokens_details.cached_tokens").Int() - if cacheReadTokens == 0 { - cacheReadTokens = value.Get("prompt_tokens_details.cached_tokens").Int() - } - imageOutputTokens := value.Get("output_tokens_details.image_tokens").Int() - if imageOutputTokens == 0 { - imageOutputTokens = value.Get("completion_tokens_details.image_tokens").Int() - } - return OpenAIUsage{ - InputTokens: int(inputTokens), - OutputTokens: int(outputTokens), - CacheCreationInputTokens: int(value.Get("cache_creation_input_tokens").Int()), - CacheReadInputTokens: int(cacheReadTokens), - ImageOutputTokens: int(imageOutputTokens), - }, true -} - -func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { - body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) - if err != nil { - return nil, err - } - - // Detect SSE responses for ALL account types via Content-Type header. - // Some OpenAI-compatible upstreams (including other sub2api instances) - // may return SSE even when stream=false was requested. - if isEventStreamResponse(resp.Header) { - return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) - } - // bodyLooksLikeSSE is a line-level heuristic: real SSE framing requires - // "data:"/"event:" field names at the very start of a physical line. A - // plain bytes.Contains scan would also match ordinary JSON responses - // whose string content merely echoes the literal text "data:" or - // "event:" (e.g. compact tool output), causing those JSON bodies to be - // misrouted into handleSSEToJSON and lose their usage accounting. - bodyLooksLikeSSE := bodyHasSSEFraming(body) - - // For OAuth accounts, also fall back to a body-content heuristic because - // the upstream may omit the Content-Type header while still sending SSE. - // This heuristic is NOT applied to API-key accounts to avoid false - // positives on JSON responses that coincidentally contain "data:" or - // "event:" in their text content. - if account.Type == AccountTypeOAuth && bodyLooksLikeSSE { - return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) - } - - usageValue, usageOK := extractOpenAIUsageFromJSONBytes(body) - if !usageOK { - if bodyLooksLikeSSE { - return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) - } - return nil, fmt.Errorf("parse response: invalid json response") - } - usage := &usageValue - - // Replace model in response if needed - if originalModel != mappedModel { - body = s.replaceModelInResponseBody(body, mappedModel, originalModel) - } - - responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - - contentType := "application/json" - if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled { - if upstreamType := resp.Header.Get("Content-Type"); upstreamType != "" { - contentType = upstreamType - } - } - - c.Data(resp.StatusCode, contentType, body) - - return &openaiNonStreamingResult{ - OpenAIUsage: usage, - usage: usage, - responseID: extractOpenAIResponseIDFromJSONBytes(body), - imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body), - imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body), - }, nil -} - -func isEventStreamResponse(header http.Header) bool { - contentType := strings.ToLower(header.Get("Content-Type")) - return strings.Contains(contentType, "text/event-stream") -} - -// bodyHasSSEFraming reports whether body contains genuine SSE framing by -// scanning for physical lines that begin with the "data:" or "event:" -// field names, per the SSE spec. Unlike a raw substring scan, this does not -// match when those strings only appear embedded inside JSON string values -// (e.g. "data: foo" quoted as part of an assistant text field), since such -// occurrences never start a physical line in a valid JSON encoding. -func bodyHasSSEFraming(body []byte) bool { - for _, line := range bytes.Split(body, []byte("\n")) { - line = bytes.TrimRight(line, "\r") - if bytes.HasPrefix(line, []byte("data:")) || bytes.HasPrefix(line, []byte("event:")) { - return true - } - } - return false -} - -func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { - bodyText := string(body) - finalResponse, ok := extractCodexFinalResponse(bodyText) - - usage := &OpenAIUsage{} - if ok { - if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed { - *usage = parsedUsage - } - // When the terminal event has an empty output array, reconstruct - // output from accumulated delta events so the client gets full content. - // gjson Array() returns empty slice for null, missing, or empty arrays. - if len(gjson.GetBytes(finalResponse, "output").Array()) == 0 { - if outputJSON, reconstructed := reconstructResponseOutputFromSSE(bodyText); reconstructed { - if patched, err := sjson.SetRawBytes(finalResponse, "output", outputJSON); err == nil { - finalResponse = patched - } - } - } - body = finalResponse - if originalModel != mappedModel { - body = s.replaceModelInResponseBody(body, mappedModel, originalModel) - } - // Correct tool calls in final response - body = s.correctToolCallsInResponseBody(body) - } else { - terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText) - if terminalOK && terminalType == "response.failed" { - msg := extractOpenAISSEErrorMessage(terminalPayload) - if msg == "" { - msg = "Upstream compact response failed" - } - return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg) - } - usage = s.parseSSEUsageFromBody(bodyText) - if originalModel != mappedModel { - bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel) - } - body = []byte(bodyText) - } - - responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - - contentType := "application/json; charset=utf-8" - if !ok { - contentType = resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "text/event-stream" - } - } - c.Data(resp.StatusCode, contentType, body) - - return &openaiNonStreamingResult{ - OpenAIUsage: usage, - usage: usage, - responseID: extractOpenAIResponseIDFromJSONBytes(body), - imageCount: countOpenAIImageOutputsFromSSEBody(bodyText), - imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText), - }, nil -} - -func extractOpenAISSETerminalEvent(body string) (string, []byte, bool) { - var terminalType string - var terminalPayload []byte - forEachOpenAISSEDataPayload(body, func(data []byte) { - if terminalPayload != nil { - return - } - eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) - switch eventType { - case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": - terminalType = eventType - terminalPayload = append([]byte(nil), data...) - } - }) - if terminalPayload != nil { - return terminalType, terminalPayload, true - } - return "", nil, false -} - -func extractOpenAISSEErrorMessage(payload []byte) string { - if len(payload) == 0 { - return "" - } - for _, path := range []string{"response.error.message", "error.message", "message"} { - if msg := strings.TrimSpace(gjson.GetBytes(payload, path).String()); msg != "" { - return sanitizeUpstreamErrorMessage(msg) - } - } - return sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(payload))) -} - -func sanitizeOpenAIResponseFailedEventForClient(payload []byte, eventType string) ([]byte, bool) { - if eventType != "response.failed" || len(payload) == 0 || !gjson.ValidBytes(payload) { - return payload, false - } - if !gjson.GetBytes(payload, "response").Exists() { - return payload, false - } - updated := payload - for _, path := range []string{ - "response.instructions", - "response.output", - "response.usage", - "response.metadata", - "response.reasoning", - "response.tools", - "response.tool_choice", - "response.parallel_tool_calls", - "response.text", - "response.truncation", - "response.max_output_tokens", - "response.incomplete_details", - } { - next, err := sjson.DeleteBytes(updated, path) - if err != nil { - return payload, false - } - updated = next - } - return updated, !bytes.Equal(updated, payload) -} - -func (s *OpenAIGatewayService) writeOpenAINonStreamingProtocolError(resp *http.Response, c *gin.Context, message string) error { - message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message)) - if message == "" { - message = "Upstream returned an invalid non-streaming response" - } - setOpsUpstreamError(c, http.StatusBadGateway, message, "") - responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") - c.JSON(http.StatusBadGateway, gin.H{ - "error": gin.H{ - "type": "upstream_error", - "message": message, - }, - }) - return fmt.Errorf("non-streaming openai protocol error: %s", message) -} - -func extractCodexFinalResponse(body string) ([]byte, bool) { - var finalResponse []byte - forEachOpenAISSEDataPayload(body, func(data []byte) { - if finalResponse != nil { - return - } - eventType := gjson.GetBytes(data, "type").String() - if eventType == "response.done" || eventType == "response.completed" { - if response := gjson.GetBytes(data, "response"); response.Exists() && response.Type == gjson.JSON && response.Raw != "" { - finalResponse = []byte(response.Raw) - } - } - }) - if finalResponse != nil { - return finalResponse, true - } - return nil, false -} - -func normalizeResponsesStreamingTerminalOutput(data []byte, acc *apicompat.BufferedResponseAccumulator, imageOutputs []json.RawMessage) ([]byte, bool) { - eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) - switch eventType { - case "response.completed", "response.done", "response.incomplete", "response.cancelled", "response.canceled": - default: - return data, false - } - - output := gjson.GetBytes(data, "response.output") - hasAccumulatedOutput := (acc != nil && acc.HasContent()) || len(imageOutputs) > 0 - if output.Exists() && output.IsArray() { - if len(output.Array()) > 0 || !hasAccumulatedOutput { - return data, false - } - } - - outputJSON := []byte("[]") - if reconstructed, ok := buildResponsesOutputJSON(acc, imageOutputs); ok { - outputJSON = reconstructed - } - updated, err := sjson.SetRawBytes(data, "response.output", outputJSON) - if err != nil { - return data, false - } - return updated, true -} - -func responsesStreamEventMayContributeToOutput(eventType string) bool { - switch eventType { - case "response.output_text.delta", - "response.output_item.added", - "response.function_call_arguments.delta", - "response.reasoning_summary_text.delta": - return true - default: - return false - } -} - -// reconstructResponseOutputFromSSE scans raw SSE body text for delta events and -// returns a JSON-encoded output array reconstructed from accumulated deltas. -// Returns (nil, false) if no content was found in deltas. -func reconstructResponseOutputFromSSE(bodyText string) ([]byte, bool) { - acc := apicompat.NewBufferedResponseAccumulator() - imageOutputs := make([]json.RawMessage, 0, 1) - seenImages := make(map[string]struct{}) - forEachOpenAISSEDataPayload(bodyText, func(data []byte) { - if imageOutput, ok := extractImageGenerationOutputFromSSEData(data, seenImages); ok { - imageOutputs = append(imageOutputs, imageOutput) - } - eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) - if responsesStreamEventMayContributeToOutput(eventType) { - var event apicompat.ResponsesStreamEvent - if err := json.Unmarshal(data, &event); err == nil { - acc.ProcessEvent(&event) - } - } - }) - return buildResponsesOutputJSON(acc, imageOutputs) -} - -func buildResponsesOutputJSON(acc *apicompat.BufferedResponseAccumulator, imageOutputs []json.RawMessage) ([]byte, bool) { - if (acc == nil || !acc.HasContent()) && len(imageOutputs) == 0 { - return nil, false - } - var output []json.RawMessage - if acc != nil && acc.HasContent() { - outputJSON, err := json.Marshal(acc.BuildOutput()) - if err == nil { - _ = json.Unmarshal(outputJSON, &output) - } - } - output = append(output, imageOutputs...) - if len(output) == 0 { - return nil, false - } - - outputJSON, err := json.Marshal(output) - if err != nil { - return nil, false - } - return outputJSON, true -} - -func extractImageGenerationOutputFromSSEData(data []byte, seen map[string]struct{}) (json.RawMessage, bool) { - if len(data) == 0 || !gjson.ValidBytes(data) { - return nil, false - } - if gjson.GetBytes(data, "type").String() != "response.output_item.done" { - return nil, false - } - item := gjson.GetBytes(data, "item") - if !item.Exists() || !item.IsObject() || item.Get("type").String() != "image_generation_call" { - return nil, false - } - if strings.TrimSpace(item.Get("result").String()) == "" { - return nil, false - } - key := strings.TrimSpace(item.Get("id").String()) - if key == "" { - key = strings.TrimSpace(item.Get("output_format").String()) + "|" + strings.TrimSpace(item.Get("result").String()) - } - if key != "" && seen != nil { - if _, exists := seen[key]; exists { - return nil, false - } - seen[key] = struct{}{} - } - return json.RawMessage(item.Raw), true -} - -func (s *OpenAIGatewayService) parseSSEUsageFromBody(body string) *OpenAIUsage { - usage := &OpenAIUsage{} - forEachOpenAISSEDataPayload(body, func(data []byte) { - s.parseSSEUsageBytes(data, usage) - }) - return usage -} - -func (s *OpenAIGatewayService) replaceModelInSSEBody(body, fromModel, toModel string) string { - lines := strings.Split(body, "\n") - for i, line := range lines { - if _, ok := extractOpenAISSEDataLine(line); !ok { - continue - } - lines[i] = s.replaceModelInSSELine(line, fromModel, toModel) - } - return strings.Join(lines, "\n") -} - -func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) { - if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled { - normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) - if err != nil { - return "", fmt.Errorf("invalid base_url: %w", err) - } - return normalized, nil - } - normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{ - AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts, - RequireAllowlist: true, - AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts, - }) - if err != nil { - return "", fmt.Errorf("invalid base_url: %w", err) - } - return normalized, nil -} - -// buildOpenAIResponsesURL 组装 OpenAI Responses 端点。 -// - base 以 /v1 结尾:追加 /responses -// - base 以其他版本段结尾(如 /v4):追加 /responses -// - base 已是 /responses:原样返回 -// - 其他情况:追加 /v1/responses -func buildOpenAIResponsesURL(base string) string { - return buildOpenAIEndpointURL(base, "/v1/responses") -} - -func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool { - if len(reqBody) == 0 { - return false - } - - inputValue, has := reqBody["input"] - if !has { - return false - } - - switch input := inputValue.(type) { - case []any: - filtered := input[:0] - changed := false - for _, item := range input { - nextItem, itemChanged, keep := sanitizeEncryptedReasoningInputItem(item) - if itemChanged { - changed = true - } - if !keep { - continue - } - filtered = append(filtered, nextItem) - } - if !changed { - return false - } - if len(filtered) == 0 { - delete(reqBody, "input") - return true - } - reqBody["input"] = filtered - return true - case []map[string]any: - filtered := input[:0] - changed := false - for _, item := range input { - nextItem, itemChanged, keep := sanitizeEncryptedReasoningInputItem(item) - if itemChanged { - changed = true - } - if !keep { - continue - } - nextMap, ok := nextItem.(map[string]any) - if !ok { - filtered = append(filtered, item) - continue - } - filtered = append(filtered, nextMap) - } - if !changed { - return false - } - if len(filtered) == 0 { - delete(reqBody, "input") - return true - } - reqBody["input"] = filtered - return true - case map[string]any: - nextItem, changed, keep := sanitizeEncryptedReasoningInputItem(input) - if !changed { - return false - } - if !keep { - delete(reqBody, "input") - return true - } - nextMap, ok := nextItem.(map[string]any) - if !ok { - return false - } - reqBody["input"] = nextMap - return true - default: - return false - } -} - -func sanitizeEncryptedReasoningInputItem(item any) (next any, changed bool, keep bool) { - inputItem, ok := item.(map[string]any) - if !ok { - return item, false, true - } - - itemType, _ := inputItem["type"].(string) - if strings.TrimSpace(itemType) != "reasoning" { - return item, false, true - } - - _, hasEncryptedContent := inputItem["encrypted_content"] - if !hasEncryptedContent { - return item, false, true - } - - delete(inputItem, "encrypted_content") - if len(inputItem) == 1 { - return nil, true, false - } - return inputItem, true, true -} - -func IsOpenAIResponsesCompactPathForTest(c *gin.Context) bool { - return isOpenAIResponsesCompactPath(c) -} - -func OpenAICompactSessionSeedKeyForTest() string { - return openAICompactSessionSeedKey -} - -func NormalizeOpenAICompactRequestBodyForTest(body []byte) ([]byte, bool, error) { - return normalizeOpenAICompactRequestBody(body) -} - -func isOpenAIResponsesCompactPath(c *gin.Context) bool { - suffix := strings.TrimSpace(openAIResponsesRequestPathSuffix(c)) - return suffix == "/compact" || strings.HasPrefix(suffix, "/compact/") -} - -func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) { - if len(body) == 0 { - return body, false, nil - } - - normalized := []byte(`{}`) - // Keep the current Codex /compact schema while still dropping request-scoped - // fields such as prompt_cache_key, store, and stream. - for _, field := range []string{ - "model", - "input", - "instructions", - "tools", - "parallel_tool_calls", - "reasoning", - "text", - "previous_response_id", - } { - value := gjson.GetBytes(body, field) - if !value.Exists() { - continue - } - next, err := sjson.SetRawBytes(normalized, field, []byte(value.Raw)) - if err != nil { - return body, false, fmt.Errorf("normalize compact body %s: %w", field, err) - } - normalized = next - } - - if bytes.Equal(bytes.TrimSpace(body), bytes.TrimSpace(normalized)) { - return body, false, nil - } - return normalized, true, nil -} - -func resolveOpenAICompactSessionID(c *gin.Context) string { - if c != nil { - if sessionID := strings.TrimSpace(c.GetHeader("session_id")); sessionID != "" { - return sessionID - } - if conversationID := strings.TrimSpace(c.GetHeader("conversation_id")); conversationID != "" { - return conversationID - } - if seed, ok := c.Get(openAICompactSessionSeedKey); ok { - if seedStr, ok := seed.(string); ok && strings.TrimSpace(seedStr) != "" { - return strings.TrimSpace(seedStr) - } - } - } - return uuid.NewString() -} - -func openAIResponsesRequestPathSuffix(c *gin.Context) string { - if c == nil || c.Request == nil || c.Request.URL == nil { - return "" - } - normalizedPath := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/") - if normalizedPath == "" { - return "" - } - idx := strings.LastIndex(normalizedPath, "/responses") - if idx < 0 { - return "" - } - suffix := normalizedPath[idx+len("/responses"):] - if suffix == "" || suffix == "/" { - return "" - } - if !strings.HasPrefix(suffix, "/") { - return "" - } - return suffix -} - -func appendOpenAIResponsesRequestPathSuffix(baseURL, suffix string) string { - trimmedBase := strings.TrimRight(strings.TrimSpace(baseURL), "/") - trimmedSuffix := strings.TrimSpace(suffix) - if trimmedBase == "" || trimmedSuffix == "" { - return trimmedBase - } - return trimmedBase + trimmedSuffix -} - -func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte { - // 使用 gjson/sjson 精确替换 model 字段,避免全量 JSON 反序列化 - if m := gjson.GetBytes(body, "model"); m.Exists() && m.Str == fromModel { - newBody, err := sjson.SetBytes(body, "model", toModel) - if err != nil { - return body - } - return newBody - } - return body -} - -func getOpenAIReasoningEffortFromReqBody(reqBody map[string]any) (value string, present bool) { - if reqBody == nil { - return "", false - } - - // Primary: reasoning.effort - if reasoning, ok := reqBody["reasoning"].(map[string]any); ok { - if effort, ok := reasoning["effort"].(string); ok { - return normalizeOpenAIReasoningEffort(effort), true - } - } - - // Fallback: some clients may use a flat field. - if effort, ok := reqBody["reasoning_effort"].(string); ok { - return normalizeOpenAIReasoningEffort(effort), true - } - - return "", false -} - -func deriveOpenAIReasoningEffortFromModel(model string) string { - if strings.TrimSpace(model) == "" { - return "" - } - - modelID := strings.TrimSpace(model) - if strings.Contains(modelID, "/") { - parts := strings.Split(modelID, "/") - modelID = parts[len(parts)-1] - } - - parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool { - switch r { - case '-', '_', ' ': - return true - default: - return false - } - }) - if len(parts) == 0 { - return "" - } - - return normalizeOpenAIReasoningEffort(parts[len(parts)-1]) -} - -type openAIRequestView struct { - body []byte - Model string - Stream bool - PromptCacheKey string - PreviousResponseID string - ServiceTier string - ReasoningEffort string - patches []openAIRequestPatch - patchesDisabled bool -} - -type openAIRequestPatch struct { - path string - delete bool - value any -} - -func newOpenAIRequestView(body []byte) openAIRequestView { - if len(body) == 0 { - return openAIRequestView{} - } - return openAIRequestView{ - body: body, - Model: strings.TrimSpace(gjson.GetBytes(body, "model").String()), - Stream: gjson.GetBytes(body, "stream").Bool(), - PromptCacheKey: strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()), - PreviousResponseID: strings.TrimSpace(gjson.GetBytes(body, "previous_response_id").String()), - ServiceTier: strings.TrimSpace(gjson.GetBytes(body, "service_tier").String()), - ReasoningEffort: strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()), - } -} - -// Decode 保留阶段一既有 full-map 行为;后续阶段会把调用点下沉到复杂分支。 -func (v openAIRequestView) Decode(c *gin.Context) (map[string]any, error) { - return getOpenAIRequestBodyMap(c, v.body) -} - -func (v *openAIRequestView) MarkPatchSet(path string, value any) { - if v == nil || v.patchesDisabled { - return - } - path = strings.TrimSpace(path) - if !isSimpleOpenAIRequestPatchPath(path) { - v.DisablePatches() - return - } - v.patches = append(v.patches, openAIRequestPatch{path: path, value: value}) -} - -func (v *openAIRequestView) MarkPatchDelete(path string) { - if v == nil || v.patchesDisabled { - return - } - path = strings.TrimSpace(path) - if !isSimpleOpenAIRequestPatchPath(path) { - v.DisablePatches() - return - } - v.patches = append(v.patches, openAIRequestPatch{path: path, delete: true}) -} - -func isSimpleOpenAIRequestPatchPath(path string) bool { - if path == "" || strings.ContainsRune(path, '\\') { - return false - } - for _, part := range strings.Split(path, ".") { - if strings.TrimSpace(part) == "" { - return false - } - } - return true -} - -func (v *openAIRequestView) DisablePatches() { - if v == nil { - return - } - v.patchesDisabled = true - v.patches = nil -} - -func (v openAIRequestView) HasPatches() bool { - return !v.patchesDisabled && len(v.patches) > 0 -} - -func (v openAIRequestView) ApplyPatches() ([]byte, error) { - if v.patchesDisabled || len(v.patches) == 0 { - return nil, errors.New("openai request patches disabled") - } - body := v.body - for _, patch := range v.patches { - var err error - if patch.delete { - body, err = sjson.DeleteBytes(body, patch.path) - } else { - body, err = sjson.SetBytes(body, patch.path, patch.value) - } - if err != nil { - return nil, err - } - } - return body, nil -} - -func setOpenAIRequestMapPath(reqBody map[string]any, path string, value any) { - path = strings.TrimSpace(path) - if reqBody == nil || path == "" { - return - } - parts := strings.Split(path, ".") - current := reqBody - for _, part := range parts[:len(parts)-1] { - part = strings.TrimSpace(part) - if part == "" { - return - } - next, _ := current[part].(map[string]any) - if next == nil { - next = map[string]any{} - current[part] = next - } - current = next - } - last := strings.TrimSpace(parts[len(parts)-1]) - if last != "" { - current[last] = value - } -} - -func deleteOpenAIRequestMapPath(reqBody map[string]any, path string) { - path = strings.TrimSpace(path) - if reqBody == nil || path == "" { - return - } - parts := strings.Split(path, ".") - current := reqBody - for _, part := range parts[:len(parts)-1] { - part = strings.TrimSpace(part) - if part == "" { - return - } - next, _ := current[part].(map[string]any) - if next == nil { - return - } - current = next - } - last := strings.TrimSpace(parts[len(parts)-1]) - if last != "" { - delete(current, last) - } -} - -func extractOpenAIRequestMetaFromBody(body []byte) (model string, stream bool, promptCacheKey string) { - view := newOpenAIRequestView(body) - return view.Model, view.Stream, view.PromptCacheKey -} - -// normalizeOpenAIPassthroughOAuthBody 将透传 OAuth 请求体收敛为旧链路关键行为: -// 1) 删除 ChatGPT internal API 不支持的顶层 Responses 参数 -// 2) store=false 3) 非 compact 保持 stream=true;compact 强制 stream=false -func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, bool, error) { - if len(body) == 0 { - return body, false, nil - } - - normalized := body - changed := false - - for _, field := range openAIChatGPTInternalUnsupportedFields { - if value := gjson.GetBytes(normalized, field); !value.Exists() { - continue - } - next, err := sjson.DeleteBytes(normalized, field) - if err != nil { - return body, false, fmt.Errorf("normalize passthrough body delete %s: %w", field, err) - } - normalized = next - changed = true - } - - if compact { - if store := gjson.GetBytes(normalized, "store"); store.Exists() { - next, err := sjson.DeleteBytes(normalized, "store") - if err != nil { - return body, false, fmt.Errorf("normalize passthrough body delete store: %w", err) - } - normalized = next - changed = true - } - if stream := gjson.GetBytes(normalized, "stream"); stream.Exists() { - next, err := sjson.DeleteBytes(normalized, "stream") - if err != nil { - return body, false, fmt.Errorf("normalize passthrough body delete stream: %w", err) - } - normalized = next - changed = true - } - } else { - if store := gjson.GetBytes(normalized, "store"); !store.Exists() || store.Type != gjson.False { - next, err := sjson.SetBytes(normalized, "store", false) - if err != nil { - return body, false, fmt.Errorf("normalize passthrough body store=false: %w", err) - } - normalized = next - changed = true - } - if stream := gjson.GetBytes(normalized, "stream"); !stream.Exists() || stream.Type != gjson.True { - next, err := sjson.SetBytes(normalized, "stream", true) - if err != nil { - return body, false, fmt.Errorf("normalize passthrough body stream=true: %w", err) - } - normalized = next - changed = true - } - } - - return normalized, changed, nil -} - -func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byte) string { - model := strings.ToLower(strings.TrimSpace(reqModel)) - if !strings.Contains(model, "codex") { - return "" - } - - instructions := gjson.GetBytes(body, "instructions") - if !instructions.Exists() { - return "instructions_missing" - } - if instructions.Type != gjson.String { - return "instructions_not_string" - } - if strings.TrimSpace(instructions.String()) == "" { - return "instructions_empty" - } - return "" -} - -func extractOpenAIReasoningEffortFromBody(body []byte, requestedModel string) *string { - reasoningEffort := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) - if reasoningEffort == "" { - reasoningEffort = strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String()) - } - if reasoningEffort != "" { - normalized := normalizeOpenAIReasoningEffort(reasoningEffort) - if normalized == "" { - return nil - } - return &normalized - } - - value := deriveOpenAIReasoningEffortFromModel(requestedModel) - if value == "" { - return nil - } - return &value -} - -func extractOpenAIServiceTier(reqBody map[string]any) *string { - if reqBody == nil { - return nil - } - raw, ok := reqBody["service_tier"].(string) - if !ok { - return nil - } - return normalizeOpenAIServiceTier(raw) -} - -func extractOpenAIServiceTierFromBody(body []byte) *string { - if len(body) == 0 { - return nil - } - return normalizeOpenAIServiceTier(gjson.GetBytes(body, "service_tier").String()) -} - -func normalizeOpenAIServiceTier(raw string) *string { - value := strings.ToLower(strings.TrimSpace(raw)) - if value == "" { - return nil - } - if value == "fast" { - value = "priority" - } - // 放过 OpenAI 官方文档定义的所有合法 tier 值:priority/flex/auto/default/scale。 - // 对 Codex 客户端零影响(Codex 只发 priority 或 flex,见 codex-rs/core/src/client.rs), - // 但能让直连 OpenAI SDK 的用户透传 auto/default/scale 以便抓包/调试。 - // 真未知值仍返回 nil,由 normalizeResponsesBodyServiceTier 从 body 中删除。 - switch value { - case "priority", "flex", "auto", "default", "scale": - return &value - default: - return nil - } -} - -// OpenAIFastBlockedError indicates a request was rejected by the OpenAI fast -// policy (action=block). Mirrors BetaBlockedError on the Claude side. -type OpenAIFastBlockedError struct { - Message string -} - -func (e *OpenAIFastBlockedError) Error() string { return e.Message } - -// evaluateOpenAIFastPolicy returns the action and error message that should be -// applied for a request with the given account/model/service_tier. When the -// policy service is unavailable or no rule matches, it returns -// (BetaPolicyActionPass, "") so callers can short-circuit safely. -// -// Matching rules: -// - Scope filters by account type (all / oauth / apikey / bedrock) -// - ServiceTier must be empty (= any), "all", or equal the normalized tier -// - ModelWhitelist narrows the rule to specific models; FallbackAction -// handles the non-matching case (default: pass) -// -// 与 Claude BetaPolicy 的差异(保留首条匹配 short-circuit): -// - BetaPolicy 处理的是 anthropic-beta header 中的 token 集合,不同 -// 规则可能针对不同 token,filter 需要累加成 set;block 则 first-match。 -// - OpenAI fast policy 操作的是单个字段 service_tier:filter 即删字段, -// 没有可累加的对象。一次请求只携带一个 service_tier,规则的 tier -// 维度天然互斥;同一 (scope, tier) 下若多条规则的 model whitelist -// 发生重叠,admin 可通过规则顺序明确意图。因此采用 first-match 而 -// 非 BetaPolicy 那样的"block 覆盖 filter 覆盖 pass"语义。 -func (s *OpenAIGatewayService) evaluateOpenAIFastPolicy(ctx context.Context, account *Account, model, serviceTier string) (action, errMsg string) { - if s == nil || s.settingService == nil { - return BetaPolicyActionPass, "" - } - tier := strings.ToLower(strings.TrimSpace(serviceTier)) - if tier == "" { - return BetaPolicyActionPass, "" - } - settings := openAIFastPolicySettingsFromContext(ctx) - if settings == nil { - fetched, err := s.settingService.GetOpenAIFastPolicySettings(ctx) - if err != nil || fetched == nil { - return BetaPolicyActionPass, "" - } - settings = fetched - } - return evaluateOpenAIFastPolicyWithSettings(settings, account, model, tier) -} - -// evaluateOpenAIFastPolicyWithSettings is the pure-function core extracted so -// long-lived sessions (e.g. WS) can prefetch settings once and avoid hitting -// the settingService on every frame. See WSSession entry and -// openAIFastPolicySettingsFromContext for the caching glue. -func evaluateOpenAIFastPolicyWithSettings(settings *OpenAIFastPolicySettings, account *Account, model, tier string) (action, errMsg string) { - if settings == nil { - return BetaPolicyActionPass, "" - } - isOAuth := account != nil && account.IsOAuth() - isBedrock := account != nil && account.IsBedrock() - for _, rule := range settings.Rules { - if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) { - continue - } - ruleTier := strings.ToLower(strings.TrimSpace(rule.ServiceTier)) - if ruleTier != "" && ruleTier != OpenAIFastTierAny && ruleTier != tier { - continue - } - eff := BetaPolicyRule{ - Action: rule.Action, - ErrorMessage: rule.ErrorMessage, - ModelWhitelist: rule.ModelWhitelist, - FallbackAction: rule.FallbackAction, - FallbackErrorMessage: rule.FallbackErrorMessage, - } - return resolveRuleAction(eff, model) - } - return BetaPolicyActionPass, "" -} - -// openAIFastPolicyCtxKey 是 context 中预取的 OpenAIFastPolicySettings 缓存 -// 键,仅用于 WebSocket 长会话内多帧复用同一份策略快照,避免每帧 DB 命中。 -// -// Trade-off:策略变更不会影响当前 WS session(只影响新 session)。这是 -// 有意为之 —— 对长会话来说,"策略一致性"比"立刻生效"更重要,且 Claude -// BetaPolicy 的 gin.Context 缓存也是同样取舍。需要 hot-reload 时管理员 -// 可以通过踢断 session 强制刷新。 -type openAIFastPolicyCtxKeyType struct{} - -var openAIFastPolicyCtxKey = openAIFastPolicyCtxKeyType{} - -// withOpenAIFastPolicyContext 将一份 settings 快照绑定到 context,供该 ctx -// 衍生 goroutine 中的 evaluateOpenAIFastPolicy 复用。 -func withOpenAIFastPolicyContext(ctx context.Context, settings *OpenAIFastPolicySettings) context.Context { - if ctx == nil || settings == nil { - return ctx - } - return context.WithValue(ctx, openAIFastPolicyCtxKey, settings) -} - -func openAIFastPolicySettingsFromContext(ctx context.Context) *OpenAIFastPolicySettings { - if ctx == nil { - return nil - } - if v, ok := ctx.Value(openAIFastPolicyCtxKey).(*OpenAIFastPolicySettings); ok { - return v - } - return nil -} - -// applyOpenAIFastPolicyToBody applies the OpenAI fast policy to a raw request -// body. When action=filter it removes the service_tier field; when -// action=block it returns (body, *OpenAIFastBlockedError). On pass it -// normalizes the service_tier value (e.g. client alias "fast" → "priority"). -// action=force_priority rewrites any matched known tier to "priority". -// -// Rationale for normalize-on-pass: chat-completions / messages 入口在调用本 -// 函数之前已经通过 normalizeResponsesBodyServiceTier 把 service_tier 归一化 -// 到了上游可识别值;passthrough(OpenAI 自动透传) / native /responses 等 -// 入口没有这一前置步骤,pass 路径下若不在此处归一化,"fast" 就会被原样 -// 透传到 OpenAI 上游导致 400/拒绝。把归一化收敛到本函数,所有入口行为一致。 -func (s *OpenAIGatewayService) applyOpenAIFastPolicyToBody(ctx context.Context, account *Account, model string, body []byte) ([]byte, error) { - if len(body) == 0 { - return body, nil - } - rawTier := gjson.GetBytes(body, "service_tier").String() - if rawTier == "" { - return body, nil - } - normTier := normalizedOpenAIServiceTierValue(rawTier) - if normTier == "" { - return body, nil - } - action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, model, normTier) - switch action { - case BetaPolicyActionBlock: - msg := errMsg - if msg == "" { - msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, model) - } - return body, &OpenAIFastBlockedError{Message: msg} - case BetaPolicyActionFilter: - trimmed, err := sjson.DeleteBytes(body, "service_tier") - if err != nil { - return body, fmt.Errorf("strip service_tier from body: %w", err) - } - return trimmed, nil - case OpenAIFastPolicyActionForcePriority: - updated, err := sjson.SetBytes(body, "service_tier", OpenAIFastTierPriority) - if err != nil { - return body, fmt.Errorf("force service_tier priority on body: %w", err) - } - return updated, nil - default: - // pass:把别名(如 "fast")写回为规范值("priority")。 - if normTier == rawTier { - return body, nil - } - updated, err := sjson.SetBytes(body, "service_tier", normTier) - if err != nil { - return body, fmt.Errorf("normalize service_tier on pass: %w", err) - } - return updated, nil - } -} - -// writeOpenAIFastPolicyBlockedResponse writes a 403 JSON response for a -// request blocked by the OpenAI fast policy. -func writeOpenAIFastPolicyBlockedResponse(c *gin.Context, err *OpenAIFastBlockedError) { - if c == nil || err == nil { - return - } - MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) - c.JSON(http.StatusForbidden, gin.H{ - "error": gin.H{ - "type": "permission_error", - "message": err.Message, - }, - }) -} - -// applyOpenAIFastPolicyToWSResponseCreate evaluates the OpenAI fast policy -// against a single client→upstream WebSocket frame whose top-level -// "type"=="response.create". It mirrors the HTTP-side -// applyOpenAIFastPolicyToBody contract but operates on a Realtime/Responses -// WS payload: -// -// - pass: keeps service_tier, normalizing aliases such as "fast" to "priority" -// - filter: returns a copy with top-level service_tier removed -// - force_priority: keeps service_tier and rewrites it to "priority" -// - block: returns (frame, *OpenAIFastBlockedError) -// -// Only frames whose "type" field strictly equals "response.create" are -// inspected/mutated. Any other frame type — including the empty string — -// passes through untouched. The OpenAI Realtime client-event spec requires -// "type" to be set, so an empty type is treated as a malformed frame we do -// not police; the upstream is the source of truth for rejecting it. -// -// service_tier lives at the top level of response.create — same as the -// Responses HTTP body shape (see openai_gateway_chat_completions.go:304 + -// extractOpenAIServiceTierFromBody at line 5593, and the test fixture at -// openai_ws_forwarder_ingress_session_test.go:402). We therefore only need -// to inspect / strip the top-level field; there is no nested form in the -// schema today. -// -// The caller is responsible for choosing the upstream model passed in — -// this helper does not re-derive it. -func (s *OpenAIGatewayService) applyOpenAIFastPolicyToWSResponseCreate( - ctx context.Context, - account *Account, - model string, - frame []byte, -) ([]byte, *OpenAIFastBlockedError, error) { - if len(frame) == 0 { - return frame, nil, nil - } - if !gjson.ValidBytes(frame) { - return frame, nil, nil - } - frameType := strings.TrimSpace(gjson.GetBytes(frame, "type").String()) - // Strict match: only response.create is policy-checked. Empty / other - // types pass through untouched so we never accidentally strip fields - // from response.cancel, conversation.item.create, or any future - // client-event the spec adds. The Realtime spec requires "type" on - // every client event, so an empty type is malformed input — let the - // upstream reject it rather than guessing at our layer. - if frameType != "response.create" { - return frame, nil, nil - } - rawTier := gjson.GetBytes(frame, "service_tier").String() - if rawTier == "" { - return frame, nil, nil - } - normTier := normalizedOpenAIServiceTierValue(rawTier) - if normTier == "" { - return frame, nil, nil - } - action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, model, normTier) - switch action { - case BetaPolicyActionBlock: - msg := errMsg - if msg == "" { - msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, model) - } - return frame, &OpenAIFastBlockedError{Message: msg}, nil - case BetaPolicyActionFilter: - trimmed, err := sjson.DeleteBytes(frame, "service_tier") - if err != nil { - return frame, nil, fmt.Errorf("strip service_tier from ws frame: %w", err) - } - return trimmed, nil, nil - case OpenAIFastPolicyActionForcePriority: - updated, err := sjson.SetBytes(frame, "service_tier", OpenAIFastTierPriority) - if err != nil { - return frame, nil, fmt.Errorf("force service_tier priority in ws frame: %w", err) - } - return updated, nil, nil - default: - if normTier == rawTier { - return frame, nil, nil - } - updated, err := sjson.SetBytes(frame, "service_tier", normTier) - if err != nil { - return frame, nil, fmt.Errorf("normalize service_tier in ws frame: %w", err) - } - return updated, nil, nil - } -} - -// newOpenAIFastPolicyWSEventID returns a Realtime-style event_id for a -// server-emitted error event. Matches the loose "evt_" convention used -// by upstream Realtime servers; the exact value is not load-bearing and is -// only required for client-side log correlation. We reuse the existing -// google/uuid dependency rather than pulling a new one. -func newOpenAIFastPolicyWSEventID() string { - id, err := uuid.NewRandom() - if err != nil { - // Extremely unlikely; fall back to a fixed prefix so the field is - // still non-empty and the schema stays self-consistent. - return "evt_openai_fast_policy" - } - // Strip dashes so it visually matches "evt_" rather than UUID v4 - // canonical form, mirroring what real Realtime traces look like. - return "evt_" + strings.ReplaceAll(id.String(), "-", "") -} - -// buildOpenAIFastPolicyBlockedWSEvent renders an OpenAI Realtime/Responses -// style "error" event payload for a request blocked by the OpenAI fast -// policy. The shape mirrors Realtime error events as observed in upstream -// traces and per the spec's server "error" event: -// -// { -// "event_id": "evt_", -// "type": "error", -// "error": { -// "type": "invalid_request_error", -// "code": "policy_violation", -// "message": "..." -// } -// } -// -// event_id lets clients correlate the rejection in their logs; "code" gives -// programmatic clients a stable identifier (HTTP-side equivalent is the -// 403 permission_error JSON body). -func buildOpenAIFastPolicyBlockedWSEvent(err *OpenAIFastBlockedError) []byte { - if err == nil { - return nil - } - eventID := newOpenAIFastPolicyWSEventID() - payload, mErr := json.Marshal(map[string]any{ - "event_id": eventID, - "type": "error", - "error": map[string]any{ - "type": "invalid_request_error", - "code": "policy_violation", - "message": err.Message, - }, - }) - if mErr != nil { - // Fallback to a minimal hand-rolled payload; Marshal of the literal - // shape above should never fail in practice. - return []byte(`{"event_id":"` + eventID + `","type":"error","error":{"type":"invalid_request_error","code":"policy_violation","message":"openai fast policy blocked this request"}}`) - } - return payload -} - -func openAIRequestBodyMayContainImageInput(body []byte) bool { - if len(body) == 0 { - return false - } - input := gjson.GetBytes(body, "input") - messages := gjson.GetBytes(body, "messages.#-1") - return openAIJSONValueMayContainImageInput(input) || openAIJSONValueMayContainImageInput(messages) -} - -func openAIJSONValueMayContainImageInput(value gjson.Result) bool { - if !value.Exists() { - return false - } - if value.IsArray() { - found := false - value.ForEach(func(_, item gjson.Result) bool { - if openAIJSONValueMayContainImageInput(item) { - found = true - return false - } - return true - }) - return found - } - if value.IsObject() { - if strings.TrimSpace(value.Get("type").String()) == "input_image" || value.Get("image_url").Exists() { - return true - } - return openAIJSONValueMayContainImageInput(value.Get("content")) - } - return false -} - -func openAIRequestBodyMayContainEmptyBase64InputImage(body []byte) bool { - if len(body) == 0 || !openAIRequestBodyMayContainInputImageToken(body) { - return false - } - input := gjson.GetBytes(body, "input") - if !input.Exists() { - return false - } - return openAIJSONValueMayContainEmptyBase64InputImage(input) -} - -func openAIRequestBodyMayContainInputImageToken(body []byte) bool { - if bytes.Contains(body, []byte("input_image")) { - return true - } - // JSON 字符串任意字符都可能被 unicode escape,遇到 \u 时交给 gjson 解码后的结构扫描兜底。 - return bytes.Contains(body, []byte("\\u")) -} - -func openAIJSONValueMayContainEmptyBase64InputImage(value gjson.Result) bool { - if !value.Exists() { - return false - } - if value.IsArray() { - found := false - value.ForEach(func(_, item gjson.Result) bool { - if openAIJSONValueMayContainEmptyBase64InputImage(item) { - found = true - return false - } - return true - }) - return found - } - if value.IsObject() { - if strings.TrimSpace(value.Get("type").String()) == "input_image" && isEmptyBase64DataURI(value.Get("image_url").String()) { - return true - } - return openAIJSONValueMayContainEmptyBase64InputImage(value.Get("content")) - } - return false -} - -func sanitizeEmptyBase64InputImagesInOpenAIBody(body []byte) ([]byte, bool, error) { - if !openAIRequestBodyMayContainEmptyBase64InputImage(body) { - return body, false, nil - } - - var reqBody map[string]any - if err := json.Unmarshal(body, &reqBody); err != nil { - return body, false, fmt.Errorf("sanitize request body: %w", err) - } - if !sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody) { - return body, false, nil - } - normalized, err := marshalOpenAIUpstreamJSON(reqBody) - if err != nil { - return body, false, fmt.Errorf("serialize sanitized request body: %w", err) - } - return normalized, true, nil -} - -func sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody map[string]any) bool { - if reqBody == nil { - return false - } - input, ok := reqBody["input"] - if !ok { - return false - } - normalizedInput, changed := sanitizeEmptyBase64InputImagesInOpenAIInput(input) - if !changed { - return false - } - reqBody["input"] = normalizedInput - return true -} - -func sanitizeEmptyBase64InputImagesInOpenAIInput(input any) (any, bool) { - items, ok := input.([]any) - if !ok { - return input, false - } - - normalizedItems := make([]any, 0, len(items)) - changed := false - for _, item := range items { - itemMap, ok := item.(map[string]any) - if !ok { - normalizedItems = append(normalizedItems, item) - continue - } - if shouldDropEmptyBase64InputImagePart(itemMap) { - changed = true - continue - } - content, ok := itemMap["content"] - if !ok { - normalizedItems = append(normalizedItems, itemMap) - continue - } - parts, ok := content.([]any) - if !ok { - normalizedItems = append(normalizedItems, itemMap) - continue - } - - normalizedParts := make([]any, 0, len(parts)) - itemChanged := false - for _, part := range parts { - if shouldDropEmptyBase64InputImagePart(part) { - changed = true - itemChanged = true - continue - } - normalizedParts = append(normalizedParts, part) - } - if itemChanged { - if len(normalizedParts) == 0 { - continue - } - itemMap["content"] = normalizedParts - } - normalizedItems = append(normalizedItems, itemMap) - } - if !changed { - return input, false - } - return normalizedItems, true -} - -func shouldDropEmptyBase64InputImagePart(part any) bool { - partMap, ok := part.(map[string]any) - if !ok { - return false - } - typeValue, _ := partMap["type"].(string) - if strings.TrimSpace(typeValue) != "input_image" { - return false - } - imageURL, _ := partMap["image_url"].(string) - return isEmptyBase64DataURI(imageURL) -} - -func isEmptyBase64DataURI(raw string) bool { - if !strings.HasPrefix(raw, "data:") { - return false - } - rest := strings.TrimPrefix(raw, "data:") - semicolonIdx := strings.Index(rest, ";") - if semicolonIdx < 0 { - return false - } - rest = rest[semicolonIdx+1:] - if !strings.HasPrefix(rest, "base64,") { - return false - } - return strings.TrimSpace(strings.TrimPrefix(rest, "base64,")) == "" -} - -func getOpenAIRequestBodyMap(_ *gin.Context, body []byte) (map[string]any, error) { - var reqBody map[string]any - if err := json.Unmarshal(body, &reqBody); err != nil { - return nil, fmt.Errorf("parse request: %w", err) - } - return reqBody, nil -} - -func extractOpenAIReasoningEffort(reqBody map[string]any, requestedModel string) *string { - if value, present := getOpenAIReasoningEffortFromReqBody(reqBody); present { - if value == "" { - return nil - } - return &value - } - - value := deriveOpenAIReasoningEffortFromModel(requestedModel) - if value == "" { - return nil - } - return &value -} - -func normalizeOpenAIReasoningEffort(raw string) string { - value := strings.ToLower(strings.TrimSpace(raw)) - if value == "" { - return "" - } - - // Normalize separators for "x-high"/"x_high" variants. - value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) - - switch value { - case "none", "minimal": - return "" - case "low", "medium", "high": - return value - case "xhigh", "extrahigh", "max": - return "xhigh" - default: - // Only store known effort levels for now to keep UI consistent. - return "" - } -} diff --git a/backend/internal/service/openai_gateway_upstream_errors.go b/backend/internal/service/openai_gateway_upstream_errors.go new file mode 100644 index 0000000000..2e661e7417 --- /dev/null +++ b/backend/internal/service/openai_gateway_upstream_errors.go @@ -0,0 +1,597 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "go.uber.org/zap" +) + +func logOpenAIInstructionsRequiredDebug( + ctx context.Context, + c *gin.Context, + account *Account, + upstreamStatusCode int, + upstreamMsg string, + requestBody []byte, + upstreamBody []byte, +) { + msg := strings.TrimSpace(upstreamMsg) + if !isOpenAIInstructionsRequiredError(upstreamStatusCode, msg, upstreamBody) { + return + } + if ctx == nil { + ctx = context.Background() + } + + accountID := int64(0) + accountName := "" + if account != nil { + accountID = account.ID + accountName = strings.TrimSpace(account.Name) + } + + userAgent := "" + originator := "" + if c != nil { + userAgent = strings.TrimSpace(c.GetHeader("User-Agent")) + originator = strings.TrimSpace(c.GetHeader("originator")) + } + + fields := []zap.Field{ + zap.String("component", "service.openai_gateway"), + zap.Int64("account_id", accountID), + zap.String("account_name", accountName), + zap.Int("upstream_status_code", upstreamStatusCode), + zap.String("upstream_error_message", msg), + zap.String("request_user_agent", userAgent), + zap.Bool("codex_official_client_match", openai.IsCodexOfficialClientByHeaders(userAgent, originator)), + } + fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, requestBody) + + logger.FromContext(ctx).With(fields...).Warn("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查") +} + +func isOpenAIInstructionsRequiredError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool { + if upstreamStatusCode != http.StatusBadRequest { + return false + } + + hasInstructionRequired := func(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false + } + if strings.Contains(lower, "instructions are required") { + return true + } + if strings.Contains(lower, "required parameter: 'instructions'") { + return true + } + if strings.Contains(lower, "required parameter: instructions") { + return true + } + if strings.Contains(lower, "missing required parameter") && strings.Contains(lower, "instructions") { + return true + } + return strings.Contains(lower, "instruction") && strings.Contains(lower, "required") + } + + if hasInstructionRequired(upstreamMsg) { + return true + } + if len(upstreamBody) == 0 { + return false + } + + errMsg := gjson.GetBytes(upstreamBody, "error.message").String() + errMsgLower := strings.ToLower(strings.TrimSpace(errMsg)) + errCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.code").String())) + errParam := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.param").String())) + errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.type").String())) + + if errParam == "instructions" { + return true + } + if hasInstructionRequired(errMsg) { + return true + } + if strings.Contains(errCode, "missing_required_parameter") && strings.Contains(errMsgLower, "instructions") { + return true + } + if strings.Contains(errType, "invalid_request") && strings.Contains(errMsgLower, "instructions") && strings.Contains(errMsgLower, "required") { + return true + } + + return false +} + +func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool { + if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable { + return false + } + + hasOpenAIServerOverloadedCode := func(payload []byte) bool { + code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String())) + if code == "" { + code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String())) + } + return code == "server_is_overloaded" || code == "slow_down" + } + + if len(upstreamBody) > 0 && hasOpenAIServerOverloadedCode(upstreamBody) { + return true + } + if upstreamStatusCode != http.StatusBadRequest { + return false + } + + match := func(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false + } + if strings.Contains(lower, "an error occurred while processing your request") { + return true + } + if strings.Contains(lower, "selected model is at capacity") { + return true + } + return strings.Contains(lower, "you can retry your request") && + strings.Contains(lower, "help.openai.com") && + strings.Contains(lower, "request id") + } + + if match(upstreamMsg) { + return true + } + if len(upstreamBody) == 0 { + return false + } + if match(gjson.GetBytes(upstreamBody, "error.message").String()) { + return true + } + return match(string(upstreamBody)) +} + +func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool { + match := func(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false + } + if strings.Contains(lower, "context_too_large") || strings.Contains(lower, "context_length_exceeded") { + return true + } + if strings.Contains(lower, "maximum context length") || strings.Contains(lower, "max context length") { + return true + } + hasExceeded := strings.Contains(lower, "exceed") || strings.Contains(lower, "too large") || strings.Contains(lower, "too long") + if strings.Contains(lower, "context window") && hasExceeded { + return true + } + if strings.Contains(lower, "context length") && hasExceeded { + return true + } + return strings.Contains(lower, "token limit") && + strings.Contains(lower, "context") && + hasExceeded + } + + if match(upstreamMsg) { + return true + } + if len(upstreamBody) == 0 { + return false + } + for _, path := range []string{ + "error.message", + "response.error.message", + "message", + "error.code", + "response.error.code", + "code", + } { + if match(gjson.GetBytes(upstreamBody, path).String()) { + return true + } + } + return match(string(upstreamBody)) +} + +func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool { + switch statusCode { + case 401, 402, 403, 429, 529: + return true + default: + return statusCode >= 500 + } +} + +func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode int, upstreamMsg string, upstreamBody []byte) bool { + if isOpenAIContextWindowError(upstreamMsg, upstreamBody) { + return false + } + if s.shouldFailoverUpstreamError(statusCode) { + return true + } + return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody) +} + +func marshalOpenAIUpstreamJSON(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + out := buf.Bytes() + if len(out) > 0 && out[len(out)-1] == '\n' { + out = out[:len(out)-1] + } + return out, nil +} + +func openAIUpstreamErrorBodyReadLimitForConfig(cfg *config.Config) int64 { + limit := openAIUpstreamErrorBodyReadLimit + if cfg != nil && cfg.Gateway.LogUpstreamErrorBody && cfg.Gateway.LogUpstreamErrorBodyMaxBytes > int(limit) { + limit = int64(cfg.Gateway.LogUpstreamErrorBodyMaxBytes) + } + return limit +} + +func (s *OpenAIGatewayService) readUpstreamErrorBody(resp *http.Response) []byte { + if resp == nil || resp.Body == nil { + return nil + } + cfg := (*config.Config)(nil) + if s != nil { + cfg = s.cfg + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, openAIUpstreamErrorBodyReadLimitForConfig(cfg))) + return body +} + +func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, responseBody []byte, requestedModel ...string) { + if len(requestedModel) > 0 { + s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody, requestedModel[0]) + return + } + s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody) +} + +func (s *OpenAIGatewayService) handleErrorResponse( + ctx context.Context, + resp *http.Response, + c *gin.Context, + account *Account, + requestBody []byte, + requestedModel ...string, +) (*OpenAIForwardResult, error) { + body := s.readUpstreamErrorBody(resp) + + // cyber_policy 硬阻断:透传上游原始错误体给客户端(不重包成通用 502),不冷却账号。 + // 当前请求恒透传(需求1);标记供 handler 事后写风控/邮件。400 cyber 不可 failover + // (shouldFailoverUpstreamError(400)=false),故走到此处即可安全早返回。 + if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit { + MarkOpsCyberPolicy(c, CyberPolicyMark{ + Code: code, + Message: cyberMsg, + Body: truncateString(string(body), 4096), + UpstreamStatus: resp.StatusCode, + }) + setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048)) + writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + c.Data(resp.StatusCode, contentType, body) + if cyberMsg == "" { + return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode) + } + return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg) + } + + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(body), maxBytes) + } + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body) + + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + logger.LegacyPrintf("service.openai_gateway", + "OpenAI upstream error %d (account=%d platform=%s type=%s): %s", + resp.StatusCode, + account.ID, + account.Platform, + account.Type, + truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), + ) + } + + if status, errType, errMsg, matched := applyErrorPassthroughRule( + c, + PlatformOpenAI, + resp.StatusCode, + body, + http.StatusBadGateway, + "upstream_error", + "Upstream request failed", + ); matched { + MarkResponseCommitted(c) + c.JSON(status, gin.H{ + "error": gin.H{ + "type": errType, + "message": errMsg, + }, + }) + if upstreamMsg == "" { + upstreamMsg = errMsg + } + if upstreamMsg == "" { + return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg) + } + + // Check custom error codes + if !account.ShouldHandleErrorCode(resp.StatusCode) { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + MarkResponseCommitted(c) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "type": "upstream_error", + "message": "Upstream gateway error", + }, + }) + if upstreamMsg == "" { + return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg) + } + + // Handle upstream error (mark account status) + var reqModel string + if len(requestedModel) > 0 { + reqModel = strings.TrimSpace(requestedModel[0]) + } + if reqModel == "" { + reqModel, _, _ = extractOpenAIRequestMetaFromBody(requestBody) + } + shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel) + kind := "http_error" + if shouldDisable { + kind = "failover" + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: kind, + Message: upstreamMsg, + Detail: upstreamDetail, + }) + if shouldDisable { + return nil, &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: body, + RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + } + } + + MarkResponseCommitted(c) + + // Return appropriate error response + var errType, errMsg string + var statusCode int + + switch resp.StatusCode { + case 401: + statusCode = http.StatusBadGateway + errType = "upstream_error" + errMsg = "Upstream authentication failed, please contact administrator" + case 402: + statusCode = http.StatusBadGateway + errType = "upstream_error" + errMsg = "Upstream payment required: insufficient balance or billing issue" + case 403: + statusCode = http.StatusBadGateway + errType = "upstream_error" + errMsg = "Upstream access forbidden, please contact administrator" + case 429: + statusCode = http.StatusTooManyRequests + errType = "rate_limit_error" + errMsg = "Upstream rate limit exceeded, please retry later" + default: + statusCode = http.StatusBadGateway + errType = "upstream_error" + errMsg = "Upstream request failed" + } + if isOpenAIContextWindowError(upstreamMsg, body) && upstreamMsg != "" { + errMsg = upstreamMsg + } + + c.JSON(statusCode, gin.H{ + "error": gin.H{ + "type": errType, + "message": errMsg, + }, + }) + + if upstreamMsg == "" { + return nil, fmt.Errorf("upstream error: %d", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg) +} + +// compatErrorWriter is the signature for format-specific error writers used by +// the compat paths (Chat Completions and Anthropic Messages). +type compatErrorWriter func(c *gin.Context, statusCode int, errType, message string) + +// handleCompatErrorResponse is the shared non-failover error handler for the +// Chat Completions and Anthropic Messages compat paths. It mirrors the logic of +// handleErrorResponse (passthrough rules, ShouldHandleErrorCode, rate-limit +// tracking, secondary failover) but delegates the final error write to the +// format-specific writer function. +func (s *OpenAIGatewayService) handleCompatErrorResponse( + resp *http.Response, + c *gin.Context, + account *Account, + writeError compatErrorWriter, + requestedModel ...string, +) (*OpenAIForwardResult, error) { + body := s.readUpstreamErrorBody(resp) + + // cyber_policy:兼容路径(Chat Completions / Anthropic)以各自格式回写错误, + // 不原样透传 responses 格式的 cyber body(否则对下游格式不合法)。cyber 是上游网络 + // 安全策略拦截,不冷却账号,故标记后直接以兼容格式回写错误并返回,跳过下方 + // handleOpenAIAccountUpstreamError(避免自定义 temp-unschedulable 规则误冷却)。 + if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit { + MarkOpsCyberPolicy(c, CyberPolicyMark{ + Code: code, + Message: cyberMsg, + Body: truncateString(string(body), 4096), + UpstreamStatus: resp.StatusCode, + }) + setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048)) + clientMsg := cyberMsg + if clientMsg == "" { + clientMsg = "Request blocked by upstream cyber-security policy" + } + writeError(c, resp.StatusCode, "invalid_request_error", clientMsg) + if cyberMsg == "" { + return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode) + } + return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg) + } + + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) + if upstreamMsg == "" { + upstreamMsg = fmt.Sprintf("Upstream error: %d", resp.StatusCode) + } + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(body), maxBytes) + } + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + + // Apply error passthrough rules + if status, errType, errMsg, matched := applyErrorPassthroughRule( + c, account.Platform, resp.StatusCode, body, + http.StatusBadGateway, "api_error", "Upstream request failed", + ); matched { + MarkResponseCommitted(c) + writeError(c, status, errType, errMsg) + if upstreamMsg == "" { + upstreamMsg = errMsg + } + if upstreamMsg == "" { + return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg) + } + + // Check custom error codes — if the account does not handle this status, + // return a generic error without exposing upstream details. + if !account.ShouldHandleErrorCode(resp.StatusCode) { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + MarkResponseCommitted(c) + writeError(c, http.StatusInternalServerError, "api_error", "Upstream gateway error") + if upstreamMsg == "" { + return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg) + } + + // Track rate limits and decide whether to trigger secondary failover. + var modelForCooldown string + if len(requestedModel) > 0 { + modelForCooldown = requestedModel[0] + } + shouldDisable := s.handleOpenAIAccountUpstreamError( + c.Request.Context(), account, resp.StatusCode, resp.Header, body, modelForCooldown, + ) + kind := "http_error" + if shouldDisable { + kind = "failover" + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: kind, + Message: upstreamMsg, + Detail: upstreamDetail, + }) + if shouldDisable { + return nil, &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: body, + RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + } + } + + MarkResponseCommitted(c) + + // Map status code to error type and write response + errType := "api_error" + switch { + case resp.StatusCode == 400: + errType = "invalid_request_error" + case resp.StatusCode == 404: + errType = "not_found_error" + case resp.StatusCode == 429: + errType = "rate_limit_error" + case resp.StatusCode >= 500: + errType = "api_error" + } + + writeError(c, resp.StatusCode, errType, upstreamMsg) + return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg) +}