package handler import ( "context" "encoding/json" "errors" "fmt" "net/http" "runtime/debug" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/securityaudit" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/tidwall/gjson" "go.uber.org/zap" ) // OpenAIGatewayHandler handles OpenAI API gateway requests type OpenAIGatewayHandler struct { gatewayService *service.OpenAIGatewayService billingCacheService *service.BillingCacheService apiKeyService *service.APIKeyService usageRecordWorkerPool *service.UsageRecordWorkerPool errorPassthroughService *service.ErrorPassthroughService contentModerationService *service.ContentModerationService securityAuditCoordinator *securityaudit.Coordinator grokMediaEligibilityProber grokMediaEligibilityProber opsService *service.OpsService concurrencyHelper *ConcurrencyHelper imageLimiter *imageConcurrencyLimiter maxAccountSwitches int cfg *config.Config } type openAIWSTurnChannelMappingSnapshot struct { turn int mapping service.ChannelMappingResult } var errOpenAIWSUnsupportedModelSwitch = errors.New("selected account does not support websocket model switch") func newOpenAIWSUnsupportedModelSwitchError(model string) error { cause := fmt.Errorf("%w: model %q", errOpenAIWSUnsupportedModelSwitch, strings.TrimSpace(model)) return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "model switch requires reconnect", cause) } func shouldReportOpenAIWSProxyAccountFailure(err error) bool { return err != nil && !errors.Is(err, errOpenAIWSUnsupportedModelSwitch) } func openAIWSTurnBillingModel(result *service.OpenAIForwardResult, mapping service.ChannelMappingResult, requestedModel, upstreamModel string) string { billingModel := "" if result != nil { billingModel = strings.TrimSpace(result.BillingModel) } if billingModel == "" { billingModel = strings.TrimSpace(upstreamModel) } if billingModel == "" { billingModel = strings.TrimSpace(requestedModel) } requestedModel = strings.TrimSpace(requestedModel) switch mapping.BillingModelSource { case service.BillingModelSourceRequested: if requestedModel != "" { billingModel = requestedModel } case service.BillingModelSourceChannelMapped: mappedModel := strings.TrimSpace(mapping.MappedModel) if mappedModel != "" && mappedModel != requestedModel { billingModel = mappedModel } } return billingModel } type grokMediaEligibilityProber interface { ProbeMediaEligibility(ctx context.Context, accountID int64) (bool, string, error) } const maxOpenAIFirstOutputTimeoutSwitches = 1 func openAIForwardSucceededForScheduling(result *service.OpenAIForwardResult) bool { return result.SucceededForScheduling() } func resolveOpenAIMessagesDispatchMappedModel(c *gin.Context, apiKey *service.APIKey, requestedModel string) string { if apiKey == nil || apiKey.Group == nil { return "" } // composite 解析到 grok/CN 目标时调度级映射不适用(Group 级映射的 gpt-5.x // 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。 if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil { if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok && (platform == service.PlatformGrok || service.IsCNProvider(platform)) { return "" } } return strings.TrimSpace(apiKey.Group.ResolveMessagesDispatchModel(requestedModel)) } type openAIModelBodyReplaceFunc func([]byte, string) []byte func openAIModelMappedBody(body []byte, mapped bool, mappedModel string, replace openAIModelBodyReplaceFunc) []byte { if !mapped || replace == nil { return body } return replace(body, mappedModel) } func seedOpenAIForwardImageIntentHint(c *gin.Context, channelMapped bool, imageIntent bool) { if channelMapped { // 渠道映射改变了规范请求,保持 unknown,由 Forward 按映射后的 model/body 初始化。 return } service.SetOpenAIImageIntentHint(c, imageIntent) } func newOpenAIModelMappedBodyCache(body []byte, replace openAIModelBodyReplaceFunc) func(bool, string) []byte { replacedBodies := make(map[string][]byte) return func(mapped bool, mappedModel string) []byte { if !mapped { return body } if cachedBody, ok := replacedBodies[mappedModel]; ok { return cachedBody } replacedBody := openAIModelMappedBody(body, true, mappedModel, replace) replacedBodies[mappedModel] = replacedBody return replacedBody } } func usageRecordContext(parent context.Context, base context.Context) context.Context { if base == nil { base = context.Background() } if parent == nil { return base } if clientRequestID, _ := parent.Value(ctxkey.ClientRequestID).(string); strings.TrimSpace(clientRequestID) != "" { base = context.WithValue(base, ctxkey.ClientRequestID, strings.TrimSpace(clientRequestID)) } if requestID, _ := parent.Value(ctxkey.RequestID).(string); strings.TrimSpace(requestID) != "" { base = context.WithValue(base, ctxkey.RequestID, strings.TrimSpace(requestID)) } return base } func wrapUsageRecordTaskContext(parent context.Context, task service.UsageRecordTask) service.UsageRecordTask { if task == nil { return nil } return func(ctx context.Context) { task(usageRecordContext(parent, ctx)) } } func openAICompatibleRequestPlatform(ctx context.Context, apiKey *service.APIKey) string { if platform, ok := service.ResolvedTargetPlatformFromContext(ctx); ok { // 保留 grok 与国产供应商原值,其他归一为 openai(与调度器精确匹配语义一致)。 return service.NormalizeOpenAICompatiblePlatform(platform) } if apiKey != nil && apiKey.Group != nil { return service.NormalizeOpenAICompatiblePlatform(apiKey.Group.Platform) } return service.PlatformOpenAI } func openAIResponsesRequiredCapability(imageIntent bool, platform string) service.OpenAIEndpointCapability { if imageIntent && platform == service.PlatformOpenAI { return service.OpenAIEndpointCapabilityResponses } return service.OpenAIEndpointCapabilityChatCompletions } // openAIResponsesRequiredCapabilityForRequest returns the endpoint capability // required by an image or Responses request. needsResponses includes both the // legacy /responses/compact endpoint and native remote compaction v2. func openAIResponsesRequiredCapabilityForRequest(imageIntent bool, needsResponses bool, platform string) service.OpenAIEndpointCapability { if needsResponses && platform == service.PlatformOpenAI { return service.OpenAIEndpointCapabilityResponses } return openAIResponsesRequiredCapability(imageIntent, platform) } func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKey) bool { if apiKey == nil || apiKey.Group == nil { return true } if apiKey.Group.Platform == service.PlatformGrok { return true } // 国产供应商分组与 grok 同语义:/v1/messages 就是其主要服务形态(anthropic // 协议账号原生直通 Claude Code),无需 allow_messages_dispatch 开关授权—— // 该开关对非 openai 平台恒被 sanitizeGroupMessagesDispatchFields 置 false, // 若不豁免,CN 分组将永远 403。 if service.IsCNProvider(apiKey.Group.Platform) { return true } // composite 分组解析到 grok/CN 目标时与对应独立分组同语义豁免:sanitize // 对 composite 同样恒置 false,不豁免则这些目标的 /v1/messages 永远 403; // 解析到 openai 目标仍受开关控制,维持现状。 if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil { if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok && (platform == service.PlatformGrok || service.IsCNProvider(platform)) { return true } } return apiKey.Group.AllowMessagesDispatch } func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool { return compositeTargetPlatformAllowed(c, apiKey, model, service.PlatformOpenAI, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek) } // isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket // 上可服务的目标平台。CN 供应商(kimi/zhipu/deepseek)刻意排除:其账号无法通过 // WSv2 ingress 的 transport 过滤,且 WS HTTP 桥没有面向 CN 的 Responses 转换, // 放行只会把明确的策略拒绝变成误导性的 "no available account"。 func isResponsesWebSocketCompositePlatform(platform string) bool { switch platform { case service.PlatformOpenAI, service.PlatformGrok: return true default: return false } } // NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler func NewOpenAIGatewayHandler( gatewayService *service.OpenAIGatewayService, concurrencyService *service.ConcurrencyService, billingCacheService *service.BillingCacheService, apiKeyService *service.APIKeyService, usageRecordWorkerPool *service.UsageRecordWorkerPool, errorPassthroughService *service.ErrorPassthroughService, contentModerationService *service.ContentModerationService, opsService *service.OpsService, cfg *config.Config, ) *OpenAIGatewayHandler { pingInterval := time.Duration(0) maxAccountSwitches := 3 if cfg != nil { pingInterval = time.Duration(cfg.Concurrency.PingInterval) * time.Second if cfg.Gateway.MaxAccountSwitches > 0 { maxAccountSwitches = cfg.Gateway.MaxAccountSwitches } } return &OpenAIGatewayHandler{ gatewayService: gatewayService, billingCacheService: billingCacheService, apiKeyService: apiKeyService, usageRecordWorkerPool: usageRecordWorkerPool, errorPassthroughService: errorPassthroughService, contentModerationService: contentModerationService, opsService: opsService, concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatComment, pingInterval), imageLimiter: &imageConcurrencyLimiter{}, maxAccountSwitches: maxAccountSwitches, cfg: cfg, } } // Responses handles OpenAI Responses API endpoint // POST /openai/v1/responses func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { // 局部兜底:确保该 handler 内部任何 panic 都不会击穿到进程级。 streamStarted := false defer h.recoverResponsesPanic(c, &streamStarted) compactStartedAt := time.Now() defer h.logOpenAIRemoteCompactOutcome(c, compactStartedAt) setOpenAIClientTransportHTTP(c) requestStart := time.Now() // Get apiKey and user from context (set by ApiKeyAuth middleware) apiKey, ok := middleware2.GetAPIKeyFromContext(c) if !ok { h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") return } subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") return } reqLog := requestLogger( c, "handler.openai_gateway.responses", zap.Int64("user_id", subject.UserID), zap.Int64("api_key_id", apiKey.ID), zap.Any("group_id", apiKey.GroupID), ) if !h.ensureResponsesDependencies(c, reqLog) { return } // Read request body body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) return } h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") return } if len(body) == 0 { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty") return } setOpsRequestContext(c, "", false) sessionHashBody := body body, ok = h.normalizeOpenAIResponsesCompactRequest(c, reqLog, body) if !ok { return } legacyCompact := service.IsOpenAIResponsesCompactPath(c) nativeV2 := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(body) if nativeV2 { // 原生 v2 压缩出站前补注 x-codex-beta-features: remote_compaction_v2, // 与真实 Codex 线型一致(网关链剥头后本级负责恢复,#5586)。 service.MarkOpenAINativeCompactionV2(c) } // body-signal compact:上游 unary 等待期间向下游发 SSE 注释行心跳,防止 // 反向代理空闲超时掐断长压缩连接(#3887)。首拍延迟一个心跳间隔,快速 // 失败仍走 JSON+状态码链路;未标记客户端流式或间隔为 0 时是 no-op。 stopCompactKeepalive := service.StartOpenAICompactSSEKeepalive(c, h.openAICompactKeepaliveInterval()) defer stopCompactKeepalive() // 校验请求体 JSON 合法性 if !gjson.ValidBytes(body) { logRequestBodyParseFailure(reqLog, body, nil) h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") return } // 使用 gjson 只读提取字段做校验,避免完整 Unmarshal modelResult := gjson.GetBytes(body, "model") if !modelResult.Exists() || modelResult.Type != gjson.String || modelResult.String() == "" { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required") return } reqModel := modelResult.String() ensureCompositeTargetPlatform(c, apiKey, reqModel) if !openAICompatibleTextTargetAllowed(c, apiKey, reqModel) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed { body = cappedBody } reqStream, ok := parseOpenAICompatibleStream(body) if !ok { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage) return } reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) previousResponseID := strings.TrimSpace(gjson.GetBytes(body, "previous_response_id").String()) if previousResponseID != "" { previousResponseIDKind := service.ClassifyOpenAIPreviousResponseIDKind(previousResponseID) reqLog = reqLog.With( zap.Bool("has_previous_response_id", true), zap.String("previous_response_id_kind", previousResponseIDKind), zap.Int("previous_response_id_len", len(previousResponseID)), ) if previousResponseIDKind == service.OpenAIPreviousResponseIDKindMessageID { reqLog.Warn("openai.request_validation_failed", zap.String("reason", "previous_response_id_looks_like_message_id"), ) h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "previous_response_id must be a response.id (resp_*), not a message id") return } reqLog.Warn("openai.request_validation_failed", zap.String("reason", "previous_response_id_requires_wsv2"), ) h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "previous_response_id is only supported on Responses WebSocket v2") return } setOpsRequestContext(c, reqModel, reqStream) setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && !decision.AllowNextStage { h.openAISecurityAuditError(c, decision) return } // 使用 IsExplicitImageGenerationIntent 排除被动 image_gen namespace 声明。 // Codex 在所有请求中被动声明 image_gen namespace,宽泛检测会导致禁了生图的 // 分组中所有 Codex 请求被 403(#4447),并误占生图并发槽位。 imageIntent := service.IsExplicitImageGenerationIntent("/v1/responses", reqModel, body) if imageIntent && !service.GroupAllowsImageGeneration(apiKey.Group) { h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage()) return } var imageReleaseFunc func() if imageIntent { var imageAcquired bool imageReleaseFunc, imageAcquired = h.acquireImageGenerationSlot(c, streamStarted) if !imageAcquired { return } if imageReleaseFunc != nil { defer imageReleaseFunc() } } // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody) seedOpenAIForwardImageIntentHint(c, channelMapping.Mapped, imageIntent) forwardModel := reqModel if channelMapping.Mapped { forwardModel = channelMapping.MappedModel } c.Request = c.Request.WithContext(service.WithOpenAIForwardModel( c.Request.Context(), forwardModel, legacyCompact, )) // 提前校验 function_call_output 是否具备可关联上下文,避免上游 400。 if !h.validateFunctionCallOutputRequest(c, body, reqLog) { return } // 绑定错误透传服务,允许 service 层在非 failover 错误场景复用规则。 if h.errorPassthroughService != nil { service.BindErrorPassthroughService(c, h.errorPassthroughService) } // Get subscription info (may be nil) subscription, _ := middleware2.GetSubscriptionFromContext(c) requestPlatform := openAICompatibleRequestPlatform(c.Request.Context(), apiKey) service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds()) routingStart := time.Now() userReleaseFunc, acquired := h.acquireResponsesUserSlot(c, subject.UserID, subject.Concurrency, reqStream, &streamStarted, reqLog) if !acquired { return } // 确保请求取消时也会释放槽位,避免长连接被动中断造成泄漏 if userReleaseFunc != nil { defer userReleaseFunc() } // 2. Re-check billing eligibility after wait if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil { reqLog.Info("openai.billing_eligibility_check_failed", zap.Error(err)) status, code, message, retryAfter := billingErrorDetails(err) if retryAfter > 0 { c.Header("Retry-After", strconv.Itoa(retryAfter)) } h.handleStreamingAwareError(c, status, code, message, streamStarted) return } // Generate session hash (header first; fallback to prompt_cache_key) sessionHash := h.gatewayService.GenerateSessionHash(c, sessionHashBody) if h.rejectIfCyberSessionBlocked(c, apiKey, sessionHashBody, reqModel, cyberBlockFormatResponses) { return } requireCompact := legacyCompact maxAccountSwitches := h.maxAccountSwitches switchCount := 0 firstOutputTimeoutSwitchCount := 0 profitVetoCount := 0 failedAccountIDs := make(map[int64]struct{}) sameAccountRetryCount := make(map[int64]int) var lastFailoverErr *service.UpstreamFailoverError var oauth429FailoverState service.OpenAIOAuth429FailoverState var passthroughFailoverState openAIPassthroughFailoverState // 生图意图的 /v1/responses 请求必须调度到确实支持 Responses API 的账号,否则 // 会在 forward 阶段被静默降级为无法生图的 Chat Completions 直转(#4417)。 // 仅对 OpenAI 平台生效:Grok 生图走独立的 forwardGrokResponses 路径,不应被过滤。 // 复用前置权限与并发阶段在未修改 body 上确认的显式生图意图,避免大 tools 请求重复扫描。 // 该判断已排除 Codex 被动 image_gen namespace,避免 CC-only 账号被误过滤(#4476)。 needsResponses := nativeV2 || legacyCompact requiredCapability := openAIResponsesRequiredCapabilityForRequest(imageIntent, needsResponses, requestPlatform) // 分组利润控制:请求级装配定价上下文——pricingAt 固定本请求的 // D 与计费高峰因子,选号、槽位终检与全部 failover 重入共用同一门与阈值。 // 生图意图只影响能力路由与图片计费,不关门:混合 /v1/responses 请求的 // token 计费部分仍受利润门保护,独立图片/视频端点才在门外。 pricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID) c.Request = c.Request.WithContext(pricingCtx) for { // Streaming Forward intentionally detaches the upstream request so usage can // be drained after a disconnect. Re-check the client context before every // account attempt so a canceled request never starts a failover replay. if !openAIRequestAllowsFailoverReplay(c) { return } // Select account supporting the requested model reqLog.Debug("openai.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs))) selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability( c.Request.Context(), apiKey.GroupID, previousResponseID, sessionHash, reqModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, requiredCapability, requireCompact, false, !imageIntent, requestPlatform, ) if err != nil { if failoverClientGone(c) { reqLog.Info("openai.account_select_aborted_client_disconnected", zap.Error(err)) return } reqLog.Warn("openai.account_select_failed", zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)), zap.Int("excluded_account_count", len(failedAccountIDs)), ) if len(failedAccountIDs) == 0 { if legacyCompact && errors.Is(err, service.ErrNoAvailableCompactAccounts) { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "compact_not_supported", "No available accounts support /responses/compact", streamStarted) return } cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, requestPlatform) if !cls.ModelNotFound { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) } h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted) return } if lastFailoverErr != nil { h.handleFailoverExhausted(c, lastFailoverErr, streamStarted) } else { h.handleFailoverExhaustedSimple(c, 502, streamStarted) } return } if selection == nil || selection.Account == nil { cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, requestPlatform) if !cls.ModelNotFound { markOpsRoutingCapacityLimited(c) } h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted) return } if previousResponseID != "" && selection != nil && selection.Account != nil { reqLog.Debug("openai.account_selected_with_previous_response_id", zap.Int64("account_id", selection.Account.ID)) } reqLog.Debug("openai.account_schedule_decision", zap.String("layer", scheduleDecision.Layer), zap.Bool("sticky_previous_hit", scheduleDecision.StickyPreviousHit), zap.Bool("sticky_session_hit", scheduleDecision.StickySessionHit), zap.Int("candidate_count", scheduleDecision.CandidateCount), zap.Int("top_k", scheduleDecision.TopK), zap.Int64("latency_ms", scheduleDecision.LatencyMs), zap.Float64("load_skew", scheduleDecision.LoadSkew), ) account := selection.Account sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account) reqLog.Debug("openai.account_selected", zap.Int64("account_id", account.ID), zap.String("account_name", account.Name)) setOpsSelectedAccount(c, account.ID, account.Platform) accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog) if slotResult == openAISlotAcquireProfitVetoed { // 利润终检否决:排除该账号重新选号,全池耗尽由下一轮选号报错; // 否决次数达上限则直接终止,避免排队抢槽后才终检的延迟放大。 if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount) return } continue } if slotResult != openAISlotAcquireOK { return } // Forward request service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds()) forwardStart := time.Now() // 用扣除非语义心跳字节的口径快照:心跳注释不构成语义响应, // 不能因心跳字节变化而放弃 failover 换号(#3887)。 writerSizeBeforeForward := service.OpenAICompactKeepaliveAdjustedWrittenSize(c) // 跨 passthrough 边界的 failover:从 Kiro 等透传账号切到 Bedrock 等非透传账号前, // 从不可变的 canonical forwardBody 派生本次尝试 body 并整块剔除上游私有的加密 // reasoning item(含耦合的 id/summary),避免非透传上游 400 拒绝 Kiro reasoning 形态。 attemptBody := h.deriveOpenAIForwardAttemptBody(reqLog, forwardBody, account, &passthroughFailoverState) result, err := func() (*service.OpenAIForwardResult, error) { defer func() { if accountReleaseFunc != nil { accountReleaseFunc() } }() return h.gatewayService.Forward(c.Request.Context(), c, account, attemptBody) }() cyberBlockKeyHTTP := "" if service.GetOpsCyberPolicy(c) != nil { cyberBlockKeyHTTP = service.CyberSessionBlockKey(apiKey.ID, c, sessionHashBody) } h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockKeyHTTP, clientRequestedUsageFields(c, channelMapping, reqModel, ""), service.HashUsageRequestPayload(body)) forwardDurationMs := time.Since(forwardStart).Milliseconds() upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey) responseLatencyMs := forwardDurationMs if upstreamLatencyMs > 0 && forwardDurationMs > upstreamLatencyMs { responseLatencyMs = forwardDurationMs - upstreamLatencyMs } service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, responseLatencyMs) if err == nil && result != nil && result.FirstTokenMs != nil { service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs)) } // #5148 对齐:错误返回携带的部分 result(流中断前上游已计量的 usage)照常 // 入账;failover 错误恒定 result=nil,不会重复计费。 submitResponsesUsage := func(res *service.OpenAIForwardResult) { if res == nil { return } userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) requestPayloadHash := service.HashUsageRequestPayload(body) inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, res) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) sessionID := service.ExtractClientSessionID(c) cyberBlocked := service.GetOpsCyberPolicy(c) != nil h.submitOpenAIUsageRecordTask(c.Request.Context(), res, func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ Result: res, APIKey: apiKey, User: apiKey.User, Account: account, Subscription: subscription, InboundEndpoint: inboundEndpoint, UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, APIKeyService: h.apiKeyService, QuotaPlatform: quotaPlatform, SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, res.UpstreamModel), PricingAt: pricingAt, CyberBlocked: cyberBlocked, }); err != nil { logger.L().With( zap.String("component", "handler.openai_gateway.responses"), zap.Int64("user_id", subject.UserID), zap.Int64("api_key_id", apiKey.ID), zap.Any("group_id", apiKey.GroupID), zap.String("model", reqModel), zap.Int64("account_id", account.ID), ).Error("openai.record_usage_failed", zap.Error(err)) } }) } if err != nil { if result != nil && result.ImageCount > 0 { reqLog.Warn("openai.forward_partial_error_with_image_result", zap.Int64("account_id", account.ID), zap.Int("image_count", result.ImageCount), zap.Error(err), ) } else { var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { if failoverClientGone(c) { reqLog.Info("openai.failover_aborted_client_disconnected", zap.Int64("account_id", account.ID), zap.Int("upstream_status", failoverErr.StatusCode), ) return } if !openAIForwardMayFailover(c, writerSizeBeforeForward, failoverErr) { h.handleFailoverExhausted(c, failoverErr, true) return } // openAIForwardMayFailover 已确认写出的字节不含语义输出, // 但重试耗尽时仍须按已提交的 SSE 响应返回流内错误。 if c.Writer.Written() { streamStarted = true } if failoverErr.ShouldReportAccountScheduleFailure() { h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) } if !failoverErr.ShouldRetryNextAccount() { h.handleFailoverExhausted(c, failoverErr, streamStarted) return } if openAIFirstOutputFailoverExhausted(failoverErr, &firstOutputTimeoutSwitchCount) { h.handleFailoverExhausted(c, failoverErr, streamStarted) return } // 池模式:同账号重试 if failoverErr.RetryableOnSameAccount { retryLimit := account.GetPoolModeRetryCount() if sameAccountRetryCount[account.ID] < retryLimit { sameAccountRetryCount[account.ID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("openai.pool_mode_same_account_retry", zap.Int64("account_id", account.ID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("retry_limit", retryLimit), zap.Int("retry_count", sameAccountRetryCount[account.ID]), zap.Duration("retry_delay", retryDelay), ) select { case <-c.Request.Context().Done(): return case <-time.After(retryDelay): } continue } } h.gatewayService.RecordOpenAIAccountSwitch() failedAccountIDs[account.ID] = struct{}{} lastFailoverErr = failoverErr if switchCount >= maxAccountSwitches { h.handleFailoverExhausted(c, failoverErr, streamStarted) return } switchCount++ if h.gatewayService.ShouldStopOpenAIOAuth429Failover(account, failoverErr.StatusCode, switchCount, &oauth429FailoverState) { h.handleFailoverExhausted(c, failoverErr, streamStarted) return } failoverSwitchFields := []zap.Field{ zap.Int64("account_id", account.ID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("switch_count", switchCount), zap.Int("max_switches", maxAccountSwitches), } if account.Proxy != nil { failoverSwitchFields = append(failoverSwitchFields, zap.Int64("proxy_id", account.Proxy.ID), zap.String("proxy_name", account.Proxy.Name), zap.String("proxy_host", account.Proxy.Host), zap.Int("proxy_port", account.Proxy.Port), ) } else if account.ProxyID != nil { failoverSwitchFields = append(failoverSwitchFields, zap.Int64p("proxy_id", account.ProxyID)) } reqLog.Warn("openai.upstream_failover_switching", failoverSwitchFields...) continue } h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) upstreamErrorAlreadyCommunicated := openAIForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err) wroteFallback := false if !upstreamErrorAlreadyCommunicated { wroteFallback = h.ensureForwardErrorResponse(c, streamStarted) } fields := []zap.Field{ zap.Int64("account_id", account.ID), zap.Bool("fallback_error_response_written", wroteFallback), zap.Bool("upstream_error_response_already_written", upstreamErrorAlreadyCommunicated), zap.Error(err), } submitResponsesUsage(result) if shouldLogOpenAIForwardFailureAsWarn(c, wroteFallback) { reqLog.Warn("openai.forward_failed", fields...) return } reqLog.Error("openai.forward_failed", fields...) return } } if result != nil { // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 if account.Type == service.AccountTypeOAuth && !account.IsShadow() { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders) } h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), result.FirstTokenMs) } else { h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), nil) } // 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。 submitResponsesUsage(result) reqLog.Debug("openai.request_completed", zap.Int64("account_id", account.ID), zap.Int("switch_count", switchCount), ) return } } func isOpenAILegacyCompactPath(c *gin.Context) bool { return service.IsOpenAIResponsesCompactPath(c) } // isBareOpenAIResponsesPath 仅匹配裸 /responses 端点(无 /compact 等子路径), // body-signal 提升只允许发生在这里,避免误伤 /responses/{id}/... 形态的请求。 func isBareOpenAIResponsesPath(c *gin.Context) bool { if c == nil || c.Request == nil || c.Request.URL == nil { return false } normalizedPath := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/") switch normalizedPath { case EndpointResponses, "/openai/v1/responses", "/responses", "/backend-api/codex/responses": return true default: return false } } func isOpenAIRemoteCompactionV2Request(body []byte) bool { stream, valid := parseOpenAICompatibleStream(body) return valid && stream && service.HasCompactionTriggerInInput(body) } // normalizeOpenAIResponsesCompactRequest keeps Codex remote compaction v2 on // its native streaming /responses wire and preserves the legacy body-signal // promotion for non-streaming requests. // 返回归一化后的 body;ok=false 表示错误响应已写出,调用方应直接 return。 func (h *OpenAIGatewayHandler) normalizeOpenAIResponsesCompactRequest(c *gin.Context, reqLog *zap.Logger, body []byte) ([]byte, bool) { isCompactRequest := isOpenAILegacyCompactPath(c) if !isCompactRequest && isBareOpenAIResponsesPath(c) && service.HasCompactionTriggerInInput(body) { if isOpenAIRemoteCompactionV2Request(body) { return body, true } c.Request.URL.Path = strings.TrimRight(c.Request.URL.Path, "/") + "/compact" isCompactRequest = true clientStream := gjson.GetBytes(body, "stream").Bool() if clientStream { service.MarkOpenAICompactClientStream(c) } reqLog.Info("codex.remote_compact.detected_body_signal", zap.Bool("client_stream", clientStream)) } if !isCompactRequest { return body, true } if compactSeed := strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()); compactSeed != "" { c.Set(service.OpenAICompactSessionSeedKeyForTest(), compactSeed) } normalizedCompactBody, normalizedCompact, compactErr := service.NormalizeOpenAICompactRequestBodyForTest(body) if compactErr != nil { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to normalize compact request body") return nil, false } if normalizedCompact { body = normalizedCompactBody } return body, true } func (h *OpenAIGatewayHandler) logOpenAIRemoteCompactOutcome(c *gin.Context, startedAt time.Time) { if !isOpenAILegacyCompactPath(c) { return } var ( ctx = context.Background() path string status int ) if c != nil { if c.Request != nil { ctx = c.Request.Context() if c.Request.URL != nil { path = strings.TrimSpace(c.Request.URL.Path) } } if c.Writer != nil { status = c.Writer.Status() } } outcome := "failed" if status >= 200 && status < 300 { outcome = "succeeded" } // compact 心跳提交后失败的 wire 状态码固化为 200,真实结局以流内错误 // 标记为准(response.failed 降级路径会 MarkOpsStreamError)。 if outcome == "succeeded" && c != nil { if _, hasStreamErr := service.GetOpsStreamError(c); hasStreamErr { outcome = "failed" } } latencyMs := time.Since(startedAt).Milliseconds() if latencyMs < 0 { latencyMs = 0 } fields := []zap.Field{ zap.String("component", "handler.openai_gateway.responses"), zap.Bool("remote_compact", true), zap.String("compact_outcome", outcome), zap.Int("status_code", status), zap.Int64("latency_ms", latencyMs), zap.String("path", path), zap.Bool("force_codex_cli", h != nil && h.cfg != nil && h.cfg.Gateway.ForceCodexCLI), } if c != nil { if userAgent := strings.TrimSpace(c.GetHeader("User-Agent")); userAgent != "" { fields = append(fields, zap.String("request_user_agent", userAgent)) } if v, ok := c.Get(opsModelKey); ok { if model, ok := v.(string); ok && strings.TrimSpace(model) != "" { fields = append(fields, zap.String("request_model", strings.TrimSpace(model))) } } if v, ok := c.Get(opsAccountIDKey); ok { if accountID, ok := v.(int64); ok && accountID > 0 { fields = append(fields, zap.Int64("account_id", accountID)) } } if c.Writer != nil { if upstreamRequestID := strings.TrimSpace(c.Writer.Header().Get("x-request-id")); upstreamRequestID != "" { fields = append(fields, zap.String("upstream_request_id", upstreamRequestID)) } else if upstreamRequestID := strings.TrimSpace(c.Writer.Header().Get("X-Request-Id")); upstreamRequestID != "" { fields = append(fields, zap.String("upstream_request_id", upstreamRequestID)) } } } log := logger.FromContext(ctx).With(fields...) if outcome == "succeeded" { log.Info("codex.remote_compact.succeeded") return } log.Warn("codex.remote_compact.failed") } // Messages handles Anthropic Messages API requests routed to OpenAI platform. // POST /v1/messages (when group platform is OpenAI) func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { streamStarted := false defer h.recoverAnthropicMessagesPanic(c, &streamStarted) requestStart := time.Now() apiKey, ok := middleware2.GetAPIKeyFromContext(c) if !ok { h.anthropicErrorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") return } subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { h.anthropicErrorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") return } reqLog := requestLogger( c, "handler.openai_gateway.messages", zap.Int64("user_id", subject.UserID), zap.Int64("api_key_id", apiKey.ID), zap.Any("group_id", apiKey.GroupID), ) // 检查分组是否允许 /v1/messages 调度 if !allowOpenAICompatibleMessagesDispatch(c, apiKey) { h.anthropicErrorResponse(c, http.StatusForbidden, "permission_error", "This group does not allow /v1/messages dispatch") return } if !h.ensureResponsesDependencies(c, reqLog) { return } body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.anthropicErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) return } h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") return } if len(body) == 0 { h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty") return } if !gjson.ValidBytes(body) { logRequestBodyParseFailure(reqLog, body, nil) h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") return } modelResult := gjson.GetBytes(body, "model") if !modelResult.Exists() || modelResult.Type != gjson.String || modelResult.String() == "" { h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required") return } reqModel := modelResult.String() ensureCompositeTargetPlatform(c, apiKey, reqModel) if !openAICompatibleTextTargetAllowed(c, apiKey, reqModel) { h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } bindOpenAIReasoningEffortPolicyForMessagesRequest(c, apiKey, body) routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel) preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(c, apiKey, reqModel) reqStream := gjson.GetBytes(body, "stream").Bool() reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) setOpsRequestContext(c, reqModel, reqStream) setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && !decision.AllowNextStage { h.anthropicSecurityAuditError(c, decision) return } // 解析渠道级模型映射 channelMappingMsg, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) mappedBodyForMessages := newOpenAIModelMappedBodyCache(body, h.gatewayService.ReplaceModelInBody) // 绑定错误透传服务,允许 service 层在非 failover 错误场景复用规则。 if h.errorPassthroughService != nil { service.BindErrorPassthroughService(c, h.errorPassthroughService) } subscription, _ := middleware2.GetSubscriptionFromContext(c) requestPlatform := openAICompatibleRequestPlatform(c.Request.Context(), apiKey) service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds()) routingStart := time.Now() userReleaseFunc, acquired := h.acquireResponsesUserSlot(c, subject.UserID, subject.Concurrency, reqStream, &streamStarted, reqLog) if !acquired { return } if userReleaseFunc != nil { defer userReleaseFunc() } if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil { reqLog.Info("openai_messages.billing_eligibility_check_failed", zap.Error(err)) status, code, message, retryAfter := billingErrorDetails(err) if retryAfter > 0 { c.Header("Retry-After", strconv.Itoa(retryAfter)) } h.anthropicStreamingAwareError(c, status, code, message, streamStarted) return } sessionHash := h.gatewayService.GenerateSessionHash(c, body) promptCacheKey := h.gatewayService.ExtractSessionID(c, body) sessionHash, promptCacheKey = resolveOpenAIMessagesMetadataSession(sessionHash, promptCacheKey, reqModel, body) if h.rejectIfCyberSessionBlocked(c, apiKey, body, reqModel, cyberBlockFormatAnthropic) { return } maxAccountSwitches := h.maxAccountSwitches switchCount := 0 profitVetoCount := 0 failedAccountIDs := make(map[int64]struct{}) sameAccountRetryCount := make(map[int64]int) var lastFailoverErr *service.UpstreamFailoverError var oauth429FailoverState service.OpenAIOAuth429FailoverState effectiveMappedModel := preferredMappedModel // 分组利润控制:Messages 文本入口同样请求级装门并固定 pricingAt。 msgPricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID) c.Request = c.Request.WithContext(msgPricingCtx) for { if failoverClientGone(c) { return } currentRoutingModel := routingModel if effectiveMappedModel != "" { currentRoutingModel = effectiveMappedModel } reqLog.Debug("openai_messages.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs))) selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability( c.Request.Context(), apiKey.GroupID, "", // no previous_response_id sessionHash, currentRoutingModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, service.OpenAIEndpointCapabilityChatCompletions, false, false, true, requestPlatform, ) if err != nil { if failoverClientGone(c) { reqLog.Info("openai_messages.account_select_aborted_client_disconnected", zap.Error(err)) return } reqLog.Warn("openai_messages.account_select_failed", zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)), zap.Int("excluded_account_count", len(failedAccountIDs)), ) if len(failedAccountIDs) == 0 { if err != nil { cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel) if !cls.ModelNotFound { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) } h.anthropicStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted) return } } else { if lastFailoverErr != nil { h.handleAnthropicFailoverExhausted(c, lastFailoverErr, streamStarted) } else { h.anthropicStreamingAwareError(c, http.StatusBadGateway, "api_error", "Upstream request failed", streamStarted) } return } } if selection == nil || selection.Account == nil { cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel) if !cls.ModelNotFound { markOpsRoutingCapacityLimited(c) } h.anthropicStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted) return } account := selection.Account sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account) reqLog.Debug("openai_messages.account_selected", zap.Int64("account_id", account.ID), zap.String("account_name", account.Name)) _ = scheduleDecision setOpsSelectedAccount(c, account.ID, account.Platform) accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog) if slotResult == openAISlotAcquireProfitVetoed { // 利润终检否决:排除该账号重新选号,全池耗尽由下一轮选号报错; // 否决次数达上限则直接终止,避免排队抢槽后才终检的延迟放大。 if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount) return } continue } if slotResult != openAISlotAcquireOK { return } service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds()) forwardStart := time.Now() defaultMappedModel := strings.TrimSpace(effectiveMappedModel) // 应用渠道模型映射到请求体 forwardBody := mappedBodyForMessages(channelMappingMsg.Mapped, channelMappingMsg.MappedModel) writerSizeBeforeForward := c.Writer.Size() result, err := func() (*service.OpenAIForwardResult, error) { defer func() { if accountReleaseFunc != nil { accountReleaseFunc() } }() return h.gatewayService.ForwardAsAnthropic(c.Request.Context(), c, account, forwardBody, promptCacheKey, defaultMappedModel) }() cyberBlockKeyMsg := "" if service.GetOpsCyberPolicy(c) != nil { cyberBlockKeyMsg = service.CyberSessionBlockKey(apiKey.ID, c, body) } h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockKeyMsg, clientRequestedUsageFields(c, channelMappingMsg, reqModel, ""), service.HashUsageRequestPayload(body)) forwardDurationMs := time.Since(forwardStart).Milliseconds() upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey) responseLatencyMs := forwardDurationMs if upstreamLatencyMs > 0 && forwardDurationMs > upstreamLatencyMs { responseLatencyMs = forwardDurationMs - upstreamLatencyMs } service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, responseLatencyMs) if err == nil && result != nil && result.FirstTokenMs != nil { service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs)) } // Forward 与错误一起返回的部分结果:流中断/客户端断开排水前上游已计量的 // usage 照常入账,避免上游已产生消耗的请求完全漏记(#5148,对齐 anthropic // 网关同名修复)。failover 错误恒定 result=nil,不会重复计费。 submitMessagesUsage := func(res *service.OpenAIForwardResult) { if res == nil { return } userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) requestPayloadHash := service.HashUsageRequestPayload(body) inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, res) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) sessionID := service.ExtractClientSessionID(c) cyberBlocked := service.GetOpsCyberPolicy(c) != nil h.submitOpenAIUsageRecordTask(c.Request.Context(), res, func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ Result: res, APIKey: apiKey, User: apiKey.User, Account: account, Subscription: subscription, InboundEndpoint: inboundEndpoint, UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, APIKeyService: h.apiKeyService, QuotaPlatform: quotaPlatform, SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMappingMsg, reqModel, res.UpstreamModel), PricingAt: pricingAt, CyberBlocked: cyberBlocked, }); err != nil { logger.L().With( zap.String("component", "handler.openai_gateway.messages"), zap.Int64("user_id", subject.UserID), zap.Int64("api_key_id", apiKey.ID), zap.Any("group_id", apiKey.GroupID), zap.String("model", reqModel), zap.Int64("account_id", account.ID), ).Error("openai_messages.record_usage_failed", zap.Error(err)) } }) } if err != nil { if result != nil && result.ImageCount > 0 { reqLog.Warn("openai_messages.forward_partial_error_with_image_result", zap.Int64("account_id", account.ID), zap.Int("image_count", result.ImageCount), zap.Error(err), ) } else { var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { if failoverClientGone(c) { reqLog.Info("openai_messages.failover_aborted_client_disconnected", zap.Int64("account_id", account.ID), zap.Int("upstream_status", failoverErr.StatusCode), ) return } if c.Writer.Size() != writerSizeBeforeForward { h.handleAnthropicFailoverExhausted(c, failoverErr, true) return } if failoverErr.ShouldReportAccountScheduleFailure() { h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), false, nil) } if !failoverErr.ShouldRetryNextAccount() { h.handleAnthropicFailoverExhausted(c, failoverErr, streamStarted) return } // 池模式:同账号重试 if failoverErr.RetryableOnSameAccount { retryLimit := account.GetPoolModeRetryCount() if sameAccountRetryCount[account.ID] < retryLimit { sameAccountRetryCount[account.ID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("openai_messages.pool_mode_same_account_retry", zap.Int64("account_id", account.ID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("retry_limit", retryLimit), zap.Int("retry_count", sameAccountRetryCount[account.ID]), zap.Duration("retry_delay", retryDelay), ) select { case <-c.Request.Context().Done(): return case <-time.After(retryDelay): } continue } } h.gatewayService.RecordOpenAIAccountSwitch() failedAccountIDs[account.ID] = struct{}{} lastFailoverErr = failoverErr if switchCount >= maxAccountSwitches { h.handleAnthropicFailoverExhausted(c, failoverErr, streamStarted) return } switchCount++ if h.gatewayService.ShouldStopOpenAIOAuth429Failover(account, failoverErr.StatusCode, switchCount, &oauth429FailoverState) { h.handleAnthropicFailoverExhausted(c, failoverErr, streamStarted) return } reqLog.Warn("openai_messages.upstream_failover_switching", zap.Int64("account_id", account.ID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("switch_count", switchCount), zap.Int("max_switches", maxAccountSwitches), ) continue } if result != nil && result.ClientDisconnect { reqLog.Info("openai_messages.client_disconnected", zap.Int64("account_id", account.ID), zap.Error(err), ) // 断开排水期间上游已计量的 usage 必须入账(此前直接 return 丢弃, // payg 上游照常计费而平台漏记)。 submitMessagesUsage(result) return } h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), false, nil) wroteFallback := h.ensureAnthropicErrorResponse(c, streamStarted) reqLog.Warn("openai_messages.forward_failed", zap.Int64("account_id", account.ID), zap.Bool("fallback_error_response_written", wroteFallback), zap.Error(err), ) submitMessagesUsage(result) return } } if result != nil { h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, result.FirstTokenMs) } else { h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, nil) } submitMessagesUsage(result) reqLog.Debug("openai_messages.request_completed", zap.Int64("account_id", account.ID), zap.Int("switch_count", switchCount), ) return } } func resolveOpenAIMessagesMetadataSession(sessionHash, promptCacheKey, reqModel string, body []byte) (string, string) { // Anthropic metadata.user_id 只作为账号粘性信号。上游 GPT/Codex 缓存键 // 交给 ForwardAsAnthropic 从 cache_control 或完整消息 digest 派生,避免 // 固定 metadata key 压住后续 turn 的缓存滚动。 if sessionHash != "" { return sessionHash, promptCacheKey } if userID := strings.TrimSpace(gjson.GetBytes(body, "metadata.user_id").String()); userID != "" { seed := reqModel + "-" + userID sessionHash = service.DeriveSessionHashFromSeed(seed) } return sessionHash, promptCacheKey } // anthropicErrorResponse writes an error in Anthropic Messages API format. func (h *OpenAIGatewayHandler) anthropicErrorResponse(c *gin.Context, status int, errType, message string) { c.JSON(status, gin.H{ "type": "error", "error": gin.H{ "type": errType, "message": message, }, }) } // anthropicStreamingAwareError handles errors that may occur during streaming, // using Anthropic SSE error format. func (h *OpenAIGatewayHandler) anthropicStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) { if streamStarted { flusher, ok := c.Writer.(http.Flusher) if ok { errPayload, _ := json.Marshal(gin.H{ "type": "error", "error": gin.H{ "type": errType, "message": message, }, }) fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", errPayload) //nolint:errcheck flusher.Flush() } return } h.anthropicErrorResponse(c, status, errType, message) } // handleAnthropicFailoverExhausted maps upstream failover errors to Anthropic format. func (h *OpenAIGatewayHandler) handleAnthropicFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, streamStarted bool) { if failoverErr != nil { copyFailoverRetryAfter(c, failoverErr.ResponseHeaders) } if failoverErr != nil && failoverErr.IsCredentialFailure() { status, message := credentialFailoverClientResponse(failoverErr) h.anthropicStreamingAwareError(c, status, "api_error", message, streamStarted) return } status, errType, errMsg := h.mapUpstreamError(failoverErr.StatusCode) h.anthropicStreamingAwareError(c, status, errType, errMsg, streamStarted) } // ensureAnthropicErrorResponse writes a fallback Anthropic error if no response was written. func (h *OpenAIGatewayHandler) ensureAnthropicErrorResponse(c *gin.Context, streamStarted bool) bool { if c == nil || c.Writer == nil || c.Writer.Written() { return false } h.anthropicStreamingAwareError(c, http.StatusBadGateway, "api_error", "Upstream request failed", streamStarted) return true } func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context, body []byte, reqLog *zap.Logger) bool { if !gjson.GetBytes(body, `input.#(type=="function_call_output")`).Exists() { return true } validation := service.ValidateFunctionCallOutputContextBytes(body) if !validation.HasFunctionCallOutput { return true } previousResponseID := gjson.GetBytes(body, "previous_response_id").String() if strings.TrimSpace(previousResponseID) != "" || validation.HasToolCallContext { return true } if validation.HasFunctionCallOutputMissingCallID { reqLog.Warn("openai.request_validation_failed", zap.String("reason", "function_call_output_missing_call_id"), ) h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "function_call_output requires call_id on HTTP requests; continuation via previous_response_id is only supported on Responses WebSocket v2") return false } if validation.HasItemReferenceForAllCallIDs { return true } reqLog.Warn("openai.request_validation_failed", zap.String("reason", "function_call_output_missing_item_reference"), ) h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "function_call_output requires item_reference ids matching each call_id on HTTP requests; continuation via previous_response_id is only supported on Responses WebSocket v2") return false } func (h *OpenAIGatewayHandler) acquireResponsesUserSlot( c *gin.Context, userID int64, userConcurrency int, reqStream bool, streamStarted *bool, reqLog *zap.Logger, ) (func(), bool) { ctx := c.Request.Context() userReleaseFunc, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, userID, userConcurrency, reqStream, streamStarted) if err != nil { reqLog.Warn("openai.user_slot_acquire_failed", zap.Error(err)) h.handleConcurrencyError(c, err, "user", *streamStarted) return nil, false } return wrapReleaseOnDone(ctx, userReleaseFunc), true } // openAISlotAcquireResult 是账号槽位获取的三态结果。 type openAISlotAcquireResult int const ( openAISlotAcquireOK openAISlotAcquireResult = iota // openAISlotAcquireFailed:错误响应已写出,调用方直接 return。 openAISlotAcquireFailed // openAISlotAcquireProfitVetoed:槽位获取成功后利润终检否决。槽位已释放、 // 未写任何响应;调用方应经 recordOpenAIProfitVeto 把该账号加入本请求排除集 // 后重新选号,全池耗尽由下一轮选号返回标准 no available accounts。 openAISlotAcquireProfitVetoed ) // openAIWSTurnPricing 持有 WebSocket 连接内「当前 turn」的计费定价时刻。 // 由 BeforeTurn 在每个 turn 开始时冻结,AfterTurn 的用量提交读取它;turn 在 // 连接内串行推进,互斥锁只为跨用量提交 goroutine 的读取安全。 // // ws_v2 passthrough ingress 没有 BeforeTurn,因此本值会保持零;AfterTurn 必须 // 以 TurnStarted 已记录的所属 turn 开始时刻为回退,而不是用建连或记录时刻。 // 这样每个 passthrough turn 都按自己的开始时刻计价,但不改变其仅在建连时执行 // 准入门、没有 turn 级利润复核的既有行为。 type openAIWSTurnPricing struct { mu sync.Mutex at time.Time } func (p *openAIWSTurnPricing) freeze(at time.Time) { p.mu.Lock() p.at = at p.mu.Unlock() } func (p *openAIWSTurnPricing) currentOr(fallback time.Time) time.Time { p.mu.Lock() defer p.mu.Unlock() if !p.at.IsZero() { return p.at } return fallback } // recordOpenAIProfitVeto 记录 OpenAI 侧选号循环的一次利润门终检否决:把账号 // 加入本请求排除集并递增否决计数。返回 false 表示否决次数已达 // maxProfitVetoAttempts,调用方必须停止重选并按「无可用账号」终止。 // // OpenAI 路径用的是各自的 failedAccountIDs map + for 循环(不是 FailoverState), // 这里用一个独立计数器复用同一上限语义。上限是必需的:WaitPlan 分支先阻塞 // 排队(sticky 45s / fallback 30s)拿到槽位才终检,无上限重选会把单次请求的 // 延迟放大到 N × WaitPlan.Timeout。 func recordOpenAIProfitVeto(failedAccountIDs map[int64]struct{}, accountID int64, vetoCount *int) bool { failedAccountIDs[accountID] = struct{}{} *vetoCount++ return *vetoCount < maxProfitVetoAttempts } // handleOpenAIProfitVetoExhausted 在利润否决预算耗尽时写出错误响应。 // 与 acquireResponsesAccountSlot 内部的 no-available-accounts 失败分支同形, // 保证同一调用方在两条路径上拿到一致的响应格式。 func (h *OpenAIGatewayHandler) handleOpenAIProfitVetoExhausted( c *gin.Context, streamStarted bool, reqLog *zap.Logger, vetoCount int, ) { reqLog.Warn("openai.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", vetoCount)) markOpsRoutingCapacityLimited(c) h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted) } func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( c *gin.Context, groupID *int64, sessionHash string, selection *service.AccountSelectionResult, reqStream bool, streamStarted *bool, reqLog *zap.Logger, ) (func(), openAISlotAcquireResult) { if selection == nil || selection.Account == nil { markOpsRoutingCapacityLimited(c) h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", *streamStarted) return nil, openAISlotAcquireFailed } // 终检与准入后绑定使用选号结果携带的门:composite 等跨分组调度解析出的 // 门只存在于调度栈的局部 ctx,必须经选号结果重放到本函数的 ctx 上。 ctx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection) account := selection.Account if selection.Acquired { latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(ctx, account) if vetoed { if selection.ReleaseFunc != nil { selection.ReleaseFunc() } reqLog.Debug("openai.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) return nil, openAISlotAcquireProfitVetoed } account = latest selection.Account = latest // 调度器已抢槽路径无门时由选号内部完成 eager 绑定;门下选号内部 // 推迟绑定,这里在终检通过后补准入后绑定。 if selection.ProfitGateActive() { if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, groupID, sessionHash, account.ID); err != nil { reqLog.Warn("openai.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) } } return wrapReleaseOnDone(ctx, selection.ReleaseFunc), openAISlotAcquireOK } if selection.WaitPlan == nil { markOpsRoutingCapacityLimited(c) h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", *streamStarted) return nil, openAISlotAcquireFailed } fastReleaseFunc, fastAcquired, err := h.concurrencyHelper.TryAcquireAccountSlot( ctx, account.ID, selection.WaitPlan.MaxConcurrency, ) if err != nil { reqLog.Warn("openai.account_slot_quick_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) h.handleConcurrencyError(c, err, "account", *streamStarted) return nil, openAISlotAcquireFailed } if fastAcquired { // 分组利润控制:快速抢槽成功后终检。选号与抢槽之间账号 // 倍率可能刷新,越线则释放槽位交由调用方排除重选,不绑定粘连。 latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(ctx, account) if vetoed { if fastReleaseFunc != nil { fastReleaseFunc() } reqLog.Debug("openai.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) return nil, openAISlotAcquireProfitVetoed } account = latest selection.Account = latest if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, groupID, sessionHash, account.ID); err != nil { reqLog.Warn("openai.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) } return wrapReleaseOnDone(ctx, fastReleaseFunc), openAISlotAcquireOK } canWait, waitErr := h.concurrencyHelper.IncrementAccountWaitCount(ctx, account.ID, selection.WaitPlan.MaxWaiting) if waitErr != nil { reqLog.Warn("openai.account_wait_counter_increment_failed", zap.Int64("account_id", account.ID), zap.Error(waitErr)) } else if !canWait { reqLog.Info("openai.account_wait_queue_full", zap.Int64("account_id", account.ID), zap.Int("max_waiting", selection.WaitPlan.MaxWaiting), ) h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", *streamStarted) return nil, openAISlotAcquireFailed } accountWaitCounted := waitErr == nil && canWait releaseWait := func() { if accountWaitCounted { h.concurrencyHelper.DecrementAccountWaitCount(ctx, account.ID) accountWaitCounted = false } } defer releaseWait() accountReleaseFunc, err := h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout( c, account.ID, selection.WaitPlan.MaxConcurrency, selection.WaitPlan.Timeout, reqStream, streamStarted, ) if err != nil { reqLog.Warn("openai.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) h.handleConcurrencyError(c, err, "account", *streamStarted) return nil, openAISlotAcquireFailed } // Slot acquired: no longer waiting in queue. releaseWait() // 分组利润控制:WaitPlan 排队成功后终检。排队期间账号倍率 // 可能上调,越线则释放槽位交由调用方排除重选,不绑定粘连。 latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(ctx, account) if vetoed { if accountReleaseFunc != nil { accountReleaseFunc() } reqLog.Debug("openai.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) return nil, openAISlotAcquireProfitVetoed } account = latest selection.Account = latest if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, groupID, sessionHash, account.ID); err != nil { reqLog.Warn("openai.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) } return wrapReleaseOnDone(ctx, accountReleaseFunc), openAISlotAcquireOK } // ResponsesWebSocket handles OpenAI Responses API WebSocket ingress endpoint // GET /openai/v1/responses (Upgrade: websocket) func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if !isOpenAIWSUpgradeRequest(c.Request) { h.errorResponse(c, http.StatusUpgradeRequired, "invalid_request_error", "WebSocket upgrade required (Upgrade: websocket)") return } setOpenAIClientTransportWS(c) apiKey, ok := middleware2.GetAPIKeyFromContext(c) if !ok { h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") return } subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") return } reqLog := requestLogger( c, "handler.openai_gateway.responses_ws", zap.Int64("user_id", subject.UserID), zap.Int64("api_key_id", apiKey.ID), zap.Any("group_id", apiKey.GroupID), zap.Bool("openai_ws_mode", true), ) if !h.ensureResponsesDependencies(c, reqLog) { return } reqLog.Info("openai.websocket_ingress_started") clientIP := ip.GetClientIP(c) userAgent := strings.TrimSpace(c.GetHeader("User-Agent")) clientLifecycleCtx := c.Request.Context() ctx := clientLifecycleCtx maxIngressConnections := 0 if h.cfg != nil { maxIngressConnections = h.cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey } ingressLease, ingressLeaseAcquired, ingressLeaseErr := h.concurrencyHelper.AcquireOpenAIWSIngressLease(ctx, apiKey.ID, maxIngressConnections) if ingressLeaseErr != nil { reqLog.Error("openai.websocket_ingress_lease_acquire_failed", zap.Error(ingressLeaseErr)) h.errorResponse(c, http.StatusServiceUnavailable, "service_unavailable", "WebSocket ingress capacity is temporarily unavailable") return } if !ingressLeaseAcquired { reqLog.Info("openai.websocket_ingress_capacity_rejected", zap.Int("max_ingress_connections_per_api_key", maxIngressConnections)) c.Header("Retry-After", "5") h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Too many open WebSocket connections, please retry later") return } if ingressLease != nil { defer ingressLease.Release() ctx = ingressLease.Context() c.Request = c.Request.WithContext(ctx) } wsConn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{ CompressionMode: coderws.CompressionContextTakeover, }) if err != nil { reqLog.Warn("openai.websocket_accept_failed", zap.Error(err), zap.String("client_ip", clientIP), zap.String("request_user_agent", userAgent), zap.String("upgrade_header", strings.TrimSpace(c.GetHeader("Upgrade"))), zap.String("connection_header", strings.TrimSpace(c.GetHeader("Connection"))), zap.String("sec_websocket_version", strings.TrimSpace(c.GetHeader("Sec-WebSocket-Version"))), zap.Bool("has_sec_websocket_key", strings.TrimSpace(c.GetHeader("Sec-WebSocket-Key")) != ""), ) return } defer func() { _ = wsConn.CloseNow() }() wsConn.SetReadLimit(service.ResolveOpenAIWSClientReadLimitBytes(h.cfg)) firstMessageTimeout := service.ResolveOpenAIWSClientFirstMessageTimeout(h.cfg) msgType, firstMessage, err := service.ReadOpenAIWSClientMessage( ctx, wsConn, firstMessageTimeout, coderws.StatusPolicyViolation, "missing first response.create message", ) if err != nil { if errors.Is(context.Cause(ctx), service.ErrOpenAIWSIngressLeaseLost) { reqLog.Warn("openai.websocket_ingress_lease_lost_before_first_message", zap.Error(err)) closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "websocket ingress capacity lease lost; please reconnect") return } closeStatus, closeReason := summarizeWSCloseErrorForLog(err) reqLog.Warn("openai.websocket_read_first_message_failed", zap.Error(err), zap.String("client_ip", clientIP), zap.String("close_status", closeStatus), zap.String("close_reason", closeReason), zap.Duration("read_timeout", firstMessageTimeout), ) closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "missing first response.create message") return } firstTurnStartedAt := time.Now() if msgType != coderws.MessageText && msgType != coderws.MessageBinary { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "unsupported websocket message type") return } if !gjson.ValidBytes(firstMessage) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "invalid JSON payload") return } reqModel := strings.TrimSpace(gjson.GetBytes(firstMessage, "model").String()) if reqModel == "" { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "model is required in first response.create payload") return } ensureCompositeTargetPlatform(c, apiKey, reqModel) ctx = c.Request.Context() if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite { platform, ok := service.ResolvedTargetPlatformFromContext(ctx) if !ok || !isResponsesWebSocketCompositePlatform(platform) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "Responses WebSocket API only supports OpenAI-compatible models for composite groups") return } } previousResponseID := strings.TrimSpace(gjson.GetBytes(firstMessage, "previous_response_id").String()) previousResponseIDKind := service.ClassifyOpenAIPreviousResponseIDKind(previousResponseID) if previousResponseID != "" && previousResponseIDKind == service.OpenAIPreviousResponseIDKindMessageID { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "previous_response_id must be a response.id (resp_*), not a message id") return } firstMessageToolCoverage := service.AnalyzeToolCallOutputContextCoverageBytes(firstMessage) previousResponseCanMove := !firstMessageToolCoverage.HasFunctionCallOutput || firstMessageToolCoverage.ContextCoversAllCallIDs reqLog = reqLog.With( zap.Bool("ws_ingress", true), zap.String("session_initial_model", reqModel), zap.Bool("has_previous_response_id", previousResponseID != ""), zap.String("previous_response_id_kind", previousResponseIDKind), ) setOpsRequestContext(c, reqModel, true) setOpsEndpointContext(c, "", int16(service.RequestTypeWSV2)) if decision := h.checkSecurityAuditStage(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, firstMessage, "first_turn"); decision != nil && !decision.AllowNextStage { writeSecurityAuditWSError(ctx, wsConn, decision) closeOpenAIClientWS(wsConn, securityAuditWSCloseStatus(decision), securityAuditWSCloseReason(decision)) return } imageIntent := service.IsExplicitImageGenerationIntent("/v1/responses", reqModel, firstMessage) if imageIntent && !service.GroupAllowsImageGeneration(apiKey.Group) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, service.ImageGenerationPermissionMessage()) return } // F5a: 握手层会话屏蔽检查。WS 握手无 body,显式标识仅来自握手 header // (session_id / conversation_id);无标识则放行,连接内仍有本地 flag 兜底。 cyberBlockKey := service.CyberSessionBlockKey(apiKey.ID, c, nil) if cyberBlockKey != "" && h.gatewayService.IsCyberSessionBlocked(c.Request.Context(), cyberBlockKey) { writeCyberSessionBlockedWSError(c.Request.Context(), wsConn) closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "session blocked by cyber-security policy") h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, reqModel, cyberBlockKey) return } cyberBlockedThisConn := false // 解析渠道级模型映射 channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel) var currentUserRelease func() var currentAccountRelease func() releaseAccountSlot := func() { if currentAccountRelease != nil { currentAccountRelease() currentAccountRelease = nil } } releaseTurnSlots := func() { releaseAccountSlot() if currentUserRelease != nil { currentUserRelease() currentUserRelease = nil } } // 必须尽早注册,确保任何 early return 都能释放已获取的并发槽位。 defer releaseTurnSlots() userReleaseFunc, userAcquired, err := h.concurrencyHelper.TryAcquireUserSlotForAPIKey(ctx, subject.UserID, subject.Concurrency, apiKey.ID) if err != nil { reqLog.Warn("openai.websocket_user_slot_acquire_failed", zap.Error(err)) closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "failed to acquire user concurrency slot") return } if !userAcquired { closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "too many concurrent requests, please retry later") return } currentUserRelease = wrapReleaseOnDone(ctx, userReleaseFunc) ensureUserSlotHeld := func() bool { if currentUserRelease != nil { return true } userReleaseFunc, userAcquired, err := h.concurrencyHelper.TryAcquireUserSlotForAPIKey(ctx, subject.UserID, subject.Concurrency, apiKey.ID) if err != nil { reqLog.Warn("openai.websocket_user_slot_reacquire_failed", zap.Error(err)) closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "failed to acquire user concurrency slot") return false } if !userAcquired { closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "too many concurrent requests, please retry later") return false } currentUserRelease = wrapReleaseOnDone(ctx, userReleaseFunc) return true } subscription, _ := middleware2.GetSubscriptionFromContext(c) requestPlatform := openAICompatibleRequestPlatform(ctx, apiKey) requiredTransport := service.OpenAIUpstreamTransportResponsesWebsocketV2Ingress if requestPlatform == service.PlatformGrok { requiredTransport = service.OpenAIUpstreamTransportHTTPSSE } if err := h.billingCacheService.CheckBillingEligibility(ctx, apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil { reqLog.Info("openai.websocket_billing_eligibility_check_failed", zap.Error(err)) closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "billing check failed") return } sessionHash := h.gatewayService.GenerateSessionHashWithFallback( c, firstMessage, openAIWSIngressFallbackSessionSeed(subject.UserID, apiKey.ID, apiKey.GroupID), ) maxAccountSwitches := h.maxAccountSwitches switchCount := 0 profitVetoCount := 0 failedAccountIDs := make(map[int64]struct{}) var lastFailoverErr *service.UpstreamFailoverError var oauth429FailoverState service.OpenAIOAuth429FailoverState wsAttemptMessage := append([]byte(nil), firstMessage...) handleWSFailover := func(account *service.Account, failoverErr *service.UpstreamFailoverError) bool { if ctx.Err() != nil { return false } if failoverErr.ShouldReportAccountScheduleFailure() { h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) } releaseAccountSlot() if !failoverErr.ShouldRetryNextAccount() { closeOpenAIWSFailoverExhausted(wsConn, failoverErr) return false } if ctx.Err() != nil { return false } h.gatewayService.RecordOpenAIAccountSwitch() failedAccountIDs[account.ID] = struct{}{} lastFailoverErr = failoverErr if switchCount >= maxAccountSwitches { closeOpenAIWSFailoverExhausted(wsConn, failoverErr) return false } switchCount++ if h.gatewayService.ShouldStopOpenAIOAuth429Failover(account, failoverErr.StatusCode, switchCount, &oauth429FailoverState) { closeOpenAIWSFailoverExhausted(wsConn, failoverErr) return false } reqLog.Warn("openai.websocket_upstream_failover_switching", zap.Int64("account_id", account.ID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("switch_count", switchCount), zap.Int("max_switches", maxAccountSwitches), ) if ctx.Err() != nil { return false } return ensureUserSlotHeld() } // 与 HTTP Responses 路径保持一致:生图意图请求要求账号支持 Responses API(#4417)。 // WSv2 传输本身已隐含 Responses 支持,此处为防御性对齐。 // 使用 IsExplicitImageGenerationIntent 排除被动 namespace 声明(#4476)。 requiredCapability := service.OpenAIEndpointCapabilityChatCompletions if service.IsExplicitImageGenerationIntent("/v1/responses", reqModel, firstMessage) && requestPlatform == service.PlatformOpenAI { requiredCapability = service.OpenAIEndpointCapabilityResponses } // 分组利润控制:WS 桥按连接装配定价上下文并装门(选号与抢槽共用该 // ctx)。连接内不重选号,但每个 turn 开始经 BeforeTurn 重新冻结 pricingAt // 并按最新门复核当前账号(准入与计费同源),峰前建连保活不能让后续 turn // 继续按建连时刻的谷价计费。生图意图只影响能力路由与图片计费,不关门。 // 建连时刻只用于选号/准入,不作为任何 turn 的计费定价时刻。 wsPricingCtx, _ := h.gatewayService.WithOpenAIRequestPricingContext(ctx, apiKey.GroupID) ctx = wsPricingCtx for { if ctx.Err() != nil { return } reqLog.Debug("openai.websocket_account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs))) selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability( ctx, apiKey.GroupID, previousResponseID, sessionHash, reqModel, failedAccountIDs, requiredTransport, requiredCapability, false, previousResponseCanMove, !imageIntent, requestPlatform, ) if err != nil { reqLog.Warn("openai.websocket_account_select_failed", zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)), zap.Int("excluded_account_count", len(failedAccountIDs)), ) if lastFailoverErr != nil { closeOpenAIWSFailoverExhausted(wsConn, lastFailoverErr) } else { closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account") } return } if selection == nil || selection.Account == nil { if lastFailoverErr != nil { closeOpenAIWSFailoverExhausted(wsConn, lastFailoverErr) } else { closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account") } return } account := selection.Account accountMaxConcurrency := account.Concurrency if selection.WaitPlan != nil && selection.WaitPlan.MaxConcurrency > 0 { accountMaxConcurrency = selection.WaitPlan.MaxConcurrency } // 终检、准入后绑定与后续 turn 级复核都使用选号结果携带的门(composite // 等跨分组调度的门只存在于调度栈局部 ctx);准入成功后并入连接 ctx。 admissionCtx := service.ContextWithSelectionProfitGate(ctx, selection) accountReleaseFunc := selection.ReleaseFunc if selection.Acquired { // 调度器已抢槽路径同样终检:选号与抢槽之间账号倍率可能刷新。 latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(admissionCtx, account) if vetoed { if accountReleaseFunc != nil { accountReleaseFunc() } reqLog.Debug("openai.websocket_account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { reqLog.Warn("openai.websocket_profit_veto_attempts_exhausted", zap.Int("profit_veto_count", profitVetoCount)) closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account") return } continue } account = latest selection.Account = latest } if !selection.Acquired { if selection.WaitPlan == nil { closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "account is busy, please retry later") return } fastReleaseFunc, fastAcquired, err := h.concurrencyHelper.TryAcquireAccountSlot( ctx, account.ID, selection.WaitPlan.MaxConcurrency, ) if err != nil { reqLog.Warn("openai.websocket_account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "failed to acquire account concurrency slot") return } if !fastAcquired { closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "account is busy, please retry later") return } // 分组利润控制:WS 快速抢槽成功后终检,越线则释放 // 槽位、排除该账号重新选号,全池耗尽由下一轮选号关闭连接。 latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(admissionCtx, account) if vetoed { if fastReleaseFunc != nil { fastReleaseFunc() } reqLog.Debug("openai.websocket_account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { reqLog.Warn("openai.websocket_profit_veto_attempts_exhausted", zap.Int("profit_veto_count", profitVetoCount)) closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account") return } continue } account = latest selection.Account = latest accountReleaseFunc = fastReleaseFunc } // 准入完成:门并入连接 ctx,turn 级复核与 failover 重选共用。 ctx = admissionCtx currentAccountRelease = wrapReleaseOnDone(ctx, accountReleaseFunc) if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, apiKey.GroupID, sessionHash, account.ID); err != nil { reqLog.Warn("openai.websocket_bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) } token, _, err := h.gatewayService.GetRequestCredential(ctx, c, account) if err != nil { reqLog.Warn("openai.websocket_get_access_token_failed", zap.Int64("account_id", account.ID), zap.Error(err)) if ctx.Err() != nil { return } var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { if handleWSFailover(account, failoverErr) { continue } return } closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "failed to get access token") return } reqLog.Debug("openai.websocket_account_selected", zap.Int64("account_id", account.ID), zap.String("account_name", account.Name), zap.String("schedule_layer", scheduleDecision.Layer), zap.Int("candidate_count", scheduleDecision.CandidateCount), ) maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey) var requestPayloadHash string var turnStartsMu sync.Mutex turnStarts := make(map[int]time.Time, 4) recordTurnStart := func(turn int, startedAt time.Time) { if turn <= 0 || startedAt.IsZero() { return } turnStartsMu.Lock() turnStarts[turn] = startedAt turnStartsMu.Unlock() } getTurnStart := func(turn int) time.Time { turnStartsMu.Lock() startedAt := turnStarts[turn] delete(turnStarts, turn) turnStartsMu.Unlock() return startedAt } // Passthrough rejects overlapping response.create frames, so one immutable // turn-tagged slot preserves the exact mapping used for the in-flight request. var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot] turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS}) // turn 级定价:BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号; // passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。 var turnPricing openAIWSTurnPricing hooks := &service.OpenAIWSIngressHooks{ ClientLifecycleContext: clientLifecycleCtx, InitialRequestModel: reqModel, InitialTurnStartedAt: firstTurnStartedAt, MaxReasoningEffort: maxReasoningEffort, ReasoningEffortMappings: reasoningEffortMappings, TurnStarted: recordTurnStart, BeforeRequest: func(turn int, payload []byte, originalModel string) error { c.Set(securityAuditWSTurnContextKey, turn) if turn == 1 { return nil } if !gjson.ValidBytes(payload) { return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", errors.New("invalid json")) } model := strings.TrimSpace(originalModel) if model == "" { model = strings.TrimSpace(gjson.GetBytes(payload, "model").String()) } if model == "" { model = reqModel } if decision := h.checkSecurityAuditStage(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, model, payload, "subsequent_turn"); decision != nil && !decision.AllowNextStage { writeSecurityAuditWSError(ctx, wsConn, decision) return service.NewOpenAIWSClientCloseError(securityAuditWSCloseStatus(decision), securityAuditWSCloseReason(decision), nil) } return nil }, MapRequestModel: func(turn int, originalModel string) (string, error) { model := strings.TrimSpace(originalModel) if model == "" { model = reqModel } mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, model) mappedModelUnchanged := false if previous := turnChannelMapping.Load(); previous != nil && previous.turn < turn { mappedModelUnchanged = strings.TrimSpace(previous.mapping.MappedModel) == strings.TrimSpace(mapping.MappedModel) } if turn > 1 && !mappedModelUnchanged && !account.IsModelSupported(model) && !account.IsModelSupported(mapping.MappedModel) { return "", newOpenAIWSUnsupportedModelSwitchError(mapping.MappedModel) } turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: turn, mapping: mapping}) return mapping.MappedModel, nil }, BeforeTurn: func(turn int) error { // turn==1 的会话屏蔽已由握手层检查覆盖;连接内 flag 只拦截后续 turn。 if cyberBlockedThisConn { return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil) } // 长连接跨峰谷/倍率刷新防护:每个 turn 按当前时刻重装门并复核 // 当前账号,越线即要求客户端重连重选(连接绑定单一上游账号, // 无法中途换号)。本 turn 的准入与计费共用同一 pricingAt。 turnCtx, turnAt := h.gatewayService.WithOpenAITurnPricingContext(ctx, apiKey.GroupID) if _, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(turnCtx, account); vetoed { reqLog.Info("openai.websocket_turn_profit_vetoed", zap.Int("turn", turn), zap.Int64("account_id", account.ID), zap.String("reason", reason)) return service.NewOpenAIWSClientCloseError(coderws.StatusTryAgainLater, "account is no longer eligible for this connection, please reconnect", nil) } turnPricing.freeze(turnAt) if turn == 1 { return nil } // 防御式清理:避免异常路径下旧槽位覆盖导致泄漏。 releaseTurnSlots() // 非首轮 turn 需要重新抢占并发槽位,避免长连接空闲占槽。 userReleaseFunc, userAcquired, err := h.concurrencyHelper.TryAcquireUserSlotForAPIKey(ctx, subject.UserID, subject.Concurrency, apiKey.ID) if err != nil { return service.NewOpenAIWSClientCloseError(coderws.StatusInternalError, "failed to acquire user concurrency slot", err) } if !userAcquired { return service.NewOpenAIWSClientCloseError(coderws.StatusTryAgainLater, "too many concurrent requests, please retry later", nil) } accountReleaseFunc, accountAcquired, err := h.concurrencyHelper.TryAcquireAccountSlot(ctx, account.ID, accountMaxConcurrency) if err != nil { if userReleaseFunc != nil { userReleaseFunc() } return service.NewOpenAIWSClientCloseError(coderws.StatusInternalError, "failed to acquire account concurrency slot", err) } if !accountAcquired { if userReleaseFunc != nil { userReleaseFunc() } return service.NewOpenAIWSClientCloseError(coderws.StatusTryAgainLater, "account is busy, please retry later", nil) } currentUserRelease = wrapReleaseOnDone(ctx, userReleaseFunc) currentAccountRelease = wrapReleaseOnDone(ctx, accountReleaseFunc) return nil }, AfterTurn: func(turn int, result *service.OpenAIForwardResult, turnErr error) { turnStart := getTurnStart(turn) // F1: cyber 标记按 turn 生命周期清理——defer 保证任意早返回路径都执行; // CyberBlocked 必须在 submit 前同步预捕获(task 闭包由 worker 池异步执行, // 届时 defer 已清除标记)。 defer clearCyberPolicyTurnState(c) releaseTurnSlots() turnRequestedModel := reqModel turnUpstreamModel := "" if result != nil && turn > 1 { if model := strings.TrimSpace(result.Model); model != "" { turnRequestedModel = model } } if result != nil { turnUpstreamModel = strings.TrimSpace(result.UpstreamModel) } var turnMapping service.ChannelMappingResult if snapshot := turnChannelMapping.Load(); snapshot != nil && snapshot.turn == turn { turnMapping = snapshot.mapping } else { turnMapping, _ = h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, turnRequestedModel) } if turnUpstreamModel == "" { turnUpstreamModel = turnRequestedModel } turnUsageFields := turnMapping.ToUsageFields(turnRequestedModel, turnUpstreamModel) h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, turnRequestedModel, turnErr != nil, cyberBlockKey, turnUsageFields, requestPayloadHash) if service.GetOpsCyberPolicy(c) != nil { cyberBlockedThisConn = true } if turnErr != nil { if result == nil || result.ImageCount <= 0 { return } // cyber 命中时该 turn 的用量已由 recordCyberPolicyIfMarked(forwardErrored=true) // 按真实 token 记录,这里不再走下方 RecordUsage,避免对同一 turn 双写/双扣费。 if service.GetOpsCyberPolicy(c) != nil { return } reqLog.Warn("openai.websocket_partial_error_with_image_result", zap.Int64("account_id", account.ID), zap.Int("image_count", result.ImageCount), zap.Error(turnErr), ) } if result == nil { return } result.BillingModel = openAIWSTurnBillingModel(result, turnMapping, turnRequestedModel, turnUpstreamModel) reqLog.Debug("openai.websocket_turn_billing", zap.Int("turn", turn), zap.String("turn_requested_model", turnRequestedModel), zap.String("turn_upstream_model", turnUpstreamModel), zap.String("billing_model", result.BillingModel), ) // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 if account.Type == service.AccountTypeOAuth && !account.IsShadow() { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, result.ResponseHeaders) } scheduleModel := turnUpstreamModel if scheduleModel == "" { scheduleModel = turnRequestedModel } h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, scheduleModel, openAIForwardSucceededForScheduling(result), result.FirstTokenMs) inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) sessionID := service.ExtractClientSessionID(c) turnRecordPricingAt := turnPricing.currentOr(turnStart) cyberBlocked := service.GetOpsCyberPolicy(c) != nil h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) { if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{ Result: result, APIKey: apiKey, User: apiKey.User, Account: account, Subscription: subscription, InboundEndpoint: inboundEndpoint, UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, APIKeyService: h.apiKeyService, QuotaPlatform: quotaPlatform, SessionID: sessionID, ChannelUsageFields: turnUsageFields, PricingAt: turnRecordPricingAt, CyberBlocked: cyberBlocked, }); err != nil { reqLog.Error("openai.websocket_record_usage_failed", zap.Int64("account_id", account.ID), zap.String("request_id", result.RequestID), zap.Error(err), ) } }) }, } wsFirstMessage := wsAttemptMessage // 切组/会话失配防护:previous_response_id 未在当前分组命中粘连账号(StickyPreviousHit=false), // 说明该会话链不属于本次调度到的账号,原样转发会触发上游会话链鉴权失败(“鉴权失败,请检查 API Key”)。 // 故剥离首包里的 previous_response_id,改用首包内 input 重建上下文;带 function_call_output 的 // 工具续链无法重建,保持原样。仅作用于首轮首包,后续 turn 的续链由 WS 转发层既有逻辑处理。 if previousResponseID != "" && !scheduleDecision.StickyPreviousHit && previousResponseCanMove { wsFirstMessage = service.RemovePreviousResponseIDFromBody(wsFirstMessage) reqLog.Debug("openai.websocket_previous_response_id_stripped_cross_group", zap.Int64("account_id", account.ID), zap.String("schedule_layer", scheduleDecision.Layer), ) } // WebSocket 首包可能很大,hash 必须在 hooks 外算成字符串,避免 AfterTurn 闭包保活请求体。 requestPayloadHash = service.HashUsageRequestPayload(wsFirstMessage) if err := h.gatewayService.ProxyResponsesWebSocketFromClient(ctx, c, wsConn, account, token, wsFirstMessage, hooks); err != nil { var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { retryPayload, retryCurrentTurn := service.OpenAIWSCurrentTurnRetryPayload(err) nextAttemptMessage, retrySafe := openAIWSNextAttemptMessage(wsAttemptMessage, retryPayload, retryCurrentTurn) if !retrySafe { closeOpenAIWSFailoverExhausted(wsConn, failoverErr) return } wsAttemptMessage = nextAttemptMessage if retryCurrentTurn { previousResponseID = "" reqLog.Warn("openai.websocket_current_turn_failover_retry", zap.Int64("account_id", account.ID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("retry_payload_bytes", len(retryPayload)), ) } if handleWSFailover(account, failoverErr) { continue } return } if errors.Is(context.Cause(ctx), service.ErrOpenAIWSIngressLeaseLost) { reqLog.Warn("openai.websocket_ingress_lease_lost", zap.Int64("account_id", account.ID), zap.Error(err), ) closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "websocket ingress capacity lease lost; please reconnect") return } var closeErr *service.OpenAIWSClientCloseError if errors.As(err, &closeErr) && closeErr.StatusCode() == coderws.StatusNormalClosure { reqLog.Info("openai.websocket_ingress_closed_normally", zap.Int64("account_id", account.ID), zap.String("reason", closeErr.Reason()), ) closeOpenAIClientWS(wsConn, closeErr.StatusCode(), closeErr.Reason()) return } if shouldReportOpenAIWSProxyAccountFailure(err) { h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) } closeStatus, closeReason := summarizeWSCloseErrorForLog(err) proxyFailedFields := []zap.Field{ zap.Int64("account_id", account.ID), zap.Error(err), zap.String("close_status", closeStatus), zap.String("close_reason", closeReason), } if account.Proxy != nil { proxyFailedFields = append(proxyFailedFields, zap.Int64("proxy_id", account.Proxy.ID), zap.String("proxy_name", account.Proxy.Name), zap.String("proxy_host", account.Proxy.Host), zap.Int("proxy_port", account.Proxy.Port), ) } else if account.ProxyID != nil { proxyFailedFields = append(proxyFailedFields, zap.Int64p("proxy_id", account.ProxyID)) } reqLog.Warn("openai.websocket_proxy_failed", proxyFailedFields...) if errors.As(err, &closeErr) { closeOpenAIClientWS(wsConn, closeErr.StatusCode(), closeErr.Reason()) return } closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "upstream websocket proxy failed") return } reqLog.Info("openai.websocket_ingress_closed", zap.Int64("account_id", account.ID)) return } } func (h *OpenAIGatewayHandler) recoverResponsesPanic(c *gin.Context, streamStarted *bool) { recovered := recover() if recovered == nil { return } started := false if streamStarted != nil { started = *streamStarted } wroteFallback := h.ensureForwardErrorResponse(c, started) requestLogger(c, "handler.openai_gateway.responses").Error( "openai.responses_panic_recovered", zap.Bool("fallback_error_response_written", wroteFallback), zap.Any("panic", recovered), zap.ByteString("stack", debug.Stack()), ) } // recoverAnthropicMessagesPanic recovers from panics in the Anthropic Messages // handler and returns an Anthropic-formatted error response. func (h *OpenAIGatewayHandler) recoverAnthropicMessagesPanic(c *gin.Context, streamStarted *bool) { recovered := recover() if recovered == nil { return } started := streamStarted != nil && *streamStarted requestLogger(c, "handler.openai_gateway.messages").Error( "openai.messages_panic_recovered", zap.Bool("stream_started", started), zap.Any("panic", recovered), zap.ByteString("stack", debug.Stack()), ) if !started { h.anthropicErrorResponse(c, http.StatusInternalServerError, "api_error", "Internal server error") } } func (h *OpenAIGatewayHandler) ensureResponsesDependencies(c *gin.Context, reqLog *zap.Logger) bool { missing := h.missingResponsesDependencies() if len(missing) == 0 { return true } if reqLog == nil { reqLog = requestLogger(c, "handler.openai_gateway.responses") } reqLog.Error("openai.handler_dependencies_missing", zap.Strings("missing_dependencies", missing)) if c != nil && c.Writer != nil && !c.Writer.Written() { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": gin.H{ "type": "api_error", "message": "Service temporarily unavailable", }, }) } return false } func (h *OpenAIGatewayHandler) missingResponsesDependencies() []string { missing := make([]string, 0, 5) if h == nil { return append(missing, "handler") } if h.gatewayService == nil { missing = append(missing, "gatewayService") } if h.billingCacheService == nil { missing = append(missing, "billingCacheService") } if h.apiKeyService == nil { missing = append(missing, "apiKeyService") } if h.concurrencyHelper == nil || h.concurrencyHelper.concurrencyService == nil { missing = append(missing, "concurrencyHelper") } return missing } func getContextInt64(c *gin.Context, key string) (int64, bool) { if c == nil || key == "" { return 0, false } v, ok := c.Get(key) if !ok { return 0, false } switch t := v.(type) { case int64: return t, true case int: return int64(t), true case int32: return int64(t), true case float64: return int64(t), true default: return 0, false } } func (h *OpenAIGatewayHandler) submitUsageRecordTask(parent context.Context, task service.UsageRecordTask) { if task == nil { return } task = wrapUsageRecordTaskContext(parent, task) if h.usageRecordWorkerPool != nil { if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDroppedStopped { return } // 池已停止(进程关停窗口):计费任务不能静默丢失,降级为内联同步执行。 // 显式配置的 drop/sample 溢出丢弃仍按配置语义保留。 logger.L().With( zap.String("component", "handler.openai_gateway.responses"), ).Warn("openai.usage_record_task_stopped_sync_fallback") } // 回退路径:worker 池未注入或已停止时同步执行,避免退回到无界 goroutine 模式。 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() defer func() { if recovered := recover(); recovered != nil { logger.L().With( zap.String("component", "handler.openai_gateway.responses"), zap.Any("panic", recovered), ).Error("openai.usage_record_task_panic_recovered") } }() task(ctx) } func (h *OpenAIGatewayHandler) submitOpenAIUsageRecordTask(parent context.Context, result *service.OpenAIForwardResult, task service.UsageRecordTask) { // Money-critical bills never drop on pool overflow: media, search surcharge, voice. if result != nil && (result.ImageCount > 0 || result.VideoCount > 0 || result.SearchCount > 0 || result.WebSearchCalls > 0 || result.AudioUsage != nil) { h.submitMandatoryUsageRecordTask(parent, task) return } h.submitUsageRecordTask(parent, task) } func (h *OpenAIGatewayHandler) submitMandatoryUsageRecordTask(parent context.Context, task service.UsageRecordTask) { if task == nil { return } task = wrapUsageRecordTaskContext(parent, task) if h.usageRecordWorkerPool != nil { if mode := h.usageRecordWorkerPool.Submit(task); !mode.Dropped() { return } logger.L().With( zap.String("component", "handler.openai_gateway.usage"), ).Warn("openai.usage_record_task_mandatory_sync_fallback") } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() defer func() { if recovered := recover(); recovered != nil { logger.L().With( zap.String("component", "handler.openai_gateway.usage"), zap.Any("panic", recovered), ).Error("openai.usage_record_task_panic_recovered") } }() task(ctx) } func (h *OpenAIGatewayHandler) acquireImageGenerationSlot(c *gin.Context, streamStarted bool) (func(), bool) { if h == nil || h.cfg == nil || h.imageLimiter == nil { return nil, true } imageConcurrency := h.cfg.Gateway.ImageConcurrency wait := strings.TrimSpace(imageConcurrency.OverflowMode) == config.ImageConcurrencyOverflowModeWait release, acquired := h.imageLimiter.Acquire( c.Request.Context(), imageConcurrency.Enabled, imageConcurrency.MaxConcurrentRequests, wait, time.Duration(imageConcurrency.WaitTimeoutSeconds)*time.Second, imageConcurrency.MaxWaitingRequests, ) if acquired { return release, true } h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Image generation concurrency limit exceeded, please retry later", streamStarted) return nil, false } // handleConcurrencyError handles concurrency-related acquire errors. func (h *OpenAIGatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) { status, errType, message := concurrencyErrorResponse(err, slotType) h.handleStreamingAwareError(c, status, errType, message, streamStarted) } func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, streamStarted bool) { if failoverErr == nil { h.handleFailoverExhaustedSimple(c, http.StatusBadGateway, streamStarted) return } if failoverErr.IsOpenAIRequestBodyTooLarge() { service.SetOpsUpstreamError(c, http.StatusRequestEntityTooLarge, service.OpenAIRequestBodyTooLargeClientMessage, "") h.handleStreamingAwareError( c, http.StatusRequestEntityTooLarge, "invalid_request_error", service.OpenAIRequestBodyTooLargeClientMessage, streamStarted, ) return } copyFailoverRetryAfter(c, failoverErr.ResponseHeaders) if failoverErr.IsCredentialFailure() { status, message := credentialFailoverClientResponse(failoverErr) h.handleStreamingAwareError(c, status, "upstream_error", message, streamStarted) return } statusCode := failoverErr.StatusCode responseBody := failoverErr.ResponseBody if service.IsOpenAISilentRefusalErrorBody(responseBody) { service.SetOpsUpstreamError(c, statusCode, service.OpenAISilentRefusalClientMessage(), "") h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage(), streamStarted) return } // 先检查透传规则 if h.errorPassthroughService != nil && len(responseBody) > 0 { if rule := h.errorPassthroughService.MatchRule("openai", statusCode, responseBody); rule != nil { // 确定响应状态码 respCode := statusCode if !rule.PassthroughCode && rule.ResponseCode != nil { respCode = *rule.ResponseCode } // 确定响应消息 msg := service.ExtractUpstreamErrorMessage(responseBody) if !rule.PassthroughBody && rule.CustomMessage != nil { msg = *rule.CustomMessage } if rule.SkipMonitoring { c.Set(service.OpsSkipPassthroughKey, true) } h.handleStreamingAwareError(c, respCode, "upstream_error", msg, streamStarted) return } } // 记录原始上游状态码,以便 ops 错误日志捕获真实的上游错误 upstreamMsg := service.ExtractUpstreamErrorMessage(responseBody) service.SetOpsUpstreamError(c, statusCode, upstreamMsg, "") // 使用默认的错误映射 status, errType, errMsg := h.mapUpstreamError(statusCode) h.handleStreamingAwareError(c, status, errType, errMsg, streamStarted) } func credentialFailoverClientResponse(failoverErr *service.UpstreamFailoverError) (int, string) { if failoverErr != nil && failoverErr.Reason == service.AntigravityCredentialRejectedReason { return http.StatusBadGateway, service.AntigravityCredentialRejectedClientMessage } return http.StatusServiceUnavailable, service.GrokCredentialUnavailableClientMessage } func copyFailoverRetryAfter(c *gin.Context, headers http.Header) { if c == nil || headers == nil { return } retryAfter := strings.TrimSpace(headers.Get("Retry-After")) if retryAfter == "" || len(retryAfter) > 128 || strings.ContainsAny(retryAfter, "\r\n") || !isSafeRetryAfter(retryAfter) { return } c.Header("Retry-After", retryAfter) } func isSafeRetryAfter(value string) bool { digitsOnly := true for _, char := range value { if char < '0' || char > '9' { digitsOnly = false break } } if digitsOnly { seconds, err := strconv.ParseUint(value, 10, 32) return err == nil && seconds <= uint64((7*24*time.Hour)/time.Second) } retryAt, err := http.ParseTime(value) if err != nil { return false } return !retryAt.After(time.Now().Add(7 * 24 * time.Hour)) } // handleFailoverExhaustedSimple 简化版本,用于没有响应体的情况 func (h *OpenAIGatewayHandler) handleFailoverExhaustedSimple(c *gin.Context, statusCode int, streamStarted bool) { status, errType, errMsg := h.mapUpstreamError(statusCode) service.SetOpsUpstreamError(c, statusCode, errMsg, "") h.handleStreamingAwareError(c, status, errType, errMsg, streamStarted) } func (h *OpenAIGatewayHandler) mapUpstreamError(statusCode int) (int, string, string) { switch statusCode { case 401: return http.StatusBadGateway, "upstream_error", "Upstream authentication failed, please contact administrator" case 403: return http.StatusBadGateway, "upstream_error", "Upstream access forbidden, please contact administrator" case 429: return http.StatusTooManyRequests, "rate_limit_error", "Upstream rate limit exceeded, please retry later" case 529: return http.StatusServiceUnavailable, "upstream_error", "Upstream service overloaded, please retry later" case 500, 502, 503, 504: return http.StatusBadGateway, "upstream_error", "Upstream service temporarily unavailable" default: return http.StatusBadGateway, "upstream_error", "Upstream request failed" } } // handleStreamingAwareError handles errors that may occur after streaming has started func (h *OpenAIGatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) { h.handleStreamingAwareErrorWithCode(c, status, errType, "", message, streamStarted, false) } func (h *OpenAIGatewayHandler) handleStreamingAwareErrorWithCode( c *gin.Context, status int, errType string, code string, message string, streamStarted bool, countTowardsSLA bool, ) { // body-signal compact 心跳可能已把响应头提交为 200:先停心跳(建立 // happens-before,接管 ResponseWriter),并升级为流内错误处理。 if service.StopOpenAICompactSSEKeepaliveCommitted(c) { streamStarted = true } if streamStarted { if countTowardsSLA { service.MarkOpsStreamFailure(c, errType, code, message, status) } else { service.MarkOpsStreamError(c, errType, message, status) } // /v1/responses 的严格 SDK(Codex CLI)要求终止事件必须属于 // response.completed/failed/incomplete/cancelled 集合。 // 通用 `event: error` 帧不被识别为终止事件,会导致 // "stream closed before response.completed"。 if inboundIsResponses(c) { if writeResponsesFailedSSE(c, errType, message) { return } } // Stream already started, send error as SSE event then close flusher, ok := c.Writer.(http.Flusher) if ok { errorObject := gin.H{"type": errType, "message": message} if code != "" { errorObject["code"] = code } payload, err := json.Marshal(gin.H{"error": errorObject}) if err != nil { payload = []byte(`{"error":{"type":"upstream_error","message":"Upstream request failed"}}`) } errorEvent := "event: error\ndata: " + string(payload) + "\n\n" if _, err := fmt.Fprint(c.Writer, errorEvent); err != nil { _ = c.Error(err) } flusher.Flush() } return } // Normal case: return JSON response with proper status code if code == "" { h.errorResponse(c, status, errType, message) return } c.JSON(status, gin.H{"error": gin.H{ "type": errType, "code": code, "message": message, }}) } func (h *OpenAIGatewayHandler) ensureOpenAIStreamReadErrorResponse(c *gin.Context, err error, streamStarted bool) bool { code, message, ok := service.OpenAIUpstreamStreamReadErrorDetails(err) if !ok || c == nil || c.Writer == nil || service.IsResponseCommitted(c) { return false } if c.Writer.Written() { streamStarted = true } h.handleStreamingAwareErrorWithCode( c, http.StatusBadGateway, "upstream_error", code, message, streamStarted, true, ) return true } // ensureForwardErrorResponse 在 Forward 返回错误但尚未写响应时补写统一错误响应。 func (h *OpenAIGatewayHandler) ensureForwardErrorResponse(c *gin.Context, streamStarted bool) bool { if c == nil || c.Writer == nil { return false } // 先停 compact 心跳再读 Writer 状态,避免与心跳 goroutine 竞争。 compactKeepaliveCommitted := service.StopOpenAICompactSSEKeepaliveCommitted(c) if compactKeepaliveCommitted { streamStarted = true } imageKeepalivePresent := service.OpenAIImagesJSONKeepalivePresent(c) service.StopOpenAIImagesJSONKeepaliveCommitted(c) imageKeepalivePaddingOnly := false imageKeepaliveResponseWritten := false if imageKeepalivePresent { adjustedSize := service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c) imageKeepalivePaddingOnly = adjustedSize < 0 imageKeepaliveResponseWritten = adjustedSize >= 0 } compactKeepaliveHasMeaningfulOutput := compactKeepaliveCommitted && service.OpenAICompactKeepaliveAdjustedWrittenSize(c) > 0 // Compact keepalive may have committed 200 headers without writing a // semantic SSE event. In that case the Responses stream still needs its // protocol-correct terminal response.failed event. if (service.IsResponseCommitted(c) && (!compactKeepaliveCommitted || compactKeepaliveHasMeaningfulOutput)) || (!compactKeepaliveCommitted && imageKeepaliveResponseWritten) { return false } if c.Writer.Written() && !imageKeepalivePaddingOnly { streamStarted = true } h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed", streamStarted) return true } func shouldLogOpenAIForwardFailureAsWarn(c *gin.Context, wroteFallback bool) bool { if wroteFallback { return false } if c == nil || c.Writer == nil { return false } return c.Writer.Written() } // openAIForwardErrorAlreadyCommunicated reports whether Forward returned an // error after it had already written the upstream terminal error response to // the client. // // This matters for Responses streams: upstream may return HTTP 200 with a // non-retryable `response.failed` event (for example a policy/safety rejection). // The service layer forwards that terminal event verbatim, then returns an // error so the caller can log/account for the failed upstream response. The // handler must not append its generic fallback `response.failed`, otherwise // strict clients may see the useful upstream message replaced by "Upstream // request failed" or receive duplicate terminal events. func openAIForwardErrorAlreadyCommunicated(c *gin.Context, writerSizeBeforeForward int, err error) bool { if err == nil || c == nil || c.Writer == nil { return false } // 与快照同口径:排除 compact 心跳字节,避免"仅心跳写出"被误判为 // 响应已写出(#3887)。 if service.OpenAICompactKeepaliveAdjustedWrittenSize(c) == writerSizeBeforeForward || service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c) == writerSizeBeforeForward { return false } // cyber_policy 命中时上游原始错误体已透传给客户端(非流式 c.Data 写出 400 body, // 流式写出 response.failed 事件),不能再让 ensureForwardErrorResponse 追加 // fallback —— 否则在已写出的完整响应尾部追加 SSE(responses 端点尾随 // response.failed、chat 端点尾随 event:error),污染响应体。Size 已变化证明响应确已写出。 if service.GetOpsCyberPolicy(c) != nil { return true } msg := strings.TrimSpace(err.Error()) for _, prefix := range []string{ "upstream response failed:", "non-streaming openai protocol error:", } { if strings.HasPrefix(msg, prefix) { return true } } return false } func openAIForwardMayFailover(c *gin.Context, writerSizeBeforeForward int, failoverErr *service.UpstreamFailoverError) bool { if c == nil || c.Writer == nil { return false } if service.OpenAICompactKeepaliveAdjustedWrittenSize(c) == writerSizeBeforeForward { return true } return failoverErr != nil && failoverErr.SafeToFailoverAfterWrite } func openAIRequestAllowsFailoverReplay(c *gin.Context) bool { if c == nil || c.Request == nil { return false } return !failoverClientGone(c) } func openAIFirstOutputFailoverExhausted(failoverErr *service.UpstreamFailoverError, switchCount *int) bool { if failoverErr == nil || !failoverErr.SafeToFailoverAfterWrite || switchCount == nil { return false } if *switchCount >= maxOpenAIFirstOutputTimeoutSwitches { return true } *switchCount = *switchCount + 1 return false } // errorResponse returns OpenAI API format error response func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType, message string) { // body-signal compact 心跳可能已把响应头提交为 200:JSON 错误体会与已 // 提交的 SSE 流交错,必须降级为 response.failed 终止事件(#3887)。 if service.StopOpenAICompactSSEKeepaliveCommitted(c) { service.MarkOpsStreamError(c, errType, message, status) if writeResponsesFailedSSE(c, errType, message) { return } } c.JSON(status, gin.H{ "error": gin.H{ "type": errType, "message": message, }, }) } // openAICompactKeepaliveInterval 复用流式 keepalive 配置作为 compact 下游 // 心跳间隔;0 表示禁用(与流式路径语义一致)。 func (h *OpenAIGatewayHandler) openAICompactKeepaliveInterval() time.Duration { if h.cfg == nil || h.cfg.Gateway.StreamKeepaliveInterval <= 0 { return 0 } return time.Duration(h.cfg.Gateway.StreamKeepaliveInterval) * time.Second } func setOpenAIClientTransportHTTP(c *gin.Context) { service.SetOpenAIClientTransport(c, service.OpenAIClientTransportHTTP) } func setOpenAIClientTransportWS(c *gin.Context) { service.SetOpenAIClientTransport(c, service.OpenAIClientTransportWS) } func ensureOpenAIPoolModeSessionHash(sessionHash string, account *service.Account) string { if sessionHash != "" || account == nil || !account.IsPoolMode() { return sessionHash } // 为当前请求生成一次性粘性会话键,确保同账号重试不会重新负载均衡到其他账号。 return "openai-pool-retry-" + uuid.NewString() } func openAIWSIngressFallbackSessionSeed(userID, apiKeyID int64, groupID *int64) string { gid := int64(0) if groupID != nil { gid = *groupID } return fmt.Sprintf("openai_ws_ingress:%d:%d:%d", gid, userID, apiKeyID) } func isOpenAIWSUpgradeRequest(r *http.Request) bool { if r == nil { return false } if !strings.EqualFold(strings.TrimSpace(r.Header.Get("Upgrade")), "websocket") { return false } return strings.Contains(strings.ToLower(strings.TrimSpace(r.Header.Get("Connection"))), "upgrade") } func closeOpenAIClientWS(conn *coderws.Conn, status coderws.StatusCode, reason string) { if conn == nil { return } reason = strings.TrimSpace(reason) if len(reason) > 120 { reason = reason[:120] } _ = conn.Close(status, reason) _ = conn.CloseNow() } func openAIWSNextAttemptMessage(current, retryPayload []byte, retryCurrentTurn bool) ([]byte, bool) { if !retryCurrentTurn { return append([]byte(nil), current...), true } if len(retryPayload) == 0 { return nil, false } return append([]byte(nil), retryPayload...), true } func closeOpenAIWSFailoverExhausted(conn *coderws.Conn, failoverErr *service.UpstreamFailoverError) { if failoverErr == nil { closeOpenAIClientWS(conn, coderws.StatusInternalError, "upstream websocket proxy failed") return } if failoverErr.Stage == service.GatewayFailureStageAccountAuth { closeOpenAIClientWS(conn, coderws.StatusTryAgainLater, service.GrokCredentialUnavailableClientMessage) return } switch failoverErr.StatusCode { case http.StatusTooManyRequests: closeOpenAIClientWS(conn, coderws.StatusTryAgainLater, "upstream rate limit exceeded, please retry later") case 529, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: closeOpenAIClientWS(conn, coderws.StatusTryAgainLater, "upstream service temporarily unavailable") case http.StatusUnauthorized, http.StatusForbidden: closeOpenAIClientWS(conn, coderws.StatusPolicyViolation, "upstream websocket authentication failed") default: closeOpenAIClientWS(conn, coderws.StatusInternalError, "upstream websocket proxy failed") } } func writeContentModerationWSError(ctx context.Context, conn *coderws.Conn, decision *service.ContentModerationDecision) { if conn == nil || decision == nil { return } if ctx == nil { ctx = context.Background() } message := strings.TrimSpace(decision.Message) if message == "" { message = "content moderation blocked this request" } payload, err := json.Marshal(gin.H{ "event_id": "evt_content_moderation_blocked", "type": "error", "error": gin.H{ "type": "invalid_request_error", "code": contentModerationErrorCode(decision), "message": message, }, }) if err != nil { payload = []byte(`{"event_id":"evt_content_moderation_blocked","type":"error","error":{"type":"invalid_request_error","code":"content_policy_violation","message":"content moderation blocked this request"}}`) } writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() _ = conn.Write(writeCtx, coderws.MessageText, payload) } // writeCyberSessionBlockedWSError sends an error frame telling the client this // session is blocked by the cyber session block (F5a) before closing. func writeCyberSessionBlockedWSError(ctx context.Context, conn *coderws.Conn) { if conn == nil { return } if ctx == nil { ctx = context.Background() } payload, err := json.Marshal(gin.H{ "event_id": "evt_cyber_session_blocked", "type": "error", "error": gin.H{ "type": "permission_error", "code": "session_blocked_by_cyber_policy", "message": cyberSessionBlockedClientMsg, }, }) if err != nil { payload = []byte(`{"event_id":"evt_cyber_session_blocked","type":"error","error":{"type":"permission_error","code":"session_blocked_by_cyber_policy","message":"This session is blocked by cyber-security policy, please start a new session"}}`) } writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() _ = conn.Write(writeCtx, coderws.MessageText, payload) } // cyberPolicyRecordedKey guards against double-firing recordCyberPolicyIfMarked // within one request (e.g. in a retry/failover loop). const cyberPolicyRecordedKey = "ops_cyber_recorded" // cyberPolicyOpsErrorMeta carries request-scoped fields captured outside the // async goroutine for building the cyber ops_error_logs entry. type cyberPolicyOpsErrorMeta struct { RequestID string ClientRequestID string Platform string Model string RequestPath string Stream bool InboundEndpoint string UserAgent string APIKeyPrefix string UserID int64 APIKeyID int64 AccountID int64 GroupID *int64 ClientIP string CreatedAt time.Time SessionBlockKey string } // buildCyberPolicyOpsErrorEntry builds the ops_error_logs entry for an upstream // cyber_policy hit. StatusCode mirrors what the codex client actually received // (400 non-stream / 200 stream), per F6. func buildCyberPolicyOpsErrorEntry(meta cyberPolicyOpsErrorMeta, mark *service.CyberPolicyMark) *service.OpsInsertErrorLogInput { rt := int16(service.RequestTypeCyberBlocked) entry := &service.OpsInsertErrorLogInput{ RequestID: meta.RequestID, ClientRequestID: meta.ClientRequestID, Platform: meta.Platform, Model: meta.Model, RequestPath: meta.RequestPath, Stream: meta.Stream, InboundEndpoint: meta.InboundEndpoint, RequestType: &rt, UserAgent: meta.UserAgent, APIKeyPrefix: meta.APIKeyPrefix, ErrorPhase: "request", ErrorType: "cyber_policy", Severity: "P3", StatusCode: mark.UpstreamStatus, IsBusinessLimited: true, ErrorMessage: "cyber_policy: " + mark.Message, // 原始 body 直接入队;ops service 落库前统一走 sanitizeErrorBodyForStorage 脱敏与截断。 ErrorBody: mark.Body, ErrorSource: "upstream_http", ErrorOwner: "provider", CreatedAt: meta.CreatedAt, } if meta.UserID > 0 { entry.UserID = &meta.UserID } if meta.APIKeyID > 0 { entry.APIKeyID = &meta.APIKeyID } if meta.AccountID > 0 { entry.AccountID = &meta.AccountID } entry.GroupID = meta.GroupID if meta.ClientIP != "" { entry.ClientIP = &meta.ClientIP } return entry } // 双语单串:网关客户端面向中英用户,且本错误无 i18n 协商通道。 const cyberSessionBlockedClientMsg = "该会话已被网络安全策略屏蔽,请开启新会话 / This session is blocked by cyber-security policy, please start a new session" // buildCyberSessionBlockedOpsEntry builds the ops_error_logs entry for a request // rejected locally by the cyber session block (F5a). Distinct error_type from // upstream `cyber_policy`; never feeds moderation logs / violation counting // (the request never reached upstream — see spec). func buildCyberSessionBlockedOpsEntry(meta cyberPolicyOpsErrorMeta) *service.OpsInsertErrorLogInput { rt := int16(service.RequestTypeCyberBlocked) entry := &service.OpsInsertErrorLogInput{ RequestID: meta.RequestID, ClientRequestID: meta.ClientRequestID, Platform: meta.Platform, Model: meta.Model, RequestPath: meta.RequestPath, Stream: meta.Stream, InboundEndpoint: meta.InboundEndpoint, RequestType: &rt, UserAgent: meta.UserAgent, APIKeyPrefix: meta.APIKeyPrefix, ErrorPhase: "request", ErrorType: "cyber_policy_session_blocked", Severity: "P3", StatusCode: http.StatusForbidden, IsBusinessLimited: true, ErrorMessage: "cyber_policy_session_blocked: request rejected locally by session block", ErrorSource: "gateway_local", ErrorOwner: "platform", CreatedAt: meta.CreatedAt, // AccountID 有意不设:请求在账号选择前即被拒绝。 } if meta.SessionBlockKey != "" { entry.ErrorBody = "session_block_key=" + meta.SessionBlockKey } if meta.UserID > 0 { entry.UserID = &meta.UserID } if meta.APIKeyID > 0 { entry.APIKeyID = &meta.APIKeyID } entry.GroupID = meta.GroupID if meta.ClientIP != "" { entry.ClientIP = &meta.ClientIP } return entry } // cyberSessionBlockFormat selects the per-endpoint error envelope for a locally // blocked session (用户决策:兼容路径各自格式). type cyberSessionBlockFormat int const ( cyberBlockFormatResponses cyberSessionBlockFormat = iota cyberBlockFormatChat cyberBlockFormatAnthropic ) // rejectIfCyberSessionBlocked checks the session-block table BEFORE account // selection. Returns true when the request was rejected (response already // written + ops entry enqueued). Fail-open: disabled switch / empty key / // store error → false. func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKey *service.APIKey, body []byte, model string, format cyberSessionBlockFormat) bool { if h == nil || h.gatewayService == nil || apiKey == nil { return false } // 开关默认关:先走 ~ns 级缓存开关检查,再付出 key 派生(gjson+sha256)成本。 if enabled, _ := h.gatewayService.CyberSessionBlockRuntime(c.Request.Context()); !enabled { return false } key := service.CyberSessionBlockKey(apiKey.ID, c, body) if key == "" { return false } if !h.gatewayService.IsCyberSessionBlocked(c.Request.Context(), key) { return false } // body-signal compact 心跳可能已把响应头提交为 200(cyber 检查在用户槽位 // 长等待之后执行):以 response.failed 终止事件回传;未提交时停拍后照常 // 写 JSON(#3887)。 if service.StopOpenAICompactSSEKeepaliveCommitted(c) { service.MarkOpsStreamError(c, "permission_error", cyberSessionBlockedClientMsg, http.StatusForbidden) if writeResponsesFailedSSE(c, "permission_error", cyberSessionBlockedClientMsg) { h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, model, key) return true } } switch format { case cyberBlockFormatAnthropic: c.JSON(http.StatusForbidden, gin.H{"type": "error", "error": gin.H{ "type": "permission_error", "message": cyberSessionBlockedClientMsg, }}) default: // cyberBlockFormatResponses 与 cyberBlockFormatChat:同构的 OpenAI error envelope c.JSON(http.StatusForbidden, gin.H{"error": gin.H{ "type": "permission_error", "code": "session_blocked_by_cyber_policy", "message": cyberSessionBlockedClientMsg, }}) } h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, model, key) return true } // enqueueCyberSessionBlockedOpsEntry captures request meta and enqueues the // ops_error_logs entry for a locally blocked request. func (h *OpenAIGatewayHandler) enqueueCyberSessionBlockedOpsEntry(c *gin.Context, apiKey *service.APIKey, model string, sessionBlockKey string) { if h.opsService == nil { return } meta := cyberPolicyOpsErrorMeta{Model: model, InboundEndpoint: GetInboundEndpoint(c), CreatedAt: time.Now(), SessionBlockKey: sessionBlockKey} meta.RequestID = c.Writer.Header().Get("X-Request-Id") if c.Request != nil && c.Request.URL != nil { meta.RequestPath = c.Request.URL.Path } if v, ok := c.Get(opsStreamKey); ok { if b, ok := v.(bool); ok { meta.Stream = b } } requestCtx := context.Background() if c.Request != nil { requestCtx = c.Request.Context() } meta.Platform = resolveOpsPlatform(requestCtx, apiKey, guessPlatformFromPath(meta.RequestPath)) if c.Request != nil { meta.ClientRequestID, _ = c.Request.Context().Value(ctxkey.ClientRequestID).(string) meta.UserAgent = c.GetHeader("User-Agent") meta.ClientIP = strings.TrimSpace(ip.GetClientIP(c)) } meta.APIKeyID = apiKey.ID meta.GroupID = apiKey.GroupID meta.APIKeyPrefix = keyPrefix(apiKey.Key, 8) if apiKey.User != nil { meta.UserID = apiKey.User.ID } enqueueOpsErrorLog(h.opsService, buildCyberSessionBlockedOpsEntry(meta)) } // recordCyberPolicyIfMarked 在 gateway forward 返回后检查 cyber 标记,异步写风控日志/邮件, // 并在 forward 返回错误时写一条 tokens=0 用量行。标记由 gateway 服务层在透传 cyber 后设置; // 当前请求已发给用户,本方法只做事后记录,不影响响应。forwardErrored 为 true 时才写用量行, // 避免与正常 RecordUsage(forward 成功路径)重复。每请求至多记录一次。 func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey *service.APIKey, account *service.Account, subscription *service.UserSubscription, model string, forwardErrored bool, cyberBlockKey string, channelFields service.ChannelUsageFields, requestPayloadHash string) { mark := service.GetOpsCyberPolicy(c) if mark == nil { return } if c.GetBool(cyberPolicyRecordedKey) { return } c.Set(cyberPolicyRecordedKey, true) model = clientRequestedModel(c, model) requestID := c.Writer.Header().Get("X-Request-Id") var userID, apiKeyID int64 var userEmail, apiKeyName, groupName string var groupID *int64 if apiKey != nil { apiKeyID = apiKey.ID apiKeyName = apiKey.Name groupID = apiKey.GroupID if apiKey.User != nil { userID = apiKey.User.ID userEmail = apiKey.User.Email } if apiKey.Group != nil { groupName = apiKey.Group.Name } } inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := "" var accountID int64 if account != nil { accountID = account.ID upstreamEndpoint = resolveOpenAIUpstreamEndpoint(c, account, nil) } stream := false if v, ok := c.Get(opsStreamKey); ok { if b, ok := v.(bool); ok { stream = b } } cmSvc := h.contentModerationService gwSvc := h.gatewayService opsSvc := h.opsService apiKeySvc := h.apiKeyService requestPath := "" if c.Request != nil && c.Request.URL != nil { requestPath = c.Request.URL.Path } requestCtx := context.Background() if c.Request != nil { requestCtx = c.Request.Context() } platform := resolveOpsPlatform(requestCtx, apiKey, guessPlatformFromPath(requestPath)) var clientRequestID, userAgent, clientIPStr string if c.Request != nil { clientRequestID, _ = c.Request.Context().Value(ctxkey.ClientRequestID).(string) userAgent = c.GetHeader("User-Agent") clientIPStr = strings.TrimSpace(ip.GetClientIP(c)) } // 提前拍成标量,避免在下方 goroutine 内访问 gin.Context。 sessionID := service.ExtractClientSessionID(c) apiKeyPrefix := "" if apiKey != nil { apiKeyPrefix = keyPrefix(apiKey.Key, 8) } opsMeta := cyberPolicyOpsErrorMeta{ RequestID: requestID, ClientRequestID: clientRequestID, Platform: platform, Model: model, RequestPath: requestPath, Stream: stream, InboundEndpoint: inboundEndpoint, UserAgent: userAgent, APIKeyPrefix: apiKeyPrefix, UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, ClientIP: clientIPStr, CreatedAt: time.Now(), } go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if cmSvc != nil { cmSvc.RecordCyberPolicyEvent(ctx, service.CyberPolicyRecordInput{ RequestID: requestID, UserID: userID, UserEmail: userEmail, APIKeyID: apiKeyID, APIKeyName: apiKeyName, GroupID: groupID, GroupName: groupName, Endpoint: inboundEndpoint, Model: model, UpstreamMessage: mark.Message, UpstreamBody: mark.Body, UpstreamStatus: mark.UpstreamStatus, UpstreamInTok: mark.UpstreamInTok, UpstreamOutTok: mark.UpstreamOutTok, }) } if forwardErrored && gwSvc != nil { gwSvc.RecordCyberPolicyUsageLog(ctx, service.CyberPolicyUsageInput{ APIKey: apiKey, Account: account, Subscription: subscription, RequestID: requestID, Model: model, Stream: stream, InputTokens: mark.UpstreamInTok, OutputTokens: mark.UpstreamOutTok, InboundEndpoint: inboundEndpoint, UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIPStr, SessionID: sessionID, RequestPayloadHash: requestPayloadHash, APIKeyService: apiKeySvc, ChannelUsageFields: channelFields, }) } if gwSvc != nil && cyberBlockKey != "" { gwSvc.MarkCyberSessionBlocked(ctx, cyberBlockKey) } if opsSvc != nil { enqueueOpsErrorLog(opsSvc, buildCyberPolicyOpsErrorEntry(opsMeta, mark)) } }() } // clearCyberPolicyTurnState resets the cyber mark and the per-request recorded // guard. WS-only: called at the END of AfterTurn, after recordCyberPolicyIfMarked // and RecordUsage (which reads CyberBlocked) have both consumed the mark. func clearCyberPolicyTurnState(c *gin.Context) { if c == nil { return } service.ClearOpsCyberPolicy(c) c.Set(cyberPolicyRecordedKey, false) } func summarizeWSCloseErrorForLog(err error) (string, string) { if err == nil { return "-", "-" } statusCode := coderws.CloseStatus(err) if statusCode == -1 { return "-", "-" } closeStatus := fmt.Sprintf("%d(%s)", int(statusCode), statusCode.String()) closeReason := "-" var closeErr coderws.CloseError if errors.As(err, &closeErr) { reason := strings.TrimSpace(closeErr.Reason) if reason != "" { closeReason = reason } } return closeStatus, closeReason }