Files
sub2api/backend/internal/handler/openai_gateway_handler.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

3444 lines
134 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// 准入完成:门并入连接 ctxturn 级复核与 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 的严格 SDKCodex 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
}