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