| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254 |
- package service
- import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "log/slog"
- "math/rand"
- "net/http"
- "strings"
- "sync"
- "sync/atomic"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
- "github.com/Wei-Shaw/sub2api/internal/platform/liveattestation"
- "github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
- "github.com/cespare/xxhash/v2"
- "github.com/gin-gonic/gin"
- "go.uber.org/zap"
- )
- const (
- // ChatGPT internal API for OAuth accounts
- chatgptCodexURL = "https://chatgpt.com/backend-api/codex/responses"
- // OpenAI Platform API for API Key accounts (fallback)
- openaiPlatformAPIURL = "https://api.openai.com/v1/responses"
- openaiPlatformAPIInputTokensURL = "https://api.openai.com/v1/responses/input_tokens"
- openaiStickySessionTTL = time.Hour // 粘性会话TTL
- // 与真实 Codex TUI 的 User-Agent 结构对齐:
- // {originator}/{version} ({OS} {OS_version}; {arch}) {terminal}
- // 缺少 OS/架构/终端后缀的形态易被上游指纹识别为非官方客户端。
- // 该后缀是 UA 形态的唯一定义处,buildCodexCLIUserAgent 按运行时版本号复用它。
- codexCLIUserAgentSuffix = " (Ubuntu 22.4.0; x86_64) xterm-256color"
- // codexCLIUserAgent 是编译期兜底 UA;运行时优先使用由后台版本号拼出的规范 UA。
- // 版本段必须来自 codexCLIVersion:UA 与 version 头是同一个版本声明的两个出口,
- // 各自硬编码会漂移成互相矛盾的身份。
- codexCLIUserAgent = openai.CodexDefaultOriginator + "/" + codexCLIVersion + codexCLIUserAgentSuffix
- // codex_cli_only 拒绝时单个请求头日志长度上限(字符)
- codexCLIOnlyHeaderValueMaxBytes = 256
- // OpenAI WS Mode 失败后的重连次数上限(不含首次尝试)。
- // 与 Codex 客户端保持一致:失败后最多重连 5 次。
- openAIWSReconnectRetryLimit = 5
- // 上游错误体只需要提取错误 JSON/日志摘要,默认 512KiB 避免错误风暴叠加大请求体。
- openAIUpstreamErrorBodyReadLimit int64 = 512 << 10
- // OpenAI WS Mode 重连退避默认值(可由配置覆盖)。
- openAIWSRetryBackoffInitialDefault = 120 * time.Millisecond
- openAIWSRetryBackoffMaxDefault = 2 * time.Second
- openAIWSRetryJitterRatioDefault = 0.2
- openAICompactSessionSeedKey = "openai_compact_session_seed"
- openAIUpstreamEndpointContextKey = "openai_actual_upstream_endpoint"
- // codexCLIVersion 是网关对上游声明的 Codex 客户端版本,同时供 codexCLIUserAgent
- // 与 version 头使用。上游 /backend-api/codex 在容量紧张时按客户端身份分优先级降载,
- // 陈旧版本会被优先丢弃(HTTP 200 + 流内 server_is_overloaded);非官方客户端配不出
- // 官方身份时整体回退到本常量,因此它必须跟随官方 CLI 的当前发布版本,
- // 落后多个版本会让这些请求稳定落在被优先丢弃的一侧。
- codexCLIVersion = "0.146.0"
- // Codex 限额快照仅用于后台展示/诊断,不需要每个成功请求都立即落库。
- openAICodexSnapshotPersistMinInterval = 30 * time.Second
- // 配额自动暂停时,超过该时长仍未刷新的 used% 快照视为陈旧,不再据此暂停账号。
- // 被暂停的账号收不到流量,其快照永远不会从上游响应头刷新;该兜底让账号在快照
- // 陈旧时放行一次请求,从而通过正常响应头自愈,而无需等待整个窗口(5h/7d)重置。
- openAICodexAutoPauseStaleAfter = 2 * time.Hour
- )
- // OpenAI allowed headers whitelist (for non-passthrough).
- var openaiAllowedHeaders = map[string]bool{
- "accept-language": true,
- "content-type": true,
- "conversation_id": true,
- "user-agent": true,
- "originator": true,
- "session_id": true,
- "x-codex-beta-features": true,
- "x-codex-installation-id": true,
- "x-codex-turn-state": true,
- "x-codex-turn-metadata": true,
- "x-codex-window-id": true,
- responsesLiteHeaderKey: true,
- }
- // OpenAI passthrough allowed headers whitelist.
- // 透传模式下仅放行这些低风险请求头,避免将非标准/环境噪声头传给上游触发风控。
- var openaiPassthroughAllowedHeaders = map[string]bool{
- "accept": true,
- "accept-language": true,
- "content-type": true,
- "conversation_id": true,
- "openai-beta": true,
- "user-agent": true,
- "originator": true,
- "session_id": true,
- "x-codex-beta-features": true,
- "x-codex-installation-id": true,
- "x-codex-turn-state": true,
- "x-codex-turn-metadata": true,
- "x-codex-window-id": true,
- responsesLiteHeaderKey: true,
- }
- // codex_cli_only 拒绝时记录的请求头白名单(仅用于诊断日志,不参与上游透传)
- var codexCLIOnlyDebugHeaderWhitelist = []string{
- "User-Agent",
- "Content-Type",
- "Accept",
- "Accept-Language",
- "OpenAI-Beta",
- "Originator",
- "Session_ID",
- "Conversation_ID",
- "X-Request-ID",
- "X-Client-Request-ID",
- "X-Forwarded-For",
- "X-Real-IP",
- }
- // OpenAICodexUsageSnapshot represents Codex API usage limits from response headers
- type OpenAICodexUsageSnapshot struct {
- PrimaryUsedPercent *float64 `json:"primary_used_percent,omitempty"`
- PrimaryResetAfterSeconds *int `json:"primary_reset_after_seconds,omitempty"`
- PrimaryWindowMinutes *int `json:"primary_window_minutes,omitempty"`
- SecondaryUsedPercent *float64 `json:"secondary_used_percent,omitempty"`
- SecondaryResetAfterSeconds *int `json:"secondary_reset_after_seconds,omitempty"`
- SecondaryWindowMinutes *int `json:"secondary_window_minutes,omitempty"`
- PrimaryOverSecondaryPercent *float64 `json:"primary_over_secondary_percent,omitempty"`
- UpdatedAt string `json:"updated_at,omitempty"`
- }
- // NormalizedCodexLimits contains normalized 5h/7d rate limit data
- type NormalizedCodexLimits struct {
- Used5hPercent *float64
- Reset5hSeconds *int
- Window5hMinutes *int
- Used7dPercent *float64
- Reset7dSeconds *int
- Window7dMinutes *int
- }
- // Normalize converts primary/secondary fields to canonical 5h/7d fields.
- // Strategy: Compare window_minutes to determine which is 5h vs 7d.
- // Returns nil if snapshot is nil or has no useful data.
- func (s *OpenAICodexUsageSnapshot) Normalize() *NormalizedCodexLimits {
- if s == nil {
- return nil
- }
- result := &NormalizedCodexLimits{}
- primaryMins := 0
- secondaryMins := 0
- hasPrimaryWindow := false
- hasSecondaryWindow := false
- if s.PrimaryWindowMinutes != nil {
- primaryMins = *s.PrimaryWindowMinutes
- hasPrimaryWindow = true
- }
- if s.SecondaryWindowMinutes != nil {
- secondaryMins = *s.SecondaryWindowMinutes
- hasSecondaryWindow = true
- }
- // Determine mapping based on window_minutes
- use5hFromPrimary := false
- use7dFromPrimary := false
- if hasPrimaryWindow && hasSecondaryWindow {
- // Both known: smaller window is 5h, larger is 7d
- if primaryMins < secondaryMins {
- use5hFromPrimary = true
- } else {
- use7dFromPrimary = true
- }
- } else if hasPrimaryWindow {
- // Only primary known: classify by threshold (<=360 min = 6h -> 5h window)
- if primaryMins <= 360 {
- use5hFromPrimary = true
- } else {
- use7dFromPrimary = true
- }
- } else if hasSecondaryWindow {
- // Only secondary known: classify by threshold
- if secondaryMins <= 360 {
- // 5h from secondary, so primary (if any data) is 7d
- use7dFromPrimary = true
- } else {
- // 7d from secondary, so primary (if any data) is 5h
- use5hFromPrimary = true
- }
- } else {
- // No window_minutes: fall back to legacy assumption (primary=7d, secondary=5h)
- use7dFromPrimary = true
- }
- // Assign values
- if use5hFromPrimary {
- result.Used5hPercent = s.PrimaryUsedPercent
- result.Reset5hSeconds = s.PrimaryResetAfterSeconds
- result.Window5hMinutes = s.PrimaryWindowMinutes
- result.Used7dPercent = s.SecondaryUsedPercent
- result.Reset7dSeconds = s.SecondaryResetAfterSeconds
- result.Window7dMinutes = s.SecondaryWindowMinutes
- } else if use7dFromPrimary {
- result.Used7dPercent = s.PrimaryUsedPercent
- result.Reset7dSeconds = s.PrimaryResetAfterSeconds
- result.Window7dMinutes = s.PrimaryWindowMinutes
- result.Used5hPercent = s.SecondaryUsedPercent
- result.Reset5hSeconds = s.SecondaryResetAfterSeconds
- result.Window5hMinutes = s.SecondaryWindowMinutes
- }
- return result
- }
- // OpenAIUsage represents OpenAI API response usage
- type OpenAIUsage struct {
- InputTokens int `json:"input_tokens"`
- ImageInputTokens int `json:"image_input_tokens,omitempty"`
- OutputTokens int `json:"output_tokens"`
- CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"`
- CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
- ImageOutputTokens int `json:"image_output_tokens,omitempty"`
- }
- // OpenAIForwardResult represents the result of forwarding
- type OpenAIForwardResult struct {
- RequestID string
- ResponseID string
- // UpstreamHeaders 是直接上游的响应头,用于按账户配置解析上游请求标识。
- UpstreamHeaders http.Header
- Usage OpenAIUsage
- Model string // 原始模型(用于响应和日志显示)
- // BillingModel is the model used for cost calculation.
- // When non-empty, CalculateCost uses this instead of Model.
- // This is set by the Anthropic Messages conversion path where
- // the mapped upstream model differs from the client-facing model.
- BillingModel string
- // UpstreamModel is the actual model sent to the upstream provider after mapping.
- // Empty when no mapping was applied (requested model was used as-is).
- UpstreamModel string
- // UpstreamResponseModel is captured from the raw successful upstream
- // response before any client-facing rewrite or protocol conversion.
- UpstreamResponseModel string
- UpstreamResponseModelConflict bool
- // UpstreamResponseServiceTier is the tier the upstream reports having used
- // (response service_tier: "priority" / "default" / "flex" / ...); "" when not declared.
- UpstreamResponseServiceTier string
- // UpstreamEndpoint is the actual upstream API path used for this request.
- // It avoids guessing when one downstream protocol can use multiple upstream endpoints.
- UpstreamEndpoint string
- // ServiceTier is the final tier sent upstream after policy rewriting.
- // The upstream response declaration remains separate above and is reconciled
- // at usage-recording time, where the credential protocol is available.
- ServiceTier *string
- // ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix
- // after group policy rewriting and model-family remapping.
- // Stored for usage records display; nil means not provided / not applicable.
- ReasoningEffort *string
- // RequestedReasoningEffort is the client-requested effort before mapping.
- // Empty/nil means it should fall back to ReasoningEffort at persistence.
- RequestedReasoningEffort *string
- Stream bool
- OpenAIWSMode bool
- // UpstreamTerminalEvent is the normalized terminal event observed on an
- // upstream Responses WebSocket turn. Empty preserves legacy/non-WS success.
- UpstreamTerminalEvent string
- ResponseHeaders http.Header
- Duration time.Duration
- FirstTokenMs *int
- ClientDisconnect bool
- ImageCount int
- ImageSize string
- ImageInputSize string
- ImageOutputSize string
- ImageOutputSizes []string
- ImageSizeSource string
- ImageSizeBreakdown map[string]int
- VideoCount int
- VideoResolution string
- // VideoDurationSeconds 是提交时请求的生成时长(xAI 按输出秒数计费),已归一化到 1-15 秒。
- VideoDurationSeconds int
- // WebSearchCalls 是 Codex alpha/search 网页搜索调用次数(每次成功请求为 1)。
- // 上游不返回 usage 字段,>0 时走按次计费(分组单价 × 次数 × 倍率)。
- WebSearchCalls int
- // SearchCount is Grok-native web_search / tool search call count (per 1k pricing).
- SearchCount int
- // AudioUsage carries Voice billing units when present.
- AudioUsage *AudioUsage
- wsReplayInput []json.RawMessage
- wsReplayInputExists bool
- wsAccountFailoverReplayInput []json.RawMessage
- }
- // SucceededForScheduling reports whether this result is an upstream success
- // that may clear model-scoped transient state. The zero value remains a success
- // for existing non-WS callers.
- func (r *OpenAIForwardResult) SucceededForScheduling() bool {
- if r == nil || !r.OpenAIWSMode || r.UpstreamTerminalEvent == "" {
- return true
- }
- switch r.UpstreamTerminalEvent {
- case "response.completed", "response.done":
- return true
- default:
- return false
- }
- }
- // SetActualOpenAIUpstreamEndpoint records the endpoint selected by the current
- // forwarding attempt. It covers error paths where no OpenAIForwardResult is
- // available for usage and operations logging.
- func SetActualOpenAIUpstreamEndpoint(c *gin.Context, endpoint string) {
- if c == nil {
- return
- }
- if endpoint = strings.TrimSpace(endpoint); endpoint != "" {
- c.Set(openAIUpstreamEndpointContextKey, endpoint)
- }
- }
- // ClearActualOpenAIUpstreamEndpoint 清理当前转发尝试记录的端点。
- // Handler 会在账号 failover 尝试间复用同一个 Gin context,因此每次尝试
- // 都必须从无残留状态开始。
- func ClearActualOpenAIUpstreamEndpoint(c *gin.Context) {
- if c == nil {
- return
- }
- c.Set(openAIUpstreamEndpointContextKey, "")
- }
- // GetActualOpenAIUpstreamEndpoint returns the endpoint recorded by the latest
- // forwarding attempt in this request.
- func GetActualOpenAIUpstreamEndpoint(c *gin.Context) string {
- if c == nil {
- return ""
- }
- value, exists := c.Get(openAIUpstreamEndpointContextKey)
- if !exists {
- return ""
- }
- endpoint, _ := value.(string)
- return strings.TrimSpace(endpoint)
- }
- type OpenAIWSRetryMetricsSnapshot struct {
- RetryAttemptsTotal int64 `json:"retry_attempts_total"`
- RetryBackoffMsTotal int64 `json:"retry_backoff_ms_total"`
- RetryExhaustedTotal int64 `json:"retry_exhausted_total"`
- NonRetryableFastFallbackTotal int64 `json:"non_retryable_fast_fallback_total"`
- }
- type OpenAICompatibilityFallbackMetricsSnapshot struct {
- SessionHashLegacyReadFallbackTotal int64 `json:"session_hash_legacy_read_fallback_total"`
- SessionHashLegacyReadFallbackHit int64 `json:"session_hash_legacy_read_fallback_hit"`
- SessionHashLegacyDualWriteTotal int64 `json:"session_hash_legacy_dual_write_total"`
- SessionHashLegacyReadHitRate float64 `json:"session_hash_legacy_read_hit_rate"`
- MetadataLegacyFallbackIsMaxTokensOneHaikuTotal int64 `json:"metadata_legacy_fallback_is_max_tokens_one_haiku_total"`
- MetadataLegacyFallbackThinkingEnabledTotal int64 `json:"metadata_legacy_fallback_thinking_enabled_total"`
- MetadataLegacyFallbackPrefetchedStickyAccount int64 `json:"metadata_legacy_fallback_prefetched_sticky_account_total"`
- MetadataLegacyFallbackPrefetchedStickyGroup int64 `json:"metadata_legacy_fallback_prefetched_sticky_group_total"`
- MetadataLegacyFallbackSingleAccountRetryTotal int64 `json:"metadata_legacy_fallback_single_account_retry_total"`
- MetadataLegacyFallbackAccountSwitchCountTotal int64 `json:"metadata_legacy_fallback_account_switch_count_total"`
- MetadataLegacyFallbackTotal int64 `json:"metadata_legacy_fallback_total"`
- }
- type openAIWSRetryMetrics struct {
- retryAttempts atomic.Int64
- retryBackoffMs atomic.Int64
- retryExhausted atomic.Int64
- nonRetryableFastFallback atomic.Int64
- }
- type accountWriteThrottle struct {
- minInterval time.Duration
- mu sync.Mutex
- lastByID map[int64]time.Time
- }
- func newAccountWriteThrottle(minInterval time.Duration) *accountWriteThrottle {
- return &accountWriteThrottle{
- minInterval: minInterval,
- lastByID: make(map[int64]time.Time),
- }
- }
- func (t *accountWriteThrottle) Allow(id int64, now time.Time) bool {
- if t == nil || id <= 0 || t.minInterval <= 0 {
- return true
- }
- t.mu.Lock()
- defer t.mu.Unlock()
- if last, ok := t.lastByID[id]; ok && now.Sub(last) < t.minInterval {
- return false
- }
- t.lastByID[id] = now
- if len(t.lastByID) > 4096 {
- cutoff := now.Add(-4 * t.minInterval)
- for accountID, writtenAt := range t.lastByID {
- if writtenAt.Before(cutoff) {
- delete(t.lastByID, accountID)
- }
- }
- }
- return true
- }
- var defaultOpenAICodexSnapshotPersistThrottle = newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval)
- // ErrNoAvailableCompactAccounts indicates a legacy /responses/compact request
- // needs compact support but no compatible account is available.
- var ErrNoAvailableCompactAccounts = errors.New("no available accounts support /responses/compact")
- // OpenAIGatewayService handles OpenAI API gateway operations
- type OpenAIGatewayService struct {
- accountRepo AccountRepository
- usageLogRepo UsageLogRepository
- usageBillingRepo UsageBillingRepository
- userRepo UserRepository
- userSubRepo UserSubscriptionRepository
- cache GatewayCache
- cfg *config.Config
- codexDetector CodexClientRestrictionDetector
- schedulerSnapshot *SchedulerSnapshotService
- concurrencyService *ConcurrencyService
- billingService *BillingService
- rateLimitService *RateLimitService
- billingCacheService *BillingCacheService
- userGroupRateResolver *userGroupRateResolver
- httpUpstream HTTPUpstream
- pluginManager *PluginManager
- deferredService *DeferredService
- openAITokenProvider *OpenAITokenProvider
- grokTokenProvider *GrokTokenProvider
- toolCorrector *CodexToolCorrector
- openaiWSResolver OpenAIWSProtocolResolver
- resolver *ModelPricingResolver
- channelService *ChannelService
- balanceNotifyService *BalanceNotifyService
- settingService *SettingService
- userPlatformQuotaRepo UserPlatformQuotaRepository
- liveAttestation liveattestation.Provider
- liveAttestationCipher SecretEncryptor
- openaiWSPoolOnce sync.Once
- openaiWSStateStoreOnce sync.Once
- openaiSchedulerOnce sync.Once
- openaiProxyStreamCircuitOnce sync.Once
- openaiWSPassthroughDialerOnce sync.Once
- openaiModelTransientOnce sync.Once
- agentIdentityTaskMu sync.Mutex
- openaiWSPool *openAIWSConnPool
- openaiWSStateStore OpenAIWSStateStore
- openaiScheduler OpenAIAccountScheduler
- openaiWSPassthroughDialer openAIWSClientDialer
- openaiWSSessionPreemptions openAIWSSessionPreemptRegistry
- openaiAccountStats *openAIAccountRuntimeStats
- openaiModelTransient *openAIAccountModelTransientState
- openaiProxyStreamCircuit *openAIProxyStreamCircuit
- openaiProxyStreamFailOpenLogAt atomic.Int64
- openaiWSFallbackUntil sync.Map // key: int64(accountID), value: time.Time
- openaiAccountRuntimeBlockUntil sync.Map // key: int64(accountID), value: time.Time
- openaiAccountRuntimeBlockLocks sync.Map // key: int64(accountID), value: *sync.Mutex
- openaiAccountRuntimeBlockGeneration sync.Map // key: int64(accountID), value: uint64
- openaiAccountRuntimeBlockSequence atomic.Uint64
- openaiOAuth429RetryStartedAt sync.Map // key: int64(accountID), value: time.Time
- grokCredentialMutationLocks sync.Map // key: int64(accountID), value: *sync.Mutex
- openaiOAuth429WindowStartUnixNano atomic.Int64
- openaiOAuth429WindowCount atomic.Int64
- openaiWSRetryMetrics openAIWSRetryMetrics
- responseHeaderFilter *responseheaders.CompiledHeaderFilter
- codexSnapshotThrottle *accountWriteThrottle
- openAIModelsCache openAIModelsCache
- openaiCompatSessionResponses sync.Map
- openaiCompatAnthropicDigestSessions sync.Map
- // openaiCodexTurnStateOrigins: 下游会话 seed → openAICodexTurnStateOrigin,
- // 记录最近一次向该会话下发 x-codex-turn-state 的铸造账号,供出站守卫
- // 剥离跨账号回带(openai_codex_turn_state.go)。
- openaiCodexTurnStateOrigins sync.Map
- openaiCodexTurnStateWrites atomic.Uint64
- }
- // NewOpenAIGatewayService creates a new OpenAIGatewayService
- func NewOpenAIGatewayService(
- accountRepo AccountRepository,
- usageLogRepo UsageLogRepository,
- usageBillingRepo UsageBillingRepository,
- userRepo UserRepository,
- userSubRepo UserSubscriptionRepository,
- userGroupRateRepo UserGroupRateRepository,
- cache GatewayCache,
- cfg *config.Config,
- schedulerSnapshot *SchedulerSnapshotService,
- concurrencyService *ConcurrencyService,
- billingService *BillingService,
- rateLimitService *RateLimitService,
- billingCacheService *BillingCacheService,
- httpUpstream HTTPUpstream,
- deferredService *DeferredService,
- openAITokenProvider *OpenAITokenProvider,
- grokTokenProvider *GrokTokenProvider,
- resolver *ModelPricingResolver,
- channelService *ChannelService,
- balanceNotifyService *BalanceNotifyService,
- settingService *SettingService,
- userPlatformQuotaRepo UserPlatformQuotaRepository,
- ) *OpenAIGatewayService {
- // enforceCodexIdentityHeaders 是 HTTP / 透传 / WS / 探针 等出站路径共用的纯函数收口点,
- // 拿不到配置,故在此发布进程级开关快照。配置取反义,零值即「强制统一出口开启」。
- if cfg != nil {
- SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement)
- }
- svc := &OpenAIGatewayService{
- accountRepo: accountRepo,
- usageLogRepo: usageLogRepo,
- usageBillingRepo: usageBillingRepo,
- userRepo: userRepo,
- userSubRepo: userSubRepo,
- cache: cache,
- cfg: cfg,
- codexDetector: NewOpenAICodexClientRestrictionDetector(cfg),
- schedulerSnapshot: schedulerSnapshot,
- concurrencyService: concurrencyService,
- billingService: billingService,
- rateLimitService: rateLimitService,
- billingCacheService: billingCacheService,
- userGroupRateResolver: newUserGroupRateResolver(
- userGroupRateRepo,
- nil,
- resolveUserGroupRateCacheTTL(cfg),
- nil,
- "service.openai_gateway",
- ),
- httpUpstream: httpUpstream,
- deferredService: deferredService,
- openAITokenProvider: openAITokenProvider,
- grokTokenProvider: grokTokenProvider,
- toolCorrector: NewCodexToolCorrector(),
- openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
- resolver: resolver,
- channelService: channelService,
- balanceNotifyService: balanceNotifyService,
- settingService: settingService,
- userPlatformQuotaRepo: userPlatformQuotaRepo,
- liveAttestation: liveattestation.NewProvider(),
- liveAttestationCipher: newLiveAttestationCipher(cfg),
- responseHeaderFilter: compileResponseHeaderFilter(cfg),
- codexSnapshotThrottle: newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval),
- openaiModelTransient: newOpenAIAccountModelTransientState(openAIModelTransientDefaultMax),
- }
- if rateLimitService != nil {
- rateLimitService.SetAccountRuntimeBlocker(svc)
- }
- if openAITokenProvider != nil {
- openAITokenProvider.SetAccountRuntimeBlocker(svc)
- }
- svc.logOpenAIWSModeBootstrap()
- return svc
- }
- // ResolveChannelMapping 解析渠道级模型映射(代理到 ChannelService)
- func (s *OpenAIGatewayService) ResolveChannelMapping(ctx context.Context, groupID int64, model string) ChannelMappingResult {
- if s.channelService == nil {
- return ChannelMappingResult{MappedModel: model}
- }
- return s.channelService.ResolveChannelMapping(ctx, groupID, model)
- }
- // IsModelRestricted 检查模型是否被渠道限制(代理到 ChannelService)
- func (s *OpenAIGatewayService) IsModelRestricted(ctx context.Context, groupID int64, model string) bool {
- if s.channelService == nil {
- return false
- }
- return s.channelService.IsModelRestricted(ctx, groupID, model)
- }
- // ResolveChannelMappingAndRestrict 解析渠道映射。
- // 模型限制检查已移至调度阶段,restricted 始终返回 false。
- func (s *OpenAIGatewayService) ResolveChannelMappingAndRestrict(ctx context.Context, groupID *int64, model string) (ChannelMappingResult, bool) {
- if s.channelService == nil {
- return ChannelMappingResult{MappedModel: model}, false
- }
- return s.channelService.ResolveChannelMappingAndRestrict(ctx, groupID, model)
- }
- func (s *OpenAIGatewayService) isCodexImageGenerationBridgeEnabled(ctx context.Context, account *Account, apiKey *APIKey) bool {
- if override := account.CodexImageGenerationBridgeOverride(); override != nil {
- return *override
- }
- if s != nil && s.channelService != nil && apiKey != nil && apiKey.GroupID != nil {
- ch, err := s.channelService.GetChannelForGroup(ctx, *apiKey.GroupID)
- if err != nil {
- slog.Warn("failed to resolve codex image generation bridge channel override", "group_id", *apiKey.GroupID, "error", err)
- } else if override := ch.CodexImageGenerationBridgeOverride(PlatformOpenAI); override != nil {
- return *override
- }
- }
- return s != nil && s.cfg != nil && s.cfg.Gateway.CodexImageGenerationBridgeEnabled
- }
- func (s *OpenAIGatewayService) checkChannelPricingRestriction(ctx context.Context, groupID *int64, requestedModel string) bool {
- if groupID == nil || s.channelService == nil || requestedModel == "" {
- return false
- }
- mapping := s.channelService.ResolveChannelMapping(ctx, *groupID, requestedModel)
- billingModel := billingModelForRestriction(mapping.BillingModelSource, requestedModel, mapping.MappedModel)
- if billingModel == "" {
- return false
- }
- return s.channelService.IsModelRestricted(ctx, *groupID, billingModel)
- }
- func (s *OpenAIGatewayService) isUpstreamModelRestrictedByChannel(ctx context.Context, groupID int64, account *Account, requestedModel string, requireCompact bool) bool {
- if s.channelService == nil {
- return false
- }
- if compactForwardModel, ok := openAIForwardModelFromContext(ctx); ok {
- requestedModel = compactForwardModel.model
- requireCompact = compactForwardModel.useCompactModelMapping
- }
- upstreamModel := resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact)
- if upstreamModel == "" {
- return false
- }
- return s.channelService.IsModelRestricted(ctx, groupID, upstreamModel)
- }
- func (s *OpenAIGatewayService) needsUpstreamChannelRestrictionCheck(ctx context.Context, groupID *int64) bool {
- if groupID == nil || s.channelService == nil {
- return false
- }
- ch, err := s.channelService.GetChannelForGroup(ctx, *groupID)
- if err != nil {
- slog.Warn("failed to check openai channel upstream restriction", "group_id", *groupID, "error", err)
- return false
- }
- if ch == nil || !ch.RestrictModels {
- return false
- }
- return ch.BillingModelSource == BillingModelSourceUpstream
- }
- // ReplaceModelInBody 替换请求体中的 JSON model 字段(通用 gjson/sjson 实现)。
- func (s *OpenAIGatewayService) ReplaceModelInBody(body []byte, newModel string) []byte {
- return ReplaceModelInBody(body, newModel)
- }
- func (s *OpenAIGatewayService) getCodexSnapshotThrottle() *accountWriteThrottle {
- if s != nil && s.codexSnapshotThrottle != nil {
- return s.codexSnapshotThrottle
- }
- return defaultOpenAICodexSnapshotPersistThrottle
- }
- func (s *OpenAIGatewayService) billingDeps() *billingDeps {
- return &billingDeps{
- accountRepo: s.accountRepo,
- userRepo: s.userRepo,
- userSubRepo: s.userSubRepo,
- billingCacheService: s.billingCacheService,
- deferredService: s.deferredService,
- balanceNotifyService: s.balanceNotifyService,
- userPlatformQuotaRepo: s.userPlatformQuotaRepo,
- }
- }
- // CloseOpenAIWSPool 关闭 OpenAI WebSocket 连接池的后台 worker 和空闲连接。
- // 应在应用优雅关闭时调用。
- func (s *OpenAIGatewayService) CloseOpenAIWSPool() {
- if s != nil && s.openaiWSPool != nil {
- s.openaiWSPool.Close()
- }
- }
- func (s *OpenAIGatewayService) InvalidateAgentIdentityWSConnections(accountID int64) {
- if pool := s.getOpenAIWSConnPool(); pool != nil {
- pool.ClearAccount(accountID)
- }
- }
- func (s *OpenAIGatewayService) logOpenAIWSModeBootstrap() {
- if s == nil || s.cfg == nil {
- return
- }
- wsCfg := s.cfg.Gateway.OpenAIWS
- logOpenAIWSModeInfo(
- "bootstrap enabled=%v oauth_enabled=%v apikey_enabled=%v force_http=%v responses_websockets_v2=%v responses_websockets=%v payload_log_sample_rate=%.3f event_flush_batch_size=%d event_flush_interval_ms=%d prewarm_cooldown_ms=%d retry_backoff_initial_ms=%d retry_backoff_max_ms=%d retry_jitter_ratio=%.3f retry_total_budget_ms=%d ws_read_limit_bytes=%d",
- wsCfg.Enabled,
- wsCfg.OAuthEnabled,
- wsCfg.APIKeyEnabled,
- wsCfg.ForceHTTP,
- wsCfg.ResponsesWebsocketsV2,
- wsCfg.ResponsesWebsockets,
- wsCfg.PayloadLogSampleRate,
- wsCfg.EventFlushBatchSize,
- wsCfg.EventFlushIntervalMS,
- wsCfg.PrewarmCooldownMS,
- wsCfg.RetryBackoffInitialMS,
- wsCfg.RetryBackoffMaxMS,
- wsCfg.RetryJitterRatio,
- wsCfg.RetryTotalBudgetMS,
- openAIWSMessageReadLimitBytes,
- )
- }
- func (s *OpenAIGatewayService) getCodexClientRestrictionDetector() CodexClientRestrictionDetector {
- if s != nil && s.codexDetector != nil {
- return s.codexDetector
- }
- var cfg *config.Config
- if s != nil {
- cfg = s.cfg
- }
- return NewOpenAICodexClientRestrictionDetector(cfg)
- }
- func (s *OpenAIGatewayService) getOpenAIWSProtocolResolver() OpenAIWSProtocolResolver {
- if s != nil && s.openaiWSResolver != nil {
- return s.openaiWSResolver
- }
- var cfg *config.Config
- if s != nil {
- cfg = s.cfg
- }
- return NewOpenAIWSProtocolResolver(cfg)
- }
- func classifyOpenAIWSReconnectReason(err error) (string, bool) {
- if err == nil {
- return "", false
- }
- var fallbackErr *openAIWSFallbackError
- if !errors.As(err, &fallbackErr) || fallbackErr == nil {
- return "", false
- }
- reason := strings.TrimSpace(fallbackErr.Reason)
- if reason == "" {
- return "", false
- }
- baseReason := strings.TrimPrefix(reason, "prewarm_")
- switch baseReason {
- case "policy_violation",
- "message_too_big",
- "upgrade_required",
- "ws_unsupported",
- "auth_failed",
- "invalid_encrypted_content",
- "previous_response_not_found":
- return reason, false
- }
- switch baseReason {
- case "read_event",
- "write_request",
- "write",
- "acquire_timeout",
- "acquire_conn",
- "conn_queue_full",
- "dial_failed",
- "upstream_5xx",
- "event_error",
- "error_event",
- "upstream_error_event",
- "ws_connection_limit_reached",
- "missing_final_response":
- return reason, true
- default:
- return reason, false
- }
- }
- func resolveOpenAIWSFallbackErrorResponse(err error) (statusCode int, errType string, clientMessage string, upstreamMessage string, ok bool) {
- if err == nil {
- return 0, "", "", "", false
- }
- var fallbackErr *openAIWSFallbackError
- if !errors.As(err, &fallbackErr) || fallbackErr == nil {
- return 0, "", "", "", false
- }
- reason := strings.TrimSpace(fallbackErr.Reason)
- reason = strings.TrimPrefix(reason, "prewarm_")
- if reason == "" {
- return 0, "", "", "", false
- }
- var dialErr *openAIWSDialError
- if fallbackErr.Err != nil && errors.As(fallbackErr.Err, &dialErr) && dialErr != nil {
- if dialErr.StatusCode > 0 {
- statusCode = dialErr.StatusCode
- }
- if dialErr.Err != nil {
- upstreamMessage = sanitizeUpstreamErrorMessage(strings.TrimSpace(dialErr.Err.Error()))
- }
- }
- switch reason {
- case "invalid_encrypted_content":
- if statusCode == 0 {
- statusCode = http.StatusBadRequest
- }
- errType = "invalid_request_error"
- if upstreamMessage == "" {
- upstreamMessage = "encrypted content could not be verified"
- }
- case "previous_response_not_found":
- if statusCode == 0 {
- statusCode = http.StatusBadRequest
- }
- errType = "invalid_request_error"
- if upstreamMessage == "" {
- upstreamMessage = "previous response not found"
- }
- case "upgrade_required":
- if statusCode == 0 {
- statusCode = http.StatusUpgradeRequired
- }
- case "ws_unsupported":
- if statusCode == 0 {
- statusCode = http.StatusBadRequest
- }
- case "auth_failed":
- if statusCode == 0 {
- statusCode = http.StatusUnauthorized
- }
- case "upstream_rate_limited":
- if statusCode == 0 {
- statusCode = http.StatusTooManyRequests
- }
- default:
- if statusCode == 0 {
- return 0, "", "", "", false
- }
- }
- if upstreamMessage == "" && fallbackErr.Err != nil {
- upstreamMessage = sanitizeUpstreamErrorMessage(strings.TrimSpace(fallbackErr.Err.Error()))
- }
- if upstreamMessage == "" {
- switch reason {
- case "upgrade_required":
- upstreamMessage = "upstream websocket upgrade required"
- case "ws_unsupported":
- upstreamMessage = "upstream websocket not supported"
- case "auth_failed":
- upstreamMessage = "upstream authentication failed"
- case "upstream_rate_limited":
- upstreamMessage = "upstream rate limit exceeded, please retry later"
- default:
- upstreamMessage = "Upstream request failed"
- }
- }
- if errType == "" {
- if statusCode == http.StatusTooManyRequests {
- errType = "rate_limit_error"
- } else {
- errType = "upstream_error"
- }
- }
- clientMessage = upstreamMessage
- return statusCode, errType, clientMessage, upstreamMessage, true
- }
- func (s *OpenAIGatewayService) writeOpenAIWSFallbackErrorResponse(c *gin.Context, account *Account, wsErr error) bool {
- if c == nil || c.Writer == nil || c.Writer.Written() {
- return false
- }
- statusCode, errType, clientMessage, upstreamMessage, ok := resolveOpenAIWSFallbackErrorResponse(wsErr)
- if !ok {
- return false
- }
- if strings.TrimSpace(clientMessage) == "" {
- clientMessage = "Upstream request failed"
- }
- if strings.TrimSpace(upstreamMessage) == "" {
- upstreamMessage = clientMessage
- }
- setOpsUpstreamError(c, statusCode, upstreamMessage, "")
- if account != nil {
- proxyID, proxyName := opsUpstreamWSProxyAttribution(account)
- appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
- ProxyID: proxyID,
- ProxyName: proxyName,
- Platform: account.Platform,
- AccountID: account.ID,
- AccountName: account.Name,
- UpstreamStatusCode: statusCode,
- Kind: "ws_error",
- Message: upstreamMessage,
- })
- }
- c.JSON(statusCode, gin.H{
- "error": gin.H{
- "type": errType,
- "message": clientMessage,
- },
- })
- return true
- }
- func (s *OpenAIGatewayService) openAIWSRetryBackoff(attempt int) time.Duration {
- if attempt <= 0 {
- return 0
- }
- initial := openAIWSRetryBackoffInitialDefault
- maxBackoff := openAIWSRetryBackoffMaxDefault
- jitterRatio := openAIWSRetryJitterRatioDefault
- if s != nil && s.cfg != nil {
- wsCfg := s.cfg.Gateway.OpenAIWS
- if wsCfg.RetryBackoffInitialMS > 0 {
- initial = time.Duration(wsCfg.RetryBackoffInitialMS) * time.Millisecond
- }
- if wsCfg.RetryBackoffMaxMS > 0 {
- maxBackoff = time.Duration(wsCfg.RetryBackoffMaxMS) * time.Millisecond
- }
- if wsCfg.RetryJitterRatio >= 0 {
- jitterRatio = wsCfg.RetryJitterRatio
- }
- }
- if initial <= 0 {
- return 0
- }
- if maxBackoff <= 0 {
- maxBackoff = initial
- }
- if maxBackoff < initial {
- maxBackoff = initial
- }
- if jitterRatio < 0 {
- jitterRatio = 0
- }
- if jitterRatio > 1 {
- jitterRatio = 1
- }
- shift := attempt - 1
- if shift < 0 {
- shift = 0
- }
- backoff := initial
- if shift > 0 {
- backoff = initial * time.Duration(1<<shift)
- }
- if backoff > maxBackoff {
- backoff = maxBackoff
- }
- if jitterRatio <= 0 {
- return backoff
- }
- jitter := time.Duration(float64(backoff) * jitterRatio)
- if jitter <= 0 {
- return backoff
- }
- delta := time.Duration(rand.Int63n(int64(jitter)*2+1)) - jitter
- withJitter := backoff + delta
- if withJitter < 0 {
- return 0
- }
- return withJitter
- }
- func (s *OpenAIGatewayService) openAIWSRetryTotalBudget() time.Duration {
- if s != nil && s.cfg != nil {
- ms := s.cfg.Gateway.OpenAIWS.RetryTotalBudgetMS
- if ms <= 0 {
- return 0
- }
- return time.Duration(ms) * time.Millisecond
- }
- return 0
- }
- func (s *OpenAIGatewayService) recordOpenAIWSRetryAttempt(backoff time.Duration) {
- if s == nil {
- return
- }
- s.openaiWSRetryMetrics.retryAttempts.Add(1)
- if backoff > 0 {
- s.openaiWSRetryMetrics.retryBackoffMs.Add(backoff.Milliseconds())
- }
- }
- func (s *OpenAIGatewayService) recordOpenAIWSRetryExhausted() {
- if s == nil {
- return
- }
- s.openaiWSRetryMetrics.retryExhausted.Add(1)
- }
- func (s *OpenAIGatewayService) recordOpenAIWSNonRetryableFastFallback() {
- if s == nil {
- return
- }
- s.openaiWSRetryMetrics.nonRetryableFastFallback.Add(1)
- }
- func (s *OpenAIGatewayService) SnapshotOpenAIWSRetryMetrics() OpenAIWSRetryMetricsSnapshot {
- if s == nil {
- return OpenAIWSRetryMetricsSnapshot{}
- }
- return OpenAIWSRetryMetricsSnapshot{
- RetryAttemptsTotal: s.openaiWSRetryMetrics.retryAttempts.Load(),
- RetryBackoffMsTotal: s.openaiWSRetryMetrics.retryBackoffMs.Load(),
- RetryExhaustedTotal: s.openaiWSRetryMetrics.retryExhausted.Load(),
- NonRetryableFastFallbackTotal: s.openaiWSRetryMetrics.nonRetryableFastFallback.Load(),
- }
- }
- func SnapshotOpenAICompatibilityFallbackMetrics() OpenAICompatibilityFallbackMetricsSnapshot {
- legacyReadFallbackTotal, legacyReadFallbackHit, legacyDualWriteTotal := openAIStickyCompatStats()
- isMaxTokensOneHaiku, thinkingEnabled, prefetchedStickyAccount, prefetchedStickyGroup, singleAccountRetry, accountSwitchCount := RequestMetadataFallbackStats()
- readHitRate := float64(0)
- if legacyReadFallbackTotal > 0 {
- readHitRate = float64(legacyReadFallbackHit) / float64(legacyReadFallbackTotal)
- }
- metadataFallbackTotal := isMaxTokensOneHaiku + thinkingEnabled + prefetchedStickyAccount + prefetchedStickyGroup + singleAccountRetry + accountSwitchCount
- return OpenAICompatibilityFallbackMetricsSnapshot{
- SessionHashLegacyReadFallbackTotal: legacyReadFallbackTotal,
- SessionHashLegacyReadFallbackHit: legacyReadFallbackHit,
- SessionHashLegacyDualWriteTotal: legacyDualWriteTotal,
- SessionHashLegacyReadHitRate: readHitRate,
- MetadataLegacyFallbackIsMaxTokensOneHaikuTotal: isMaxTokensOneHaiku,
- MetadataLegacyFallbackThinkingEnabledTotal: thinkingEnabled,
- MetadataLegacyFallbackPrefetchedStickyAccount: prefetchedStickyAccount,
- MetadataLegacyFallbackPrefetchedStickyGroup: prefetchedStickyGroup,
- MetadataLegacyFallbackSingleAccountRetryTotal: singleAccountRetry,
- MetadataLegacyFallbackAccountSwitchCountTotal: accountSwitchCount,
- MetadataLegacyFallbackTotal: metadataFallbackTotal,
- }
- }
- func (s *OpenAIGatewayService) detectCodexClientRestriction(c *gin.Context, account *Account, body []byte) CodexClientRestrictionDetectionResult {
- // 安全默认:即便缺 settingService(仅测试/误配可达)也保持指纹门为默认种子,
- // 避免零值 policy(nil 信号)让指纹门失败开放。有 settingService 时整体覆盖为全局策略。
- policy := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}
- if account != nil && account.IsCodexCLIOnlyEnabled() && s != nil && s.settingService != nil {
- ctx := context.Background()
- if c != nil && c.Request != nil {
- ctx = c.Request.Context()
- }
- policy = s.settingService.GetCodexRestrictionPolicy(ctx)
- }
- return s.getCodexClientRestrictionDetector().Detect(c, account, policy, body)
- }
- func getAPIKeyIDFromContext(c *gin.Context) int64 {
- if c == nil {
- return 0
- }
- v, exists := c.Get("api_key")
- if !exists {
- return 0
- }
- apiKey, ok := v.(*APIKey)
- if !ok || apiKey == nil {
- return 0
- }
- return apiKey.ID
- }
- // isolateOpenAISessionID 将 apiKeyID 混入 session 标识符,
- // 确保不同 API Key 的用户即使使用相同的原始 session_id/conversation_id,
- // 到达上游的标识符也不同,防止跨用户会话碰撞。
- func isolateOpenAISessionID(apiKeyID int64, raw string) string {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return ""
- }
- h := xxhash.New()
- _, _ = fmt.Fprintf(h, "k%d:", apiKeyID)
- _, _ = h.WriteString(raw)
- return fmt.Sprintf("%016x", h.Sum64())
- }
- func logCodexCLIOnlyDetection(ctx context.Context, c *gin.Context, account *Account, apiKeyID int64, result CodexClientRestrictionDetectionResult, body []byte) {
- if !result.Enabled {
- return
- }
- if ctx == nil {
- ctx = context.Background()
- }
- accountID := int64(0)
- if account != nil {
- accountID = account.ID
- }
- fields := []zap.Field{
- zap.String("component", "service.openai_gateway"),
- zap.Int64("account_id", accountID),
- zap.Bool("codex_cli_only_enabled", result.Enabled),
- zap.Bool("codex_official_client_match", result.Matched),
- zap.String("reject_reason", result.Reason),
- }
- if apiKeyID > 0 {
- fields = append(fields, zap.Int64("api_key_id", apiKeyID))
- }
- if !result.Matched {
- fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, body)
- }
- log := logger.FromContext(ctx).With(fields...)
- if result.Matched {
- log.Info("OpenAI codex_cli_only 放行请求")
- return
- }
- log.Warn("OpenAI codex_cli_only 拒绝非官方客户端请求")
- }
- func appendCodexCLIOnlyRejectedRequestFields(fields []zap.Field, c *gin.Context, body []byte) []zap.Field {
- if c == nil || c.Request == nil {
- return fields
- }
- req := c.Request
- requestModel, requestStream, promptCacheKey := extractOpenAIRequestMetaFromBody(body)
- fields = append(fields,
- zap.String("request_method", strings.TrimSpace(req.Method)),
- zap.String("request_path", strings.TrimSpace(req.URL.Path)),
- zap.String("request_query", strings.TrimSpace(req.URL.RawQuery)),
- zap.String("request_host", strings.TrimSpace(req.Host)),
- zap.String("request_client_ip", strings.TrimSpace(ip.GetClientIP(c))),
- zap.String("request_remote_addr", strings.TrimSpace(req.RemoteAddr)),
- zap.String("request_user_agent", strings.TrimSpace(req.Header.Get("User-Agent"))),
- zap.String("request_content_type", strings.TrimSpace(req.Header.Get("Content-Type"))),
- zap.Int64("request_content_length", req.ContentLength),
- zap.Bool("request_stream", requestStream),
- )
- if requestModel != "" {
- fields = append(fields, zap.String("request_model", requestModel))
- }
- if promptCacheKey != "" {
- fields = append(fields, zap.String("request_prompt_cache_key_sha256", hashSensitiveValueForLog(promptCacheKey)))
- }
- if headers := snapshotCodexCLIOnlyHeaders(req.Header); len(headers) > 0 {
- fields = append(fields, zap.Any("request_headers", headers))
- }
- fields = append(fields, zap.Int("request_body_size", len(body)))
- return fields
- }
- func snapshotCodexCLIOnlyHeaders(header http.Header) map[string]string {
- if len(header) == 0 {
- return nil
- }
- result := make(map[string]string, len(codexCLIOnlyDebugHeaderWhitelist))
- for _, key := range codexCLIOnlyDebugHeaderWhitelist {
- value := strings.TrimSpace(header.Get(key))
- if value == "" {
- continue
- }
- result[strings.ToLower(key)] = truncateString(value, codexCLIOnlyHeaderValueMaxBytes)
- }
- return result
- }
- func hashSensitiveValueForLog(raw string) string {
- value := strings.TrimSpace(raw)
- if value == "" {
- return ""
- }
- sum := sha256.Sum256([]byte(value))
- return hex.EncodeToString(sum[:8])
- }
- // GetAccessToken gets the access token for an OpenAI account
- func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Account) (string, string, error) {
- if account.IsShadow() {
- credAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account)
- if err != nil {
- return "", "", err
- }
- account = credAccount
- }
- switch account.Type {
- case AccountTypeOAuth:
- if account.IsOpenAIAgentIdentity() {
- return "", OpenAIAuthModeAgentIdentity, nil
- }
- if account.Platform == PlatformGrok {
- if s.grokTokenProvider != nil {
- accessToken, err := s.grokTokenProvider.GetAccessToken(ctx, account)
- if err != nil {
- return "", "", err
- }
- return accessToken, "oauth", nil
- }
- accessToken := account.GetGrokAccessToken()
- if accessToken == "" {
- return "", "", errors.New("access_token not found in credentials")
- }
- return accessToken, "oauth", nil
- }
- // 使用 TokenProvider 获取缓存的 token
- if s.openAITokenProvider != nil {
- accessToken, err := s.openAITokenProvider.GetAccessToken(ctx, account)
- if err != nil {
- return "", "", err
- }
- return accessToken, "oauth", nil
- }
- // 降级:TokenProvider 未配置时直接从账号读取
- accessToken := account.GetOpenAIAccessToken()
- if accessToken == "" {
- return "", "", errors.New("access_token not found in credentials")
- }
- return accessToken, "oauth", nil
- case AccountTypeSetupToken:
- if !account.IsOpenAIOAuthLike() {
- return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
- }
- // OpenAI setup tokens are inference-only bearer credentials. They use the
- // Codex OAuth forwarding protocol but have no refresh-token lifecycle.
- accessToken := account.GetOpenAIAccessToken()
- if accessToken == "" {
- return "", "", errors.New("access_token not found in credentials")
- }
- return accessToken, "oauth", nil
- case AccountTypeAPIKey:
- if account.Platform == PlatformGrok {
- apiKey := strings.TrimSpace(account.GetCredential("api_key"))
- if apiKey == "" {
- return "", "", errors.New("api_key not found in credentials")
- }
- return apiKey, "apikey", nil
- }
- apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey())
- if apiKey == "" {
- return "", "", errors.New("api_key not found in credentials")
- }
- return apiKey, "apikey", nil
- default:
- return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
- }
- }
|