diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 529e46c575..1d506f505b 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -1,21 +1,16 @@ package admin import ( - "context" "crypto/rand" "encoding/hex" "encoding/json" - "errors" "fmt" "log/slog" - "net/http" "regexp" "strings" - "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" - "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -415,2556 +410,6 @@ func loginAgreementDocumentsToService(items []dto.LoginAgreementDocument) []serv return result } -// UpdateSettingsRequest 更新设置请求 -type UpdateSettingsRequest struct { - // 注册设置 - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - FrontendURL string `json:"frontend_url"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 - LoginAgreementEnabled bool `json:"login_agreement_enabled"` - LoginAgreementMode string `json:"login_agreement_mode"` - LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` - LoginAgreementDocuments []dto.LoginAgreementDocument `json:"login_agreement_documents"` - - // 邮件服务设置 - SMTPHost string `json:"smtp_host"` - SMTPPort int `json:"smtp_port"` - SMTPUsername string `json:"smtp_username"` - SMTPPassword string `json:"smtp_password"` - SMTPFrom string `json:"smtp_from_email"` - SMTPFromName string `json:"smtp_from_name"` - SMTPUseTLS bool `json:"smtp_use_tls"` - - // Cloudflare Turnstile 设置 - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - TurnstileSecretKey string `json:"turnstile_secret_key"` - - // API Key IP 访问控制设置 - APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"` - - // LinuxDo Connect OAuth 登录 - LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"` - LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"` - LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"` - LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` - - // DingTalk Connect OAuth 登录 - DingTalkConnectEnabled bool `json:"dingtalk_connect_enabled"` - DingTalkConnectClientID string `json:"dingtalk_connect_client_id"` - DingTalkConnectClientSecret string `json:"dingtalk_connect_client_secret"` - DingTalkConnectRedirectURL string `json:"dingtalk_connect_redirect_url"` - DingTalkConnectCorpRestrictionPolicy string `json:"dingtalk_connect_corp_restriction_policy"` - DingTalkConnectInternalCorpID string `json:"dingtalk_connect_internal_corp_id"` - DingTalkConnectBypassRegistration bool `json:"dingtalk_connect_bypass_registration"` - DingTalkConnectSyncCorpEmail bool `json:"dingtalk_connect_sync_corp_email"` - DingTalkConnectSyncDisplayName bool `json:"dingtalk_connect_sync_display_name"` - DingTalkConnectSyncDept bool `json:"dingtalk_connect_sync_dept"` - DingTalkConnectSyncCorpEmailAttrKey string `json:"dingtalk_connect_sync_corp_email_attr_key"` - DingTalkConnectSyncDisplayNameAttrKey string `json:"dingtalk_connect_sync_display_name_attr_key"` - DingTalkConnectSyncDeptAttrKey string `json:"dingtalk_connect_sync_dept_attr_key"` - DingTalkConnectSyncCorpEmailAttrName string `json:"dingtalk_connect_sync_corp_email_attr_name"` - DingTalkConnectSyncDisplayNameAttrName string `json:"dingtalk_connect_sync_display_name_attr_name"` - DingTalkConnectSyncDeptAttrName string `json:"dingtalk_connect_sync_dept_attr_name"` - - // WeChat Connect OAuth 登录 - WeChatConnectEnabled bool `json:"wechat_connect_enabled"` - WeChatConnectAppID string `json:"wechat_connect_app_id"` - WeChatConnectAppSecret string `json:"wechat_connect_app_secret"` - WeChatConnectOpenAppID string `json:"wechat_connect_open_app_id"` - WeChatConnectOpenAppSecret string `json:"wechat_connect_open_app_secret"` - WeChatConnectMPAppID string `json:"wechat_connect_mp_app_id"` - WeChatConnectMPAppSecret string `json:"wechat_connect_mp_app_secret"` - WeChatConnectMobileAppID string `json:"wechat_connect_mobile_app_id"` - WeChatConnectMobileAppSecret string `json:"wechat_connect_mobile_app_secret"` - WeChatConnectOpenEnabled bool `json:"wechat_connect_open_enabled"` - WeChatConnectMPEnabled bool `json:"wechat_connect_mp_enabled"` - WeChatConnectMobileEnabled bool `json:"wechat_connect_mobile_enabled"` - WeChatConnectMode string `json:"wechat_connect_mode"` - WeChatConnectScopes string `json:"wechat_connect_scopes"` - WeChatConnectRedirectURL string `json:"wechat_connect_redirect_url"` - WeChatConnectFrontendRedirectURL string `json:"wechat_connect_frontend_redirect_url"` - - // Generic OIDC OAuth 登录 - OIDCConnectEnabled bool `json:"oidc_connect_enabled"` - OIDCConnectProviderName string `json:"oidc_connect_provider_name"` - OIDCConnectClientID string `json:"oidc_connect_client_id"` - OIDCConnectClientSecret string `json:"oidc_connect_client_secret"` - OIDCConnectIssuerURL string `json:"oidc_connect_issuer_url"` - OIDCConnectDiscoveryURL string `json:"oidc_connect_discovery_url"` - OIDCConnectAuthorizeURL string `json:"oidc_connect_authorize_url"` - OIDCConnectTokenURL string `json:"oidc_connect_token_url"` - OIDCConnectUserInfoURL string `json:"oidc_connect_userinfo_url"` - OIDCConnectJWKSURL string `json:"oidc_connect_jwks_url"` - OIDCConnectScopes string `json:"oidc_connect_scopes"` - OIDCConnectRedirectURL string `json:"oidc_connect_redirect_url"` - OIDCConnectFrontendRedirectURL string `json:"oidc_connect_frontend_redirect_url"` - OIDCConnectTokenAuthMethod string `json:"oidc_connect_token_auth_method"` - OIDCConnectUsePKCE *bool `json:"oidc_connect_use_pkce"` - OIDCConnectValidateIDToken *bool `json:"oidc_connect_validate_id_token"` - OIDCConnectAllowedSigningAlgs string `json:"oidc_connect_allowed_signing_algs"` - OIDCConnectClockSkewSeconds int `json:"oidc_connect_clock_skew_seconds"` - OIDCConnectRequireEmailVerified bool `json:"oidc_connect_require_email_verified"` - OIDCConnectUserInfoEmailPath string `json:"oidc_connect_userinfo_email_path"` - OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"` - OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"` - - GitHubOAuthEnabled bool `json:"github_oauth_enabled"` - GitHubOAuthClientID string `json:"github_oauth_client_id"` - GitHubOAuthClientSecret string `json:"github_oauth_client_secret"` - GitHubOAuthRedirectURL string `json:"github_oauth_redirect_url"` - GitHubOAuthFrontendRedirectURL string `json:"github_oauth_frontend_redirect_url"` - GoogleOAuthEnabled bool `json:"google_oauth_enabled"` - GoogleOAuthClientID string `json:"google_oauth_client_id"` - GoogleOAuthClientSecret string `json:"google_oauth_client_secret"` - GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"` - GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"` - - // OEM设置 - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL *string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"` - CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"` - - // 默认配置 - DefaultConcurrency int `json:"default_concurrency"` - DefaultBalance float64 `json:"default_balance"` - AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"` - AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"` - AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"` - AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"` - DefaultUserRPMLimit int `json:"default_user_rpm_limit"` - DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"` - AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"` - AuthSourceDefaultEmailConcurrency *int `json:"auth_source_default_email_concurrency"` - AuthSourceDefaultEmailSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_email_subscriptions"` - AuthSourceDefaultEmailGrantOnSignup *bool `json:"auth_source_default_email_grant_on_signup"` - AuthSourceDefaultEmailGrantOnFirstBind *bool `json:"auth_source_default_email_grant_on_first_bind"` - AuthSourceDefaultLinuxDoBalance *float64 `json:"auth_source_default_linuxdo_balance"` - AuthSourceDefaultLinuxDoConcurrency *int `json:"auth_source_default_linuxdo_concurrency"` - AuthSourceDefaultLinuxDoSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_linuxdo_subscriptions"` - AuthSourceDefaultLinuxDoGrantOnSignup *bool `json:"auth_source_default_linuxdo_grant_on_signup"` - AuthSourceDefaultLinuxDoGrantOnFirstBind *bool `json:"auth_source_default_linuxdo_grant_on_first_bind"` - AuthSourceDefaultOIDCBalance *float64 `json:"auth_source_default_oidc_balance"` - AuthSourceDefaultOIDCConcurrency *int `json:"auth_source_default_oidc_concurrency"` - AuthSourceDefaultOIDCSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_oidc_subscriptions"` - AuthSourceDefaultOIDCGrantOnSignup *bool `json:"auth_source_default_oidc_grant_on_signup"` - AuthSourceDefaultOIDCGrantOnFirstBind *bool `json:"auth_source_default_oidc_grant_on_first_bind"` - AuthSourceDefaultWeChatBalance *float64 `json:"auth_source_default_wechat_balance"` - AuthSourceDefaultWeChatConcurrency *int `json:"auth_source_default_wechat_concurrency"` - AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"` - AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"` - AuthSourceDefaultWeChatGrantOnFirstBind *bool `json:"auth_source_default_wechat_grant_on_first_bind"` - AuthSourceDefaultGitHubBalance *float64 `json:"auth_source_default_github_balance"` - AuthSourceDefaultGitHubConcurrency *int `json:"auth_source_default_github_concurrency"` - AuthSourceDefaultGitHubSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_github_subscriptions"` - AuthSourceDefaultGitHubGrantOnSignup *bool `json:"auth_source_default_github_grant_on_signup"` - AuthSourceDefaultGitHubGrantOnFirstBind *bool `json:"auth_source_default_github_grant_on_first_bind"` - AuthSourceDefaultGoogleBalance *float64 `json:"auth_source_default_google_balance"` - AuthSourceDefaultGoogleConcurrency *int `json:"auth_source_default_google_concurrency"` - AuthSourceDefaultGoogleSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_google_subscriptions"` - AuthSourceDefaultGoogleGrantOnSignup *bool `json:"auth_source_default_google_grant_on_signup"` - AuthSourceDefaultGoogleGrantOnFirstBind *bool `json:"auth_source_default_google_grant_on_first_bind"` - AuthSourceDefaultDingTalkBalance *float64 `json:"auth_source_default_dingtalk_balance"` - AuthSourceDefaultDingTalkConcurrency *int `json:"auth_source_default_dingtalk_concurrency"` - AuthSourceDefaultDingTalkSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_dingtalk_subscriptions"` - AuthSourceDefaultDingTalkGrantOnSignup *bool `json:"auth_source_default_dingtalk_grant_on_signup"` - AuthSourceDefaultDingTalkGrantOnFirstBind *bool `json:"auth_source_default_dingtalk_grant_on_first_bind"` - ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"` - - // Model fallback configuration - EnableModelFallback bool `json:"enable_model_fallback"` - FallbackModelAnthropic string `json:"fallback_model_anthropic"` - FallbackModelOpenAI string `json:"fallback_model_openai"` - FallbackModelGemini string `json:"fallback_model_gemini"` - FallbackModelAntigravity string `json:"fallback_model_antigravity"` - - // Identity patch configuration (Claude -> Gemini) - EnableIdentityPatch bool `json:"enable_identity_patch"` - IdentityPatchPrompt string `json:"identity_patch_prompt"` - - // Ops monitoring (vNext) - OpsMonitoringEnabled *bool `json:"ops_monitoring_enabled"` - OpsRealtimeMonitoringEnabled *bool `json:"ops_realtime_monitoring_enabled"` - OpsQueryModeDefault *string `json:"ops_query_mode_default"` - OpsMetricsIntervalSeconds *int `json:"ops_metrics_interval_seconds"` - - MinClaudeCodeVersion string `json:"min_claude_code_version"` - MaxClaudeCodeVersion string `json:"max_claude_code_version"` - - // 分组隔离 - AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"` - - // Backend Mode - BackendModeEnabled bool `json:"backend_mode_enabled"` - - // Gateway forwarding behavior - EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"` - EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"` - EnableCCHSigning *bool `json:"enable_cch_signing"` - EnableClaudeOAuthSystemPromptInjection *bool `json:"enable_claude_oauth_system_prompt_injection"` - ClaudeOAuthSystemPrompt *string `json:"claude_oauth_system_prompt"` - ClaudeOAuthSystemPromptBlocks *string `json:"claude_oauth_system_prompt_blocks"` - EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"` - RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"` - EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"` - AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"` - OpenAICodexUserAgent *string `json:"openai_codex_user_agent"` - - // codex_cli_only 加固(global-only) - MinCodexVersion string `json:"min_codex_version"` - MaxCodexVersion string `json:"max_codex_version"` - CodexCLIOnlyBlacklist string `json:"codex_cli_only_blacklist"` - CodexCLIOnlyWhitelist string `json:"codex_cli_only_whitelist"` - CodexCLIOnlyAllowAppServerClients *bool `json:"codex_cli_only_allow_app_server_clients"` - CodexCLIOnlyEngineFingerprintSignals string `json:"codex_cli_only_engine_fingerprint_signals"` - - // Payment visible method routing - PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"` - PaymentVisibleMethodWxpaySource *string `json:"payment_visible_method_wxpay_source"` - PaymentVisibleMethodAlipayEnabled *bool `json:"payment_visible_method_alipay_enabled"` - PaymentVisibleMethodWxpayEnabled *bool `json:"payment_visible_method_wxpay_enabled"` - - // OpenAI account scheduling - OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"` - OpenAIAdvancedSchedulerStickyWeightedEnabled *bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"` - OpenAIAdvancedSchedulerSubscriptionPriorityEnabled *bool `json:"openai_advanced_scheduler_subscription_priority_enabled"` - OpenAIAdvancedSchedulerLBTopK *string `json:"openai_advanced_scheduler_lb_top_k"` - OpenAIAdvancedSchedulerWeightPriority *string `json:"openai_advanced_scheduler_weight_priority"` - OpenAIAdvancedSchedulerWeightLoad *string `json:"openai_advanced_scheduler_weight_load"` - OpenAIAdvancedSchedulerWeightQueue *string `json:"openai_advanced_scheduler_weight_queue"` - OpenAIAdvancedSchedulerWeightErrorRate *string `json:"openai_advanced_scheduler_weight_error_rate"` - OpenAIAdvancedSchedulerWeightTTFT *string `json:"openai_advanced_scheduler_weight_ttft"` - OpenAIAdvancedSchedulerWeightReset *string `json:"openai_advanced_scheduler_weight_reset"` - OpenAIAdvancedSchedulerWeightQuotaHeadroom *string `json:"openai_advanced_scheduler_weight_quota_headroom"` - OpenAIAdvancedSchedulerWeightPreviousResponse *string `json:"openai_advanced_scheduler_weight_previous_response"` - OpenAIAdvancedSchedulerWeightSessionSticky *string `json:"openai_advanced_scheduler_weight_session_sticky"` - - // 余额不足提醒 - BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"` - BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"` - BalanceLowNotifyRechargeURL *string `json:"balance_low_notify_recharge_url"` - SubscriptionExpiryNotifyEnabled *bool `json:"subscription_expiry_notify_enabled"` - AccountQuotaNotifyEnabled *bool `json:"account_quota_notify_enabled"` - AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"` - - // Payment configuration (integrated into settings, full replace) - PaymentEnabled *bool `json:"payment_enabled"` - PaymentMinAmount *float64 `json:"payment_min_amount"` - PaymentMaxAmount *float64 `json:"payment_max_amount"` - PaymentDailyLimit *float64 `json:"payment_daily_limit"` - PaymentOrderTimeoutMin *int `json:"payment_order_timeout_minutes"` - PaymentMaxPendingOrders *int `json:"payment_max_pending_orders"` - PaymentEnabledTypes []string `json:"payment_enabled_types"` - PaymentBalanceDisabled *bool `json:"payment_balance_disabled"` - PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"` - PaymentSubscriptionUSDToCNYRate *float64 `json:"payment_subscription_usd_to_cny_rate"` - PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"` - PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"` - PaymentProductNamePrefix *string `json:"payment_product_name_prefix"` - PaymentProductNameSuffix *string `json:"payment_product_name_suffix"` - PaymentHelpImageURL *string `json:"payment_help_image_url"` - PaymentHelpText *string `json:"payment_help_text"` - - // Cancel rate limit - PaymentCancelRateLimitEnabled *bool `json:"payment_cancel_rate_limit_enabled"` - PaymentCancelRateLimitMax *int `json:"payment_cancel_rate_limit_max"` - PaymentCancelRateLimitWindow *int `json:"payment_cancel_rate_limit_window"` - PaymentCancelRateLimitUnit *string `json:"payment_cancel_rate_limit_unit"` - PaymentCancelRateLimitMode *string `json:"payment_cancel_rate_limit_window_mode"` - - // Force Alipay mobile clients to use QR code payment instead of mobile redirect - PaymentAlipayForceQRCode *bool `json:"payment_alipay_force_qrcode"` - - // Channel Monitor feature switch - ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"` - - // Available Channels feature switch (user-facing) - AvailableChannelsEnabled *bool `json:"available_channels_enabled"` - - // Affiliate (邀请返利) feature switch - AffiliateEnabled *bool `json:"affiliate_enabled"` - - // 风控中心功能开关 - RiskControlEnabled *bool `json:"risk_control_enabled"` - - // cyber 会话屏蔽开关 + TTL - CyberSessionBlockEnabled *bool `json:"cyber_session_block_enabled"` - CyberSessionBlockTTLSeconds *int `json:"cyber_session_block_ttl_seconds"` - - // OpenAI fast/flex policy (optional, only updated when provided) - OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"` - - // 系统全局 platform quota 默认值(整体替换语义:nil = 不修改,non-nil = 整体覆盖)。 - DefaultPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"default_platform_quotas"` - - // auth-source 层 platform quota 覆盖(override 语义:nil = 不修改,non-nil = 整体覆盖该 source 的 quota 配置)。 - AuthSourceEmailPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_email_platform_quotas"` - AuthSourceLinuxDoPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_linuxdo_platform_quotas"` - AuthSourceOIDCPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_oidc_platform_quotas"` - AuthSourceWeChatPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_wechat_platform_quotas"` - AuthSourceGitHubPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_github_platform_quotas"` - AuthSourceGooglePlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_google_platform_quotas"` - AuthSourceDingTalkPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_dingtalk_platform_quotas"` - - AllowUserViewErrorRequests *bool `json:"allow_user_view_error_requests"` -} - -// UpdateSettings 更新系统设置 -// PUT /api/v1/admin/settings -func (h *SettingHandler) UpdateSettings(c *gin.Context) { - var req UpdateSettingsRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - - previousSettings, err := h.settingService.GetAllSettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - previousAuthSourceDefaults, err := h.settingService.GetAuthSourceDefaultSettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - - // 验证参数 - if req.DefaultConcurrency < 1 { - req.DefaultConcurrency = 1 - } - if req.DefaultBalance < 0 { - req.DefaultBalance = 0 - } - affiliateRebateRate := previousSettings.AffiliateRebateRate - if req.AffiliateRebateRate != nil { - affiliateRebateRate = *req.AffiliateRebateRate - } - if affiliateRebateRate < service.AffiliateRebateRateMin { - affiliateRebateRate = service.AffiliateRebateRateMin - } - if affiliateRebateRate > service.AffiliateRebateRateMax { - affiliateRebateRate = service.AffiliateRebateRateMax - } - affiliateRebateFreezeHours := previousSettings.AffiliateRebateFreezeHours - if req.AffiliateRebateFreezeHours != nil { - affiliateRebateFreezeHours = *req.AffiliateRebateFreezeHours - } - if affiliateRebateFreezeHours < 0 { - affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursDefault - } - if affiliateRebateFreezeHours > service.AffiliateRebateFreezeHoursMax { - affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursMax - } - affiliateRebateDurationDays := previousSettings.AffiliateRebateDurationDays - if req.AffiliateRebateDurationDays != nil { - affiliateRebateDurationDays = *req.AffiliateRebateDurationDays - } - if affiliateRebateDurationDays < 0 { - affiliateRebateDurationDays = service.AffiliateRebateDurationDaysDefault - } - if affiliateRebateDurationDays > service.AffiliateRebateDurationDaysMax { - affiliateRebateDurationDays = service.AffiliateRebateDurationDaysMax - } - affiliateRebatePerInviteeCap := previousSettings.AffiliateRebatePerInviteeCap - if req.AffiliateRebatePerInviteeCap != nil { - affiliateRebatePerInviteeCap = *req.AffiliateRebatePerInviteeCap - } - if affiliateRebatePerInviteeCap < 0 { - affiliateRebatePerInviteeCap = service.AffiliateRebatePerInviteeCapDefault - } - // 通用表格配置:兼容旧客户端未传字段时保留当前值。 - if req.TableDefaultPageSize <= 0 { - req.TableDefaultPageSize = previousSettings.TableDefaultPageSize - } - if req.TablePageSizeOptions == nil { - req.TablePageSizeOptions = previousSettings.TablePageSizeOptions - } - req.SMTPHost = strings.TrimSpace(req.SMTPHost) - req.SMTPUsername = strings.TrimSpace(req.SMTPUsername) - req.SMTPPassword = strings.TrimSpace(req.SMTPPassword) - req.SMTPFrom = strings.TrimSpace(req.SMTPFrom) - req.SMTPFromName = strings.TrimSpace(req.SMTPFromName) - if req.SMTPPort <= 0 { - req.SMTPPort = 587 - } - req.DefaultSubscriptions = normalizeDefaultSubscriptions(req.DefaultSubscriptions) - req.AuthSourceDefaultEmailSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultEmailSubscriptions) - req.AuthSourceDefaultLinuxDoSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultLinuxDoSubscriptions) - req.AuthSourceDefaultOIDCSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultOIDCSubscriptions) - req.AuthSourceDefaultWeChatSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultWeChatSubscriptions) - req.AuthSourceDefaultDingTalkSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultDingTalkSubscriptions) - - // SMTP 配置保护:如果请求中 smtp_host 为空但数据库中已有配置,则保留已有 SMTP 配置 - // 防止前端加载设置失败时空表单覆盖已保存的 SMTP 配置 - if req.SMTPHost == "" && previousSettings.SMTPHost != "" { - req.SMTPHost = previousSettings.SMTPHost - req.SMTPPort = previousSettings.SMTPPort - req.SMTPUsername = previousSettings.SMTPUsername - req.SMTPFrom = previousSettings.SMTPFrom - req.SMTPFromName = previousSettings.SMTPFromName - req.SMTPUseTLS = previousSettings.SMTPUseTLS - } - - // Turnstile 参数验证 - if req.TurnstileEnabled { - // 检查必填字段 - if req.TurnstileSiteKey == "" { - response.BadRequest(c, "Turnstile Site Key is required when enabled") - return - } - // 如果未提供 secret key,使用已保存的值(留空保留当前值) - if req.TurnstileSecretKey == "" { - if previousSettings.TurnstileSecretKey == "" { - response.BadRequest(c, "Turnstile Secret Key is required when enabled") - return - } - req.TurnstileSecretKey = previousSettings.TurnstileSecretKey - } - - // 当 site_key 或 secret_key 任一变化时验证(避免配置错误导致无法登录) - siteKeyChanged := previousSettings.TurnstileSiteKey != req.TurnstileSiteKey - secretKeyChanged := previousSettings.TurnstileSecretKey != req.TurnstileSecretKey - if siteKeyChanged || secretKeyChanged { - if err := h.turnstileService.ValidateSecretKey(c.Request.Context(), req.TurnstileSecretKey); err != nil { - response.ErrorFrom(c, err) - return - } - } - } - - // TOTP 双因素认证参数验证 - // 只有手动配置了加密密钥才允许启用 TOTP 功能 - if req.TotpEnabled && !previousSettings.TotpEnabled { - // 尝试启用 TOTP,检查加密密钥是否已手动配置 - if !h.settingService.IsTotpEncryptionKeyConfigured() { - response.BadRequest(c, "Cannot enable TOTP: TOTP_ENCRYPTION_KEY environment variable must be configured first. Generate a key with 'openssl rand -hex 32' and set it in your environment.") - return - } - } - loginAgreementMode := strings.ToLower(strings.TrimSpace(req.LoginAgreementMode)) - if loginAgreementMode == "" { - loginAgreementMode = strings.ToLower(strings.TrimSpace(previousSettings.LoginAgreementMode)) - } - switch loginAgreementMode { - case "", "modal": - loginAgreementMode = "modal" - case "checkbox": - default: - response.BadRequest(c, "Login agreement mode must be modal or checkbox") - return - } - loginAgreementUpdatedAt := strings.TrimSpace(req.LoginAgreementUpdatedAt) - if loginAgreementUpdatedAt == "" { - loginAgreementUpdatedAt = strings.TrimSpace(previousSettings.LoginAgreementUpdatedAt) - } - loginAgreementDocuments := loginAgreementDocumentsToService(req.LoginAgreementDocuments) - if len(loginAgreementDocuments) == 0 { - loginAgreementDocuments = previousSettings.LoginAgreementDocuments - } - for _, doc := range loginAgreementDocuments { - if strings.TrimSpace(doc.Title) == "" { - response.BadRequest(c, "Login agreement document title is required") - return - } - if len(doc.Title) > 80 { - response.BadRequest(c, "Login agreement document title is too long (max 80 characters)") - return - } - if len(doc.ContentMD) > 200*1024 { - response.BadRequest(c, "Login agreement document content is too large (max 200KB)") - return - } - } - if req.LoginAgreementEnabled && len(loginAgreementDocuments) == 0 { - response.BadRequest(c, "Login agreement documents are required when enabled") - return - } - - // LinuxDo Connect 参数验证 - if req.LinuxDoConnectEnabled { - req.LinuxDoConnectClientID = strings.TrimSpace(req.LinuxDoConnectClientID) - req.LinuxDoConnectClientSecret = strings.TrimSpace(req.LinuxDoConnectClientSecret) - req.LinuxDoConnectRedirectURL = strings.TrimSpace(req.LinuxDoConnectRedirectURL) - - if req.LinuxDoConnectClientID == "" { - response.BadRequest(c, "LinuxDo Client ID is required when enabled") - return - } - if req.LinuxDoConnectRedirectURL == "" { - response.BadRequest(c, "LinuxDo Redirect URL is required when enabled") - return - } - if err := config.ValidateAbsoluteHTTPURL(req.LinuxDoConnectRedirectURL); err != nil { - response.BadRequest(c, "LinuxDo Redirect URL must be an absolute http(s) URL") - return - } - - // 如果未提供 client_secret,则保留现有值(如有)。 - if req.LinuxDoConnectClientSecret == "" { - if previousSettings.LinuxDoConnectClientSecret == "" { - response.BadRequest(c, "LinuxDo Client Secret is required when enabled") - return - } - req.LinuxDoConnectClientSecret = previousSettings.LinuxDoConnectClientSecret - } - } - - // DingTalk Connect 参数验证 - // 防御性:任何写入路径上把已废弃的 corp_restriction_policy=whitelist 入参 coerce 为 none, - // 避免任何直连 admin API 的客户端把死值写回 DB(前端 UI 已无此选项)。 - req.DingTalkConnectCorpRestrictionPolicy = service.CoerceDingTalkCorpPolicyForWrite(req.DingTalkConnectCorpRestrictionPolicy) - - if req.DingTalkConnectEnabled { - req.DingTalkConnectClientID = strings.TrimSpace(req.DingTalkConnectClientID) - req.DingTalkConnectClientSecret = strings.TrimSpace(req.DingTalkConnectClientSecret) - req.DingTalkConnectRedirectURL = strings.TrimSpace(req.DingTalkConnectRedirectURL) - req.DingTalkConnectCorpRestrictionPolicy = strings.TrimSpace(req.DingTalkConnectCorpRestrictionPolicy) - req.DingTalkConnectInternalCorpID = strings.TrimSpace(req.DingTalkConnectInternalCorpID) - - if req.DingTalkConnectClientID == "" { - response.BadRequest(c, "DingTalk Client ID is required when enabled") - return - } - if req.DingTalkConnectRedirectURL == "" { - response.BadRequest(c, "DingTalk Redirect URL is required when enabled") - return - } - if err := config.ValidateAbsoluteHTTPURL(req.DingTalkConnectRedirectURL); err != nil { - response.BadRequest(c, "DingTalk Redirect URL must be an absolute http(s) URL") - return - } - - // 如果未提供 client_secret,则保留现有值(如有)。 - if req.DingTalkConnectClientSecret == "" { - if previousSettings.DingTalkConnectClientSecret == "" { - response.BadRequest(c, "DingTalk Client Secret is required when enabled") - return - } - req.DingTalkConnectClientSecret = previousSettings.DingTalkConnectClientSecret - } - - // Corp 策略校验(V1/V4 fail-closed) - dingTalkCfg := config.DingTalkConnectConfig{ - Enabled: true, - DingTalkAppKind: "internal_app", // 硬编码:settings 层仅支持 internal_app - AppType: "internal", // 对于 internal_only 策略的默认值 - CorpRestrictionPolicy: req.DingTalkConnectCorpRestrictionPolicy, - InternalCorpID: req.DingTalkConnectInternalCorpID, - } - // 若未填 corp_restriction_policy,保留已有配置 - if dingTalkCfg.CorpRestrictionPolicy == "" { - dingTalkCfg.CorpRestrictionPolicy = previousSettings.DingTalkConnectCorpRestrictionPolicy - } - // 对于 internal_only 策略,app_type 必须为 internal(V1 校验) - if dingTalkCfg.CorpRestrictionPolicy == "internal_only" { - dingTalkCfg.AppType = "internal" - } else { - dingTalkCfg.AppType = "public" - } - if err := config.ValidateDingTalkConfig(dingTalkCfg); err != nil { - response.ErrorWithDetails(c, http.StatusBadRequest, err.Error(), mapDingTalkValidateError(err), nil) - return - } - - // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制为 false, - // 防止 admin 在切换 policy 时把 bypass 残留在 DB 中(前端 UI 也已隐藏该开关)。 - if dingTalkCfg.CorpRestrictionPolicy != "internal_only" { - req.DingTalkConnectBypassRegistration = false - // 身份同步三开关同理:仅 internal_only 模式下有意义,其它策略强制 false。 - req.DingTalkConnectSyncCorpEmail = false - req.DingTalkConnectSyncDisplayName = false - req.DingTalkConnectSyncDept = false - } - // 身份同步目标 attr key:trimSpace + 空值 fallback 到默认值 - req.DingTalkConnectSyncCorpEmailAttrKey = strings.TrimSpace(req.DingTalkConnectSyncCorpEmailAttrKey) - if req.DingTalkConnectSyncCorpEmailAttrKey == "" { - req.DingTalkConnectSyncCorpEmailAttrKey = "dingtalk_email" - } - req.DingTalkConnectSyncDisplayNameAttrKey = strings.TrimSpace(req.DingTalkConnectSyncDisplayNameAttrKey) - if req.DingTalkConnectSyncDisplayNameAttrKey == "" { - req.DingTalkConnectSyncDisplayNameAttrKey = "dingtalk_name" - } - req.DingTalkConnectSyncDeptAttrKey = strings.TrimSpace(req.DingTalkConnectSyncDeptAttrKey) - if req.DingTalkConnectSyncDeptAttrKey == "" { - req.DingTalkConnectSyncDeptAttrKey = "dingtalk_department" - } - // 身份同步目标 attr 显示名称:trim + 空值 fallback 到默认中文名 - req.DingTalkConnectSyncCorpEmailAttrName = strings.TrimSpace(req.DingTalkConnectSyncCorpEmailAttrName) - if req.DingTalkConnectSyncCorpEmailAttrName == "" { - req.DingTalkConnectSyncCorpEmailAttrName = "钉钉企业邮箱" - } - req.DingTalkConnectSyncDisplayNameAttrName = strings.TrimSpace(req.DingTalkConnectSyncDisplayNameAttrName) - if req.DingTalkConnectSyncDisplayNameAttrName == "" { - req.DingTalkConnectSyncDisplayNameAttrName = "钉钉姓名" - } - req.DingTalkConnectSyncDeptAttrName = strings.TrimSpace(req.DingTalkConnectSyncDeptAttrName) - if req.DingTalkConnectSyncDeptAttrName == "" { - req.DingTalkConnectSyncDeptAttrName = "钉钉部门" - } - } - - if req.WeChatConnectEnabled { - req.WeChatConnectAppID = strings.TrimSpace(req.WeChatConnectAppID) - req.WeChatConnectAppSecret = strings.TrimSpace(req.WeChatConnectAppSecret) - req.WeChatConnectOpenAppID = strings.TrimSpace(req.WeChatConnectOpenAppID) - req.WeChatConnectOpenAppSecret = strings.TrimSpace(req.WeChatConnectOpenAppSecret) - req.WeChatConnectMPAppID = strings.TrimSpace(req.WeChatConnectMPAppID) - req.WeChatConnectMPAppSecret = strings.TrimSpace(req.WeChatConnectMPAppSecret) - req.WeChatConnectMobileAppID = strings.TrimSpace(req.WeChatConnectMobileAppID) - req.WeChatConnectMobileAppSecret = strings.TrimSpace(req.WeChatConnectMobileAppSecret) - req.WeChatConnectMode = strings.ToLower(strings.TrimSpace(req.WeChatConnectMode)) - req.WeChatConnectScopes = strings.TrimSpace(req.WeChatConnectScopes) - req.WeChatConnectRedirectURL = strings.TrimSpace(req.WeChatConnectRedirectURL) - req.WeChatConnectFrontendRedirectURL = strings.TrimSpace(req.WeChatConnectFrontendRedirectURL) - req.WeChatConnectAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectAppID, previousSettings.WeChatConnectAppID)) - req.WeChatConnectRedirectURL = strings.TrimSpace(firstNonEmpty(req.WeChatConnectRedirectURL, previousSettings.WeChatConnectRedirectURL)) - req.WeChatConnectFrontendRedirectURL = strings.TrimSpace(firstNonEmpty(req.WeChatConnectFrontendRedirectURL, previousSettings.WeChatConnectFrontendRedirectURL)) - if req.WeChatConnectMode == "" { - req.WeChatConnectMode = strings.ToLower(strings.TrimSpace(previousSettings.WeChatConnectMode)) - } - if req.WeChatConnectScopes == "" { - req.WeChatConnectScopes = strings.TrimSpace(previousSettings.WeChatConnectScopes) - } - - if req.WeChatConnectMPEnabled && req.WeChatConnectMobileEnabled { - response.BadRequest(c, "WeChat Official Account and Mobile App cannot be enabled at the same time") - return - } - if req.WeChatConnectMode != "" { - switch req.WeChatConnectMode { - case "open", "mp", "mobile": - default: - response.BadRequest(c, "WeChat mode must be open, mp, or mobile") - return - } - } - if !req.WeChatConnectOpenEnabled && !req.WeChatConnectMPEnabled && !req.WeChatConnectMobileEnabled { - switch req.WeChatConnectMode { - case "mp": - req.WeChatConnectMPEnabled = true - case "mobile": - req.WeChatConnectMobileEnabled = true - default: - req.WeChatConnectOpenEnabled = true - } - } - if req.WeChatConnectMode == "" { - if req.WeChatConnectMPEnabled { - req.WeChatConnectMode = "mp" - } else if req.WeChatConnectMobileEnabled { - req.WeChatConnectMode = "mobile" - } else { - req.WeChatConnectMode = "open" - } - } - - req.WeChatConnectOpenAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectOpenAppID, req.WeChatConnectAppID, previousSettings.WeChatConnectOpenAppID, previousSettings.WeChatConnectAppID)) - req.WeChatConnectMPAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectMPAppID, req.WeChatConnectAppID, previousSettings.WeChatConnectMPAppID, previousSettings.WeChatConnectAppID)) - req.WeChatConnectMobileAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectMobileAppID, req.WeChatConnectAppID, previousSettings.WeChatConnectMobileAppID, previousSettings.WeChatConnectAppID)) - - if req.WeChatConnectOpenAppSecret == "" { - req.WeChatConnectOpenAppSecret = strings.TrimSpace(firstNonEmpty(previousSettings.WeChatConnectOpenAppSecret, previousSettings.WeChatConnectAppSecret, req.WeChatConnectAppSecret)) - } - if req.WeChatConnectMPAppSecret == "" { - req.WeChatConnectMPAppSecret = strings.TrimSpace(firstNonEmpty(previousSettings.WeChatConnectMPAppSecret, previousSettings.WeChatConnectAppSecret, req.WeChatConnectAppSecret)) - } - if req.WeChatConnectMobileAppSecret == "" { - req.WeChatConnectMobileAppSecret = strings.TrimSpace(firstNonEmpty(previousSettings.WeChatConnectMobileAppSecret, previousSettings.WeChatConnectAppSecret, req.WeChatConnectAppSecret)) - } - if req.WeChatConnectAppSecret == "" { - req.WeChatConnectAppSecret = strings.TrimSpace(firstNonEmpty(req.WeChatConnectOpenAppSecret, req.WeChatConnectMPAppSecret, req.WeChatConnectMobileAppSecret, previousSettings.WeChatConnectAppSecret)) - } - - if req.WeChatConnectOpenEnabled { - if req.WeChatConnectOpenAppID == "" { - response.BadRequest(c, "WeChat PC App ID is required when enabled") - return - } - if req.WeChatConnectOpenAppSecret == "" { - response.BadRequest(c, "WeChat PC App Secret is required when enabled") - return - } - } - if req.WeChatConnectMPEnabled { - if req.WeChatConnectMPAppID == "" { - response.BadRequest(c, "WeChat Official Account App ID is required when enabled") - return - } - if req.WeChatConnectMPAppSecret == "" { - response.BadRequest(c, "WeChat Official Account App Secret is required when enabled") - return - } - } - if req.WeChatConnectMobileEnabled { - if req.WeChatConnectMobileAppID == "" { - response.BadRequest(c, "WeChat Mobile App ID is required when enabled") - return - } - if req.WeChatConnectMobileAppSecret == "" { - response.BadRequest(c, "WeChat Mobile App Secret is required when enabled") - return - } - } - - if req.WeChatConnectScopes == "" { - if req.WeChatConnectMPEnabled { - req.WeChatConnectScopes = service.DefaultWeChatConnectScopesForMode("mp") - } else { - req.WeChatConnectScopes = service.DefaultWeChatConnectScopesForMode(req.WeChatConnectMode) - } - } - if req.WeChatConnectOpenEnabled || req.WeChatConnectMPEnabled { - if req.WeChatConnectRedirectURL == "" { - response.BadRequest(c, "WeChat Redirect URL is required when web oauth is enabled") - return - } - if err := config.ValidateAbsoluteHTTPURL(req.WeChatConnectRedirectURL); err != nil { - response.BadRequest(c, "WeChat Redirect URL must be an absolute http(s) URL") - return - } - if req.WeChatConnectFrontendRedirectURL == "" { - req.WeChatConnectFrontendRedirectURL = "/auth/wechat/callback" - } - if err := config.ValidateFrontendRedirectURL(req.WeChatConnectFrontendRedirectURL); err != nil { - response.BadRequest(c, "WeChat Frontend Redirect URL is invalid") - return - } - } - } - - // Generic OIDC 参数验证 - oidcUsePKCE, oidcValidateIDToken, err := h.settingService.OIDCSecurityWriteDefaults(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - if req.OIDCConnectEnabled { - req.OIDCConnectProviderName = strings.TrimSpace(req.OIDCConnectProviderName) - req.OIDCConnectClientID = strings.TrimSpace(req.OIDCConnectClientID) - req.OIDCConnectClientSecret = strings.TrimSpace(req.OIDCConnectClientSecret) - req.OIDCConnectIssuerURL = strings.TrimSpace(req.OIDCConnectIssuerURL) - req.OIDCConnectDiscoveryURL = strings.TrimSpace(req.OIDCConnectDiscoveryURL) - req.OIDCConnectAuthorizeURL = strings.TrimSpace(req.OIDCConnectAuthorizeURL) - req.OIDCConnectTokenURL = strings.TrimSpace(req.OIDCConnectTokenURL) - req.OIDCConnectUserInfoURL = strings.TrimSpace(req.OIDCConnectUserInfoURL) - req.OIDCConnectJWKSURL = strings.TrimSpace(req.OIDCConnectJWKSURL) - req.OIDCConnectScopes = strings.TrimSpace(req.OIDCConnectScopes) - req.OIDCConnectRedirectURL = strings.TrimSpace(req.OIDCConnectRedirectURL) - req.OIDCConnectFrontendRedirectURL = strings.TrimSpace(req.OIDCConnectFrontendRedirectURL) - req.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(req.OIDCConnectTokenAuthMethod)) - req.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(req.OIDCConnectAllowedSigningAlgs) - req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(req.OIDCConnectUserInfoEmailPath) - req.OIDCConnectUserInfoIDPath = strings.TrimSpace(req.OIDCConnectUserInfoIDPath) - req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(req.OIDCConnectUserInfoUsernamePath) - req.OIDCConnectProviderName = strings.TrimSpace(firstNonEmpty(req.OIDCConnectProviderName, previousSettings.OIDCConnectProviderName, "OIDC")) - req.OIDCConnectClientID = strings.TrimSpace(firstNonEmpty(req.OIDCConnectClientID, previousSettings.OIDCConnectClientID)) - req.OIDCConnectIssuerURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectIssuerURL, previousSettings.OIDCConnectIssuerURL)) - req.OIDCConnectDiscoveryURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectDiscoveryURL, previousSettings.OIDCConnectDiscoveryURL)) - req.OIDCConnectAuthorizeURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectAuthorizeURL, previousSettings.OIDCConnectAuthorizeURL)) - req.OIDCConnectTokenURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectTokenURL, previousSettings.OIDCConnectTokenURL)) - req.OIDCConnectUserInfoURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoURL, previousSettings.OIDCConnectUserInfoURL)) - req.OIDCConnectJWKSURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectJWKSURL, previousSettings.OIDCConnectJWKSURL)) - req.OIDCConnectScopes = strings.TrimSpace(firstNonEmpty(req.OIDCConnectScopes, previousSettings.OIDCConnectScopes, "openid email profile")) - req.OIDCConnectRedirectURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectRedirectURL, previousSettings.OIDCConnectRedirectURL)) - req.OIDCConnectFrontendRedirectURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectFrontendRedirectURL, previousSettings.OIDCConnectFrontendRedirectURL, "/auth/oidc/callback")) - req.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(firstNonEmpty(req.OIDCConnectTokenAuthMethod, previousSettings.OIDCConnectTokenAuthMethod, "client_secret_post"))) - req.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(firstNonEmpty(req.OIDCConnectAllowedSigningAlgs, previousSettings.OIDCConnectAllowedSigningAlgs, "RS256,ES256,PS256")) - req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoEmailPath, previousSettings.OIDCConnectUserInfoEmailPath)) - req.OIDCConnectUserInfoIDPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoIDPath, previousSettings.OIDCConnectUserInfoIDPath)) - req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoUsernamePath, previousSettings.OIDCConnectUserInfoUsernamePath)) - if req.OIDCConnectUsePKCE != nil { - oidcUsePKCE = *req.OIDCConnectUsePKCE - } - if req.OIDCConnectValidateIDToken != nil { - oidcValidateIDToken = *req.OIDCConnectValidateIDToken - } - if req.OIDCConnectClockSkewSeconds == 0 { - req.OIDCConnectClockSkewSeconds = previousSettings.OIDCConnectClockSkewSeconds - if req.OIDCConnectClockSkewSeconds == 0 { - req.OIDCConnectClockSkewSeconds = 120 - } - } - - if req.OIDCConnectClientID == "" { - response.BadRequest(c, "OIDC Client ID is required when enabled") - return - } - if req.OIDCConnectIssuerURL == "" { - response.BadRequest(c, "OIDC Issuer URL is required when enabled") - return - } - if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectIssuerURL); err != nil { - response.BadRequest(c, "OIDC Issuer URL must be an absolute http(s) URL") - return - } - if req.OIDCConnectDiscoveryURL != "" { - if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectDiscoveryURL); err != nil { - response.BadRequest(c, "OIDC Discovery URL must be an absolute http(s) URL") - return - } - } - if req.OIDCConnectAuthorizeURL != "" { - if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectAuthorizeURL); err != nil { - response.BadRequest(c, "OIDC Authorize URL must be an absolute http(s) URL") - return - } - } - if req.OIDCConnectTokenURL != "" { - if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectTokenURL); err != nil { - response.BadRequest(c, "OIDC Token URL must be an absolute http(s) URL") - return - } - } - if req.OIDCConnectUserInfoURL != "" { - if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectUserInfoURL); err != nil { - response.BadRequest(c, "OIDC UserInfo URL must be an absolute http(s) URL") - return - } - } - if req.OIDCConnectRedirectURL == "" { - response.BadRequest(c, "OIDC Redirect URL is required when enabled") - return - } - if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectRedirectURL); err != nil { - response.BadRequest(c, "OIDC Redirect URL must be an absolute http(s) URL") - return - } - if req.OIDCConnectFrontendRedirectURL == "" { - response.BadRequest(c, "OIDC Frontend Redirect URL is required when enabled") - return - } - if err := config.ValidateFrontendRedirectURL(req.OIDCConnectFrontendRedirectURL); err != nil { - response.BadRequest(c, "OIDC Frontend Redirect URL is invalid") - return - } - if !scopesContainOpenID(req.OIDCConnectScopes) { - response.BadRequest(c, "OIDC scopes must contain openid") - return - } - switch req.OIDCConnectTokenAuthMethod { - case "", "client_secret_post", "client_secret_basic", "none": - default: - response.BadRequest(c, "OIDC Token Auth Method must be one of client_secret_post/client_secret_basic/none") - return - } - if req.OIDCConnectClockSkewSeconds < 0 || req.OIDCConnectClockSkewSeconds > 600 { - response.BadRequest(c, "OIDC clock skew seconds must be between 0 and 600") - return - } - if oidcValidateIDToken && req.OIDCConnectAllowedSigningAlgs == "" { - response.BadRequest(c, "OIDC Allowed Signing Algs is required when validate_id_token=true") - return - } - if req.OIDCConnectJWKSURL != "" { - if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectJWKSURL); err != nil { - response.BadRequest(c, "OIDC JWKS URL must be an absolute http(s) URL") - return - } - } - if req.OIDCConnectTokenAuthMethod == "" || req.OIDCConnectTokenAuthMethod == "client_secret_post" || req.OIDCConnectTokenAuthMethod == "client_secret_basic" { - if req.OIDCConnectClientSecret == "" { - if previousSettings.OIDCConnectClientSecret == "" { - response.BadRequest(c, "OIDC Client Secret is required when enabled") - return - } - req.OIDCConnectClientSecret = previousSettings.OIDCConnectClientSecret - } - } - } - - // “购买订阅”页面配置验证 - purchaseEnabled := previousSettings.PurchaseSubscriptionEnabled - if req.PurchaseSubscriptionEnabled != nil { - purchaseEnabled = *req.PurchaseSubscriptionEnabled - } - purchaseURL := previousSettings.PurchaseSubscriptionURL - if req.PurchaseSubscriptionURL != nil { - purchaseURL = strings.TrimSpace(*req.PurchaseSubscriptionURL) - } - - // - 启用时要求 URL 合法且非空 - // - 禁用时允许为空;若提供了 URL 也做基本校验,避免误配置 - if purchaseEnabled { - if purchaseURL == "" { - response.BadRequest(c, "Purchase Subscription URL is required when enabled") - return - } - if err := config.ValidateAbsoluteHTTPURL(purchaseURL); err != nil { - response.BadRequest(c, "Purchase Subscription URL must be an absolute http(s) URL") - return - } - } else if purchaseURL != "" { - if err := config.ValidateAbsoluteHTTPURL(purchaseURL); err != nil { - response.BadRequest(c, "Purchase Subscription URL must be an absolute http(s) URL") - return - } - } - - // Frontend URL 验证 - req.FrontendURL = strings.TrimSpace(req.FrontendURL) - if req.FrontendURL != "" { - if err := config.ValidateAbsoluteHTTPURL(req.FrontendURL); err != nil { - response.BadRequest(c, "Frontend URL must be an absolute http(s) URL") - return - } - } - - // 自定义菜单项验证 - const ( - maxCustomMenuItems = 20 - maxMenuItemLabelLen = 50 - maxMenuItemURLLen = 2048 - maxMenuItemIconSVGLen = 10 * 1024 // 10KB - maxMenuItemIDLen = 32 - ) - - customMenuJSON := previousSettings.CustomMenuItems - if req.CustomMenuItems != nil { - items := *req.CustomMenuItems - if len(items) > maxCustomMenuItems { - response.BadRequest(c, "Too many custom menu items (max 20)") - return - } - for i, item := range items { - if strings.TrimSpace(item.Label) == "" { - response.BadRequest(c, "Custom menu item label is required") - return - } - if len(item.Label) > maxMenuItemLabelLen { - response.BadRequest(c, "Custom menu item label is too long (max 50 characters)") - return - } - urlTrimmed := strings.TrimSpace(item.URL) - if strings.HasPrefix(urlTrimmed, "md:") { - // Markdown page mode: URL = "md:" - slug := strings.TrimPrefix(urlTrimmed, "md:") - if slug == "" { - response.BadRequest(c, "Custom menu item markdown slug cannot be empty (use md:slug format)") - return - } - } else { - if urlTrimmed == "" { - response.BadRequest(c, "Custom menu item URL is required (use md:slug for markdown pages)") - return - } - if len(item.URL) > maxMenuItemURLLen { - response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)") - return - } - if err := config.ValidateAbsoluteHTTPURL(urlTrimmed); err != nil { - response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL or md:") - return - } - } - if item.Visibility != "user" && item.Visibility != "admin" { - response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'") - return - } - if len(item.IconSVG) > maxMenuItemIconSVGLen { - response.BadRequest(c, "Custom menu item icon SVG is too large (max 10KB)") - return - } - // Auto-generate ID if missing - if strings.TrimSpace(item.ID) == "" { - id, err := generateMenuItemID() - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to generate menu item ID") - return - } - items[i].ID = id - } else if len(item.ID) > maxMenuItemIDLen { - response.BadRequest(c, "Custom menu item ID is too long (max 32 characters)") - return - } else if !menuItemIDPattern.MatchString(item.ID) { - response.BadRequest(c, "Custom menu item ID contains invalid characters (only a-z, A-Z, 0-9, - and _ are allowed)") - return - } - } - // ID uniqueness check - seen := make(map[string]struct{}, len(items)) - for _, item := range items { - if _, exists := seen[item.ID]; exists { - response.BadRequest(c, "Duplicate custom menu item ID: "+item.ID) - return - } - seen[item.ID] = struct{}{} - } - menuBytes, err := json.Marshal(items) - if err != nil { - response.BadRequest(c, "Failed to serialize custom menu items") - return - } - customMenuJSON = string(menuBytes) - } - - // 自定义端点验证 - const ( - maxCustomEndpoints = 10 - maxEndpointNameLen = 50 - maxEndpointURLLen = 2048 - maxEndpointDescriptionLen = 200 - ) - - customEndpointsJSON := previousSettings.CustomEndpoints - if req.CustomEndpoints != nil { - endpoints := *req.CustomEndpoints - if len(endpoints) > maxCustomEndpoints { - response.BadRequest(c, "Too many custom endpoints (max 10)") - return - } - for _, ep := range endpoints { - if strings.TrimSpace(ep.Name) == "" { - response.BadRequest(c, "Custom endpoint name is required") - return - } - if len(ep.Name) > maxEndpointNameLen { - response.BadRequest(c, "Custom endpoint name is too long (max 50 characters)") - return - } - if strings.TrimSpace(ep.Endpoint) == "" { - response.BadRequest(c, "Custom endpoint URL is required") - return - } - if len(ep.Endpoint) > maxEndpointURLLen { - response.BadRequest(c, "Custom endpoint URL is too long (max 2048 characters)") - return - } - if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(ep.Endpoint)); err != nil { - response.BadRequest(c, "Custom endpoint URL must be an absolute http(s) URL") - return - } - if len(ep.Description) > maxEndpointDescriptionLen { - response.BadRequest(c, "Custom endpoint description is too long (max 200 characters)") - return - } - } - endpointBytes, err := json.Marshal(endpoints) - if err != nil { - response.BadRequest(c, "Failed to serialize custom endpoints") - return - } - customEndpointsJSON = string(endpointBytes) - } - - // Ops metrics collector interval validation (seconds). - if req.OpsMetricsIntervalSeconds != nil { - v := *req.OpsMetricsIntervalSeconds - if v < 60 { - v = 60 - } - if v > 3600 { - v = 3600 - } - req.OpsMetricsIntervalSeconds = &v - } - defaultSubscriptions := make([]service.DefaultSubscriptionSetting, 0, len(req.DefaultSubscriptions)) - for _, sub := range req.DefaultSubscriptions { - defaultSubscriptions = append(defaultSubscriptions, service.DefaultSubscriptionSetting{ - GroupID: sub.GroupID, - ValidityDays: sub.ValidityDays, - }) - } - - // 验证最低版本号格式(空字符串=禁用,或合法 semver) - if req.MinClaudeCodeVersion != "" { - if !semverPattern.MatchString(req.MinClaudeCodeVersion) { - response.Error(c, http.StatusBadRequest, "min_claude_code_version must be empty or a valid semver (e.g. 2.1.63)") - return - } - } - - // 验证最高版本号格式(空字符串=禁用,或合法 semver) - if req.MaxClaudeCodeVersion != "" { - if !semverPattern.MatchString(req.MaxClaudeCodeVersion) { - response.Error(c, http.StatusBadRequest, "max_claude_code_version must be empty or a valid semver (e.g. 3.0.0)") - return - } - } - if req.AntigravityUserAgentVersion != nil { - normalized := strings.TrimSpace(*req.AntigravityUserAgentVersion) - req.AntigravityUserAgentVersion = &normalized - if normalized != "" && !semverPattern.MatchString(normalized) { - response.Error(c, http.StatusBadRequest, "antigravity_user_agent_version must be empty or a valid semver (e.g. 1.23.2)") - return - } - } - if req.OpenAICodexUserAgent != nil { - normalized := strings.TrimSpace(*req.OpenAICodexUserAgent) - req.OpenAICodexUserAgent = &normalized - // 仅做长度上限保护,不限制具体格式(运维需要可自由调整 codex 版本号) - if len(normalized) > 512 { - response.Error(c, http.StatusBadRequest, "openai_codex_user_agent must be at most 512 characters") - return - } - } - - // codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min) - if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) { - response.Error(c, http.StatusBadRequest, "min_codex_version must be empty or a valid semver (e.g. 0.141.0)") - return - } - if req.MaxCodexVersion != "" && !semverPattern.MatchString(req.MaxCodexVersion) { - response.Error(c, http.StatusBadRequest, "max_codex_version must be empty or a valid semver (e.g. 0.200.0)") - return - } - if req.MinCodexVersion != "" && req.MaxCodexVersion != "" && service.CompareVersions(req.MaxCodexVersion, req.MinCodexVersion) < 0 { - response.Error(c, http.StatusBadRequest, "max_codex_version must be greater than or equal to min_codex_version") - return - } - // codex_cli_only 黑/白名单:非空须为合法 []AllowedClientEntry JSON。 - // 黑名单 OR 宽 deny(允许 originator-only);白名单双因子 AND,额外要求每条可命中(非空 originator + ua_contains)。 - if err := service.ValidateCodexClientEntriesJSON(req.CodexCLIOnlyBlacklist); err != nil { - response.Error(c, http.StatusBadRequest, "codex_cli_only_blacklist "+err.Error()) - return - } - if err := service.ValidateCodexWhitelistEntriesJSON(req.CodexCLIOnlyWhitelist); err != nil { - response.Error(c, http.StatusBadRequest, "codex_cli_only_whitelist "+err.Error()) - return - } - if err := service.ValidateEngineFingerprintSignalsJSON(req.CodexCLIOnlyEngineFingerprintSignals); err != nil { - response.Error(c, http.StatusBadRequest, "codex_cli_only_engine_fingerprint_signals "+err.Error()) - return - } - - // 交叉验证:如果同时设置了最低和最高版本号,最高版本号必须 >= 最低版本号 - if req.MinClaudeCodeVersion != "" && req.MaxClaudeCodeVersion != "" { - if service.CompareVersions(req.MaxClaudeCodeVersion, req.MinClaudeCodeVersion) < 0 { - response.Error(c, http.StatusBadRequest, "max_claude_code_version must be greater than or equal to min_claude_code_version") - return - } - } - - // cyber 会话屏蔽 TTL 校验:提供时必须 > 0 - if req.CyberSessionBlockTTLSeconds != nil && *req.CyberSessionBlockTTLSeconds <= 0 { - response.BadRequest(c, "cyber_session_block_ttl_seconds must be > 0") - return - } - - settings := &service.SystemSettings{ - // 系统全局 platform quota 默认值(整体替换语义) - DefaultPlatformQuotas: req.DefaultPlatformQuotas, - - RegistrationEnabled: req.RegistrationEnabled, - EmailVerifyEnabled: req.EmailVerifyEnabled, - RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: req.PromoCodeEnabled, - PasswordResetEnabled: req.PasswordResetEnabled, - FrontendURL: req.FrontendURL, - InvitationCodeEnabled: req.InvitationCodeEnabled, - TotpEnabled: req.TotpEnabled, - LoginAgreementEnabled: req.LoginAgreementEnabled, - LoginAgreementMode: loginAgreementMode, - LoginAgreementUpdatedAt: loginAgreementUpdatedAt, - LoginAgreementDocuments: loginAgreementDocuments, - SMTPHost: req.SMTPHost, - SMTPPort: req.SMTPPort, - SMTPUsername: req.SMTPUsername, - SMTPPassword: req.SMTPPassword, - SMTPFrom: req.SMTPFrom, - SMTPFromName: req.SMTPFromName, - SMTPUseTLS: req.SMTPUseTLS, - TurnstileEnabled: req.TurnstileEnabled, - TurnstileSiteKey: req.TurnstileSiteKey, - TurnstileSecretKey: req.TurnstileSecretKey, - APIKeyACLTrustForwardedIP: func() bool { - if req.APIKeyACLTrustForwardedIP != nil { - return *req.APIKeyACLTrustForwardedIP - } - return previousSettings.APIKeyACLTrustForwardedIP - }(), - LinuxDoConnectEnabled: req.LinuxDoConnectEnabled, - LinuxDoConnectClientID: req.LinuxDoConnectClientID, - LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, - LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL, - DingTalkConnectEnabled: req.DingTalkConnectEnabled, - DingTalkConnectClientID: req.DingTalkConnectClientID, - DingTalkConnectClientSecret: req.DingTalkConnectClientSecret, - DingTalkConnectRedirectURL: req.DingTalkConnectRedirectURL, - DingTalkConnectCorpRestrictionPolicy: req.DingTalkConnectCorpRestrictionPolicy, - DingTalkConnectInternalCorpID: req.DingTalkConnectInternalCorpID, - DingTalkConnectBypassRegistration: req.DingTalkConnectBypassRegistration, - DingTalkConnectSyncCorpEmail: req.DingTalkConnectSyncCorpEmail, - DingTalkConnectSyncDisplayName: req.DingTalkConnectSyncDisplayName, - DingTalkConnectSyncDept: req.DingTalkConnectSyncDept, - DingTalkConnectSyncCorpEmailAttrKey: req.DingTalkConnectSyncCorpEmailAttrKey, - DingTalkConnectSyncDisplayNameAttrKey: req.DingTalkConnectSyncDisplayNameAttrKey, - DingTalkConnectSyncDeptAttrKey: req.DingTalkConnectSyncDeptAttrKey, - DingTalkConnectSyncCorpEmailAttrName: req.DingTalkConnectSyncCorpEmailAttrName, - DingTalkConnectSyncDisplayNameAttrName: req.DingTalkConnectSyncDisplayNameAttrName, - DingTalkConnectSyncDeptAttrName: req.DingTalkConnectSyncDeptAttrName, - WeChatConnectEnabled: req.WeChatConnectEnabled, - WeChatConnectAppID: req.WeChatConnectAppID, - WeChatConnectAppSecret: req.WeChatConnectAppSecret, - WeChatConnectOpenAppID: req.WeChatConnectOpenAppID, - WeChatConnectOpenAppSecret: req.WeChatConnectOpenAppSecret, - WeChatConnectMPAppID: req.WeChatConnectMPAppID, - WeChatConnectMPAppSecret: req.WeChatConnectMPAppSecret, - WeChatConnectMobileAppID: req.WeChatConnectMobileAppID, - WeChatConnectMobileAppSecret: req.WeChatConnectMobileAppSecret, - WeChatConnectOpenEnabled: req.WeChatConnectOpenEnabled, - WeChatConnectMPEnabled: req.WeChatConnectMPEnabled, - WeChatConnectMobileEnabled: req.WeChatConnectMobileEnabled, - WeChatConnectMode: req.WeChatConnectMode, - WeChatConnectScopes: req.WeChatConnectScopes, - WeChatConnectRedirectURL: req.WeChatConnectRedirectURL, - WeChatConnectFrontendRedirectURL: req.WeChatConnectFrontendRedirectURL, - OIDCConnectEnabled: req.OIDCConnectEnabled, - OIDCConnectProviderName: req.OIDCConnectProviderName, - OIDCConnectClientID: req.OIDCConnectClientID, - OIDCConnectClientSecret: req.OIDCConnectClientSecret, - OIDCConnectIssuerURL: req.OIDCConnectIssuerURL, - OIDCConnectDiscoveryURL: req.OIDCConnectDiscoveryURL, - OIDCConnectAuthorizeURL: req.OIDCConnectAuthorizeURL, - OIDCConnectTokenURL: req.OIDCConnectTokenURL, - OIDCConnectUserInfoURL: req.OIDCConnectUserInfoURL, - OIDCConnectJWKSURL: req.OIDCConnectJWKSURL, - OIDCConnectScopes: req.OIDCConnectScopes, - OIDCConnectRedirectURL: req.OIDCConnectRedirectURL, - OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL, - OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod, - OIDCConnectUsePKCE: oidcUsePKCE, - OIDCConnectValidateIDToken: oidcValidateIDToken, - OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs, - OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds, - OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified, - OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath, - OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath, - OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath, - GitHubOAuthEnabled: req.GitHubOAuthEnabled, - GitHubOAuthClientID: req.GitHubOAuthClientID, - GitHubOAuthClientSecret: req.GitHubOAuthClientSecret, - GitHubOAuthRedirectURL: req.GitHubOAuthRedirectURL, - GitHubOAuthFrontendRedirectURL: req.GitHubOAuthFrontendRedirectURL, - GoogleOAuthEnabled: req.GoogleOAuthEnabled, - GoogleOAuthClientID: req.GoogleOAuthClientID, - GoogleOAuthClientSecret: req.GoogleOAuthClientSecret, - GoogleOAuthRedirectURL: req.GoogleOAuthRedirectURL, - GoogleOAuthFrontendRedirectURL: req.GoogleOAuthFrontendRedirectURL, - SiteName: req.SiteName, - SiteLogo: req.SiteLogo, - SiteSubtitle: req.SiteSubtitle, - APIBaseURL: req.APIBaseURL, - ContactInfo: req.ContactInfo, - DocURL: req.DocURL, - HomeContent: req.HomeContent, - HideCcsImportButton: req.HideCcsImportButton, - PurchaseSubscriptionEnabled: purchaseEnabled, - PurchaseSubscriptionURL: purchaseURL, - TableDefaultPageSize: req.TableDefaultPageSize, - TablePageSizeOptions: req.TablePageSizeOptions, - CustomMenuItems: customMenuJSON, - CustomEndpoints: customEndpointsJSON, - DefaultConcurrency: req.DefaultConcurrency, - DefaultBalance: req.DefaultBalance, - AffiliateRebateRate: affiliateRebateRate, - AffiliateRebateFreezeHours: affiliateRebateFreezeHours, - AffiliateRebateDurationDays: affiliateRebateDurationDays, - AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap, - DefaultUserRPMLimit: req.DefaultUserRPMLimit, - DefaultSubscriptions: defaultSubscriptions, - EnableModelFallback: req.EnableModelFallback, - FallbackModelAnthropic: req.FallbackModelAnthropic, - FallbackModelOpenAI: req.FallbackModelOpenAI, - FallbackModelGemini: req.FallbackModelGemini, - FallbackModelAntigravity: req.FallbackModelAntigravity, - EnableIdentityPatch: req.EnableIdentityPatch, - IdentityPatchPrompt: req.IdentityPatchPrompt, - MinClaudeCodeVersion: req.MinClaudeCodeVersion, - MaxClaudeCodeVersion: req.MaxClaudeCodeVersion, - AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling, - BackendModeEnabled: req.BackendModeEnabled, - AllowUserViewErrorRequests: func() bool { - if req.AllowUserViewErrorRequests != nil { - return *req.AllowUserViewErrorRequests - } - return previousSettings.AllowUserViewErrorRequests - }(), - OpsMonitoringEnabled: func() bool { - if req.OpsMonitoringEnabled != nil { - return *req.OpsMonitoringEnabled - } - return previousSettings.OpsMonitoringEnabled - }(), - OpsRealtimeMonitoringEnabled: func() bool { - if req.OpsRealtimeMonitoringEnabled != nil { - return *req.OpsRealtimeMonitoringEnabled - } - return previousSettings.OpsRealtimeMonitoringEnabled - }(), - OpsQueryModeDefault: func() string { - if req.OpsQueryModeDefault != nil { - return *req.OpsQueryModeDefault - } - return previousSettings.OpsQueryModeDefault - }(), - OpsMetricsIntervalSeconds: func() int { - if req.OpsMetricsIntervalSeconds != nil { - return *req.OpsMetricsIntervalSeconds - } - return previousSettings.OpsMetricsIntervalSeconds - }(), - EnableFingerprintUnification: func() bool { - if req.EnableFingerprintUnification != nil { - return *req.EnableFingerprintUnification - } - return previousSettings.EnableFingerprintUnification - }(), - EnableMetadataPassthrough: func() bool { - if req.EnableMetadataPassthrough != nil { - return *req.EnableMetadataPassthrough - } - return previousSettings.EnableMetadataPassthrough - }(), - EnableCCHSigning: func() bool { - if req.EnableCCHSigning != nil { - return *req.EnableCCHSigning - } - return previousSettings.EnableCCHSigning - }(), - EnableClaudeOAuthSystemPromptInjection: func() bool { - if req.EnableClaudeOAuthSystemPromptInjection != nil { - return *req.EnableClaudeOAuthSystemPromptInjection - } - return previousSettings.EnableClaudeOAuthSystemPromptInjection - }(), - ClaudeOAuthSystemPrompt: func() string { - if req.ClaudeOAuthSystemPrompt != nil { - return *req.ClaudeOAuthSystemPrompt - } - return previousSettings.ClaudeOAuthSystemPrompt - }(), - ClaudeOAuthSystemPromptBlocks: func() string { - if req.ClaudeOAuthSystemPromptBlocks != nil { - return *req.ClaudeOAuthSystemPromptBlocks - } - return previousSettings.ClaudeOAuthSystemPromptBlocks - }(), - EnableAnthropicCacheTTL1hInjection: func() bool { - if req.EnableAnthropicCacheTTL1hInjection != nil { - return *req.EnableAnthropicCacheTTL1hInjection - } - return previousSettings.EnableAnthropicCacheTTL1hInjection - }(), - RewriteMessageCacheControl: func() bool { - if req.RewriteMessageCacheControl != nil { - return *req.RewriteMessageCacheControl - } - return previousSettings.RewriteMessageCacheControl - }(), - EnableClientDatelineNormalization: func() bool { - if req.EnableClientDatelineNormalization != nil { - return *req.EnableClientDatelineNormalization - } - return previousSettings.EnableClientDatelineNormalization - }(), - AntigravityUserAgentVersion: func() string { - if req.AntigravityUserAgentVersion != nil { - return *req.AntigravityUserAgentVersion - } - return previousSettings.AntigravityUserAgentVersion - }(), - OpenAICodexUserAgent: func() string { - if req.OpenAICodexUserAgent != nil { - return *req.OpenAICodexUserAgent - } - return previousSettings.OpenAICodexUserAgent - }(), - MinCodexVersion: strings.TrimSpace(req.MinCodexVersion), - MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion), - CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist), - CodexCLIOnlyWhitelist: strings.TrimSpace(req.CodexCLIOnlyWhitelist), - CodexCLIOnlyAllowAppServerClients: func() bool { - if req.CodexCLIOnlyAllowAppServerClients != nil { - return *req.CodexCLIOnlyAllowAppServerClients - } - return previousSettings.CodexCLIOnlyAllowAppServerClients - }(), - CodexCLIOnlyEngineFingerprintSignals: strings.TrimSpace(req.CodexCLIOnlyEngineFingerprintSignals), - PaymentVisibleMethodAlipaySource: func() string { - if req.PaymentVisibleMethodAlipaySource != nil { - return strings.TrimSpace(*req.PaymentVisibleMethodAlipaySource) - } - return previousSettings.PaymentVisibleMethodAlipaySource - }(), - PaymentVisibleMethodWxpaySource: func() string { - if req.PaymentVisibleMethodWxpaySource != nil { - return strings.TrimSpace(*req.PaymentVisibleMethodWxpaySource) - } - return previousSettings.PaymentVisibleMethodWxpaySource - }(), - PaymentVisibleMethodAlipayEnabled: func() bool { - if req.PaymentVisibleMethodAlipayEnabled != nil { - return *req.PaymentVisibleMethodAlipayEnabled - } - return previousSettings.PaymentVisibleMethodAlipayEnabled - }(), - PaymentVisibleMethodWxpayEnabled: func() bool { - if req.PaymentVisibleMethodWxpayEnabled != nil { - return *req.PaymentVisibleMethodWxpayEnabled - } - return previousSettings.PaymentVisibleMethodWxpayEnabled - }(), - OpenAIAdvancedSchedulerEnabled: func() bool { - if req.OpenAIAdvancedSchedulerEnabled != nil { - return *req.OpenAIAdvancedSchedulerEnabled - } - return previousSettings.OpenAIAdvancedSchedulerEnabled - }(), - OpenAIAdvancedSchedulerStickyWeightedEnabled: func() bool { - if req.OpenAIAdvancedSchedulerStickyWeightedEnabled != nil { - return *req.OpenAIAdvancedSchedulerStickyWeightedEnabled - } - return previousSettings.OpenAIAdvancedSchedulerStickyWeightedEnabled - }(), - OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: func() bool { - if req.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled != nil { - return *req.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled - } - return previousSettings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled - }(), - OpenAIAdvancedSchedulerLBTopK: stringSetting(req.OpenAIAdvancedSchedulerLBTopK, previousSettings.OpenAIAdvancedSchedulerLBTopK), - OpenAIAdvancedSchedulerWeightPriority: stringSetting(req.OpenAIAdvancedSchedulerWeightPriority, previousSettings.OpenAIAdvancedSchedulerWeightPriority), - OpenAIAdvancedSchedulerWeightLoad: stringSetting(req.OpenAIAdvancedSchedulerWeightLoad, previousSettings.OpenAIAdvancedSchedulerWeightLoad), - OpenAIAdvancedSchedulerWeightQueue: stringSetting(req.OpenAIAdvancedSchedulerWeightQueue, previousSettings.OpenAIAdvancedSchedulerWeightQueue), - OpenAIAdvancedSchedulerWeightErrorRate: stringSetting(req.OpenAIAdvancedSchedulerWeightErrorRate, previousSettings.OpenAIAdvancedSchedulerWeightErrorRate), - OpenAIAdvancedSchedulerWeightTTFT: stringSetting(req.OpenAIAdvancedSchedulerWeightTTFT, previousSettings.OpenAIAdvancedSchedulerWeightTTFT), - OpenAIAdvancedSchedulerWeightReset: stringSetting(req.OpenAIAdvancedSchedulerWeightReset, previousSettings.OpenAIAdvancedSchedulerWeightReset), - OpenAIAdvancedSchedulerWeightQuotaHeadroom: stringSetting(req.OpenAIAdvancedSchedulerWeightQuotaHeadroom, previousSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom), - OpenAIAdvancedSchedulerWeightPreviousResponse: stringSetting(req.OpenAIAdvancedSchedulerWeightPreviousResponse, previousSettings.OpenAIAdvancedSchedulerWeightPreviousResponse), - OpenAIAdvancedSchedulerWeightSessionSticky: stringSetting(req.OpenAIAdvancedSchedulerWeightSessionSticky, previousSettings.OpenAIAdvancedSchedulerWeightSessionSticky), - BalanceLowNotifyEnabled: func() bool { - if req.BalanceLowNotifyEnabled != nil { - return *req.BalanceLowNotifyEnabled - } - return previousSettings.BalanceLowNotifyEnabled - }(), - BalanceLowNotifyThreshold: func() float64 { - if req.BalanceLowNotifyThreshold != nil { - return *req.BalanceLowNotifyThreshold - } - return previousSettings.BalanceLowNotifyThreshold - }(), - BalanceLowNotifyRechargeURL: func() string { - if req.BalanceLowNotifyRechargeURL != nil { - return *req.BalanceLowNotifyRechargeURL - } - return previousSettings.BalanceLowNotifyRechargeURL - }(), - SubscriptionExpiryNotifyEnabled: func() bool { - if req.SubscriptionExpiryNotifyEnabled != nil { - return *req.SubscriptionExpiryNotifyEnabled - } - return previousSettings.SubscriptionExpiryNotifyEnabled - }(), - AccountQuotaNotifyEnabled: func() bool { - if req.AccountQuotaNotifyEnabled != nil { - return *req.AccountQuotaNotifyEnabled - } - return previousSettings.AccountQuotaNotifyEnabled - }(), - AccountQuotaNotifyEmails: func() []service.NotifyEmailEntry { - if req.AccountQuotaNotifyEmails != nil { - return dto.NotifyEmailEntriesToService(*req.AccountQuotaNotifyEmails) - } - return previousSettings.AccountQuotaNotifyEmails - }(), - ChannelMonitorEnabled: func() bool { - if req.ChannelMonitorEnabled != nil { - return *req.ChannelMonitorEnabled - } - return previousSettings.ChannelMonitorEnabled - }(), - ChannelMonitorDefaultIntervalSeconds: func() int { - if req.ChannelMonitorDefaultIntervalSeconds != nil { - return *req.ChannelMonitorDefaultIntervalSeconds - } - return previousSettings.ChannelMonitorDefaultIntervalSeconds - }(), - AvailableChannelsEnabled: func() bool { - if req.AvailableChannelsEnabled != nil { - return *req.AvailableChannelsEnabled - } - return previousSettings.AvailableChannelsEnabled - }(), - AffiliateEnabled: func() bool { - if req.AffiliateEnabled != nil { - return *req.AffiliateEnabled - } - return previousSettings.AffiliateEnabled - }(), - RiskControlEnabled: func() bool { - if req.RiskControlEnabled != nil { - return *req.RiskControlEnabled - } - return previousSettings.RiskControlEnabled - }(), - CyberSessionBlockEnabled: func() bool { - if req.CyberSessionBlockEnabled != nil { - return *req.CyberSessionBlockEnabled - } - return previousSettings.CyberSessionBlockEnabled - }(), - CyberSessionBlockTTLSeconds: func() int { - if req.CyberSessionBlockTTLSeconds != nil { - return *req.CyberSessionBlockTTLSeconds - } - return previousSettings.CyberSessionBlockTTLSeconds - }(), - } - - // req.AuthSourceXxxPlatformQuotas 为 nil 表示本次请求未包含该 source 的 quota 配置(保留 previousAuthSourceDefaults 中的值); - // non-nil(含 empty map)表示整体覆盖:empty map = 清空该 source 的所有 quota 配置。 - authSourceDefaults := &service.AuthSourceDefaultSettings{ - Email: service.ProviderDefaultGrantSettings{ - Balance: float64ValueOrDefault(req.AuthSourceDefaultEmailBalance, previousAuthSourceDefaults.Email.Balance), - Concurrency: intValueOrDefault(req.AuthSourceDefaultEmailConcurrency, previousAuthSourceDefaults.Email.Concurrency), - Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultEmailSubscriptions, previousAuthSourceDefaults.Email.Subscriptions), - GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultEmailGrantOnSignup, previousAuthSourceDefaults.Email.GrantOnSignup), - GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultEmailGrantOnFirstBind, previousAuthSourceDefaults.Email.GrantOnFirstBind), - PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceEmailPlatformQuotas, previousAuthSourceDefaults.Email.PlatformQuotas), - }, - LinuxDo: service.ProviderDefaultGrantSettings{ - Balance: float64ValueOrDefault(req.AuthSourceDefaultLinuxDoBalance, previousAuthSourceDefaults.LinuxDo.Balance), - Concurrency: intValueOrDefault(req.AuthSourceDefaultLinuxDoConcurrency, previousAuthSourceDefaults.LinuxDo.Concurrency), - Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultLinuxDoSubscriptions, previousAuthSourceDefaults.LinuxDo.Subscriptions), - GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultLinuxDoGrantOnSignup, previousAuthSourceDefaults.LinuxDo.GrantOnSignup), - GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultLinuxDoGrantOnFirstBind, previousAuthSourceDefaults.LinuxDo.GrantOnFirstBind), - PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceLinuxDoPlatformQuotas, previousAuthSourceDefaults.LinuxDo.PlatformQuotas), - }, - OIDC: service.ProviderDefaultGrantSettings{ - Balance: float64ValueOrDefault(req.AuthSourceDefaultOIDCBalance, previousAuthSourceDefaults.OIDC.Balance), - Concurrency: intValueOrDefault(req.AuthSourceDefaultOIDCConcurrency, previousAuthSourceDefaults.OIDC.Concurrency), - Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultOIDCSubscriptions, previousAuthSourceDefaults.OIDC.Subscriptions), - GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultOIDCGrantOnSignup, previousAuthSourceDefaults.OIDC.GrantOnSignup), - GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultOIDCGrantOnFirstBind, previousAuthSourceDefaults.OIDC.GrantOnFirstBind), - PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceOIDCPlatformQuotas, previousAuthSourceDefaults.OIDC.PlatformQuotas), - }, - WeChat: service.ProviderDefaultGrantSettings{ - Balance: float64ValueOrDefault(req.AuthSourceDefaultWeChatBalance, previousAuthSourceDefaults.WeChat.Balance), - Concurrency: intValueOrDefault(req.AuthSourceDefaultWeChatConcurrency, previousAuthSourceDefaults.WeChat.Concurrency), - Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultWeChatSubscriptions, previousAuthSourceDefaults.WeChat.Subscriptions), - GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnSignup, previousAuthSourceDefaults.WeChat.GrantOnSignup), - GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnFirstBind, previousAuthSourceDefaults.WeChat.GrantOnFirstBind), - PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceWeChatPlatformQuotas, previousAuthSourceDefaults.WeChat.PlatformQuotas), - }, - GitHub: service.ProviderDefaultGrantSettings{ - Balance: float64ValueOrDefault(req.AuthSourceDefaultGitHubBalance, previousAuthSourceDefaults.GitHub.Balance), - Concurrency: intValueOrDefault(req.AuthSourceDefaultGitHubConcurrency, previousAuthSourceDefaults.GitHub.Concurrency), - Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGitHubSubscriptions, previousAuthSourceDefaults.GitHub.Subscriptions), - GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnSignup, previousAuthSourceDefaults.GitHub.GrantOnSignup), - GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnFirstBind, previousAuthSourceDefaults.GitHub.GrantOnFirstBind), - PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceGitHubPlatformQuotas, previousAuthSourceDefaults.GitHub.PlatformQuotas), - }, - Google: service.ProviderDefaultGrantSettings{ - Balance: float64ValueOrDefault(req.AuthSourceDefaultGoogleBalance, previousAuthSourceDefaults.Google.Balance), - Concurrency: intValueOrDefault(req.AuthSourceDefaultGoogleConcurrency, previousAuthSourceDefaults.Google.Concurrency), - Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGoogleSubscriptions, previousAuthSourceDefaults.Google.Subscriptions), - GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnSignup, previousAuthSourceDefaults.Google.GrantOnSignup), - GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnFirstBind, previousAuthSourceDefaults.Google.GrantOnFirstBind), - PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceGooglePlatformQuotas, previousAuthSourceDefaults.Google.PlatformQuotas), - }, - DingTalk: service.ProviderDefaultGrantSettings{ - Balance: float64ValueOrDefault(req.AuthSourceDefaultDingTalkBalance, previousAuthSourceDefaults.DingTalk.Balance), - Concurrency: intValueOrDefault(req.AuthSourceDefaultDingTalkConcurrency, previousAuthSourceDefaults.DingTalk.Concurrency), - Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultDingTalkSubscriptions, previousAuthSourceDefaults.DingTalk.Subscriptions), - GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultDingTalkGrantOnSignup, previousAuthSourceDefaults.DingTalk.GrantOnSignup), - GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultDingTalkGrantOnFirstBind, previousAuthSourceDefaults.DingTalk.GrantOnFirstBind), - PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceDingTalkPlatformQuotas, previousAuthSourceDefaults.DingTalk.PlatformQuotas), - }, - ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup), - } - if err := h.settingService.UpdateSettingsWithAuthSourceDefaults(c.Request.Context(), settings, authSourceDefaults); err != nil { - response.ErrorFrom(c, err) - return - } - - // Update OpenAI fast policy (stored under dedicated key, only when provided). - if req.OpenAIFastPolicySettings != nil { - if err := h.settingService.SetOpenAIFastPolicySettings(c.Request.Context(), openaiFastPolicySettingsFromDTO(req.OpenAIFastPolicySettings)); err != nil { - response.BadRequest(c, err.Error()) - return - } - } - - // Update payment configuration (integrated into system settings). - // Skip if no payment fields were provided (prevents accidental wipe). - if h.paymentConfigService != nil && hasPaymentFields(req) { - paymentReq := service.UpdatePaymentConfigRequest{ - Enabled: req.PaymentEnabled, - MinAmount: req.PaymentMinAmount, - MaxAmount: req.PaymentMaxAmount, - DailyLimit: req.PaymentDailyLimit, - OrderTimeoutMin: req.PaymentOrderTimeoutMin, - MaxPendingOrders: req.PaymentMaxPendingOrders, - EnabledTypes: req.PaymentEnabledTypes, - BalanceDisabled: req.PaymentBalanceDisabled, - BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier, - SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate, - RechargeFeeRate: req.PaymentRechargeFeeRate, - LoadBalanceStrategy: req.PaymentLoadBalanceStrat, - ProductNamePrefix: req.PaymentProductNamePrefix, - ProductNameSuffix: req.PaymentProductNameSuffix, - HelpImageURL: req.PaymentHelpImageURL, - HelpText: req.PaymentHelpText, - CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled, - CancelRateLimitMax: req.PaymentCancelRateLimitMax, - CancelRateLimitWindow: req.PaymentCancelRateLimitWindow, - CancelRateLimitUnit: req.PaymentCancelRateLimitUnit, - CancelRateLimitMode: req.PaymentCancelRateLimitMode, - AlipayForceQRCode: req.PaymentAlipayForceQRCode, - } - if err := h.paymentConfigService.UpdatePaymentConfig(c.Request.Context(), paymentReq); err != nil { - response.ErrorFrom(c, err) - return - } - // Refresh in-memory provider registry so config changes take effect immediately - if h.paymentService != nil { - h.paymentService.RefreshProviders(c.Request.Context()) - } - } - - h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, req) - - // 重新获取设置返回 - updatedSettings, err := h.settingService.GetAllSettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - h.ensureDingTalkSyncAttributes(c.Request.Context(), updatedSettings) - updatedAuthSourceDefaults, err := h.settingService.GetAuthSourceDefaultSettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - updatedDefaultSubscriptions := make([]dto.DefaultSubscriptionSetting, 0, len(updatedSettings.DefaultSubscriptions)) - for _, sub := range updatedSettings.DefaultSubscriptions { - updatedDefaultSubscriptions = append(updatedDefaultSubscriptions, dto.DefaultSubscriptionSetting{ - GroupID: sub.GroupID, - ValidityDays: sub.ValidityDays, - }) - } - - // Reload payment config for response - var updatedPaymentCfg *service.PaymentConfig - if h.paymentConfigService != nil { - updatedPaymentCfg, _ = h.paymentConfigService.GetPaymentConfig(c.Request.Context()) - } - if updatedPaymentCfg == nil { - updatedPaymentCfg = &service.PaymentConfig{} - } - - payload := dto.SystemSettings{ - RegistrationEnabled: updatedSettings.RegistrationEnabled, - EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, - RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: updatedSettings.PromoCodeEnabled, - PasswordResetEnabled: updatedSettings.PasswordResetEnabled, - FrontendURL: updatedSettings.FrontendURL, - InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, - TotpEnabled: updatedSettings.TotpEnabled, - TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), - LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled, - LoginAgreementMode: updatedSettings.LoginAgreementMode, - LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt, - LoginAgreementDocuments: loginAgreementDocumentsToDTO(updatedSettings.LoginAgreementDocuments), - SMTPHost: updatedSettings.SMTPHost, - SMTPPort: updatedSettings.SMTPPort, - SMTPUsername: updatedSettings.SMTPUsername, - SMTPPasswordConfigured: updatedSettings.SMTPPasswordConfigured, - SMTPFrom: updatedSettings.SMTPFrom, - SMTPFromName: updatedSettings.SMTPFromName, - SMTPUseTLS: updatedSettings.SMTPUseTLS, - TurnstileEnabled: updatedSettings.TurnstileEnabled, - TurnstileSiteKey: updatedSettings.TurnstileSiteKey, - TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured, - APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP, - LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled, - LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID, - LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured, - LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL, - DingTalkConnectEnabled: updatedSettings.DingTalkConnectEnabled, - DingTalkConnectClientID: updatedSettings.DingTalkConnectClientID, - DingTalkConnectClientSecretConfigured: updatedSettings.DingTalkConnectClientSecretConfigured, - DingTalkConnectRedirectURL: updatedSettings.DingTalkConnectRedirectURL, - DingTalkConnectCorpRestrictionPolicy: updatedSettings.DingTalkConnectCorpRestrictionPolicy, - DingTalkConnectInternalCorpID: updatedSettings.DingTalkConnectInternalCorpID, - DingTalkConnectBypassRegistration: updatedSettings.DingTalkConnectBypassRegistration, - DingTalkConnectSyncCorpEmail: updatedSettings.DingTalkConnectSyncCorpEmail, - DingTalkConnectSyncDisplayName: updatedSettings.DingTalkConnectSyncDisplayName, - DingTalkConnectSyncDept: updatedSettings.DingTalkConnectSyncDept, - DingTalkConnectSyncCorpEmailAttrKey: updatedSettings.DingTalkConnectSyncCorpEmailAttrKey, - DingTalkConnectSyncDisplayNameAttrKey: updatedSettings.DingTalkConnectSyncDisplayNameAttrKey, - DingTalkConnectSyncDeptAttrKey: updatedSettings.DingTalkConnectSyncDeptAttrKey, - DingTalkConnectSyncCorpEmailAttrName: updatedSettings.DingTalkConnectSyncCorpEmailAttrName, - DingTalkConnectSyncDisplayNameAttrName: updatedSettings.DingTalkConnectSyncDisplayNameAttrName, - DingTalkConnectSyncDeptAttrName: updatedSettings.DingTalkConnectSyncDeptAttrName, - WeChatConnectEnabled: updatedSettings.WeChatConnectEnabled, - WeChatConnectAppID: updatedSettings.WeChatConnectAppID, - WeChatConnectAppSecretConfigured: updatedSettings.WeChatConnectAppSecretConfigured, - WeChatConnectOpenAppID: updatedSettings.WeChatConnectOpenAppID, - WeChatConnectOpenAppSecretConfigured: updatedSettings.WeChatConnectOpenAppSecretConfigured, - WeChatConnectMPAppID: updatedSettings.WeChatConnectMPAppID, - WeChatConnectMPAppSecretConfigured: updatedSettings.WeChatConnectMPAppSecretConfigured, - WeChatConnectMobileAppID: updatedSettings.WeChatConnectMobileAppID, - WeChatConnectMobileAppSecretConfigured: updatedSettings.WeChatConnectMobileAppSecretConfigured, - WeChatConnectOpenEnabled: updatedSettings.WeChatConnectOpenEnabled, - WeChatConnectMPEnabled: updatedSettings.WeChatConnectMPEnabled, - WeChatConnectMobileEnabled: updatedSettings.WeChatConnectMobileEnabled, - WeChatConnectMode: updatedSettings.WeChatConnectMode, - WeChatConnectScopes: updatedSettings.WeChatConnectScopes, - WeChatConnectRedirectURL: updatedSettings.WeChatConnectRedirectURL, - WeChatConnectFrontendRedirectURL: updatedSettings.WeChatConnectFrontendRedirectURL, - OIDCConnectEnabled: updatedSettings.OIDCConnectEnabled, - OIDCConnectProviderName: updatedSettings.OIDCConnectProviderName, - OIDCConnectClientID: updatedSettings.OIDCConnectClientID, - OIDCConnectClientSecretConfigured: updatedSettings.OIDCConnectClientSecretConfigured, - OIDCConnectIssuerURL: updatedSettings.OIDCConnectIssuerURL, - OIDCConnectDiscoveryURL: updatedSettings.OIDCConnectDiscoveryURL, - OIDCConnectAuthorizeURL: updatedSettings.OIDCConnectAuthorizeURL, - OIDCConnectTokenURL: updatedSettings.OIDCConnectTokenURL, - OIDCConnectUserInfoURL: updatedSettings.OIDCConnectUserInfoURL, - OIDCConnectJWKSURL: updatedSettings.OIDCConnectJWKSURL, - OIDCConnectScopes: updatedSettings.OIDCConnectScopes, - OIDCConnectRedirectURL: updatedSettings.OIDCConnectRedirectURL, - OIDCConnectFrontendRedirectURL: updatedSettings.OIDCConnectFrontendRedirectURL, - OIDCConnectTokenAuthMethod: updatedSettings.OIDCConnectTokenAuthMethod, - OIDCConnectUsePKCE: updatedSettings.OIDCConnectUsePKCE, - OIDCConnectValidateIDToken: updatedSettings.OIDCConnectValidateIDToken, - OIDCConnectAllowedSigningAlgs: updatedSettings.OIDCConnectAllowedSigningAlgs, - OIDCConnectClockSkewSeconds: updatedSettings.OIDCConnectClockSkewSeconds, - OIDCConnectRequireEmailVerified: updatedSettings.OIDCConnectRequireEmailVerified, - OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath, - OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath, - OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath, - GitHubOAuthEnabled: updatedSettings.GitHubOAuthEnabled, - GitHubOAuthClientID: updatedSettings.GitHubOAuthClientID, - GitHubOAuthClientSecretConfigured: updatedSettings.GitHubOAuthClientSecretConfigured, - GitHubOAuthRedirectURL: updatedSettings.GitHubOAuthRedirectURL, - GitHubOAuthFrontendRedirectURL: updatedSettings.GitHubOAuthFrontendRedirectURL, - GoogleOAuthEnabled: updatedSettings.GoogleOAuthEnabled, - GoogleOAuthClientID: updatedSettings.GoogleOAuthClientID, - GoogleOAuthClientSecretConfigured: updatedSettings.GoogleOAuthClientSecretConfigured, - GoogleOAuthRedirectURL: updatedSettings.GoogleOAuthRedirectURL, - GoogleOAuthFrontendRedirectURL: updatedSettings.GoogleOAuthFrontendRedirectURL, - SiteName: updatedSettings.SiteName, - SiteLogo: updatedSettings.SiteLogo, - SiteSubtitle: updatedSettings.SiteSubtitle, - APIBaseURL: updatedSettings.APIBaseURL, - ContactInfo: updatedSettings.ContactInfo, - DocURL: updatedSettings.DocURL, - HomeContent: updatedSettings.HomeContent, - HideCcsImportButton: updatedSettings.HideCcsImportButton, - PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled, - PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL, - TableDefaultPageSize: updatedSettings.TableDefaultPageSize, - TablePageSizeOptions: updatedSettings.TablePageSizeOptions, - CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems), - CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints), - DefaultConcurrency: updatedSettings.DefaultConcurrency, - DefaultBalance: updatedSettings.DefaultBalance, - AffiliateRebateRate: updatedSettings.AffiliateRebateRate, - AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours, - AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays, - AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap, - DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit, - DefaultSubscriptions: updatedDefaultSubscriptions, - EnableModelFallback: updatedSettings.EnableModelFallback, - FallbackModelAnthropic: updatedSettings.FallbackModelAnthropic, - FallbackModelOpenAI: updatedSettings.FallbackModelOpenAI, - FallbackModelGemini: updatedSettings.FallbackModelGemini, - FallbackModelAntigravity: updatedSettings.FallbackModelAntigravity, - EnableIdentityPatch: updatedSettings.EnableIdentityPatch, - IdentityPatchPrompt: updatedSettings.IdentityPatchPrompt, - OpsMonitoringEnabled: updatedSettings.OpsMonitoringEnabled, - OpsRealtimeMonitoringEnabled: updatedSettings.OpsRealtimeMonitoringEnabled, - OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault, - OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds, - MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion, - MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion, - AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling, - BackendModeEnabled: updatedSettings.BackendModeEnabled, - EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification, - EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough, - EnableCCHSigning: updatedSettings.EnableCCHSigning, - EnableClaudeOAuthSystemPromptInjection: updatedSettings.EnableClaudeOAuthSystemPromptInjection, - ClaudeOAuthSystemPrompt: updatedSettings.ClaudeOAuthSystemPrompt, - ClaudeOAuthSystemPromptBlocks: updatedSettings.ClaudeOAuthSystemPromptBlocks, - EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection, - RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl, - EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization, - AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion, - OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent, - MinCodexVersion: updatedSettings.MinCodexVersion, - MaxCodexVersion: updatedSettings.MaxCodexVersion, - CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist, - CodexCLIOnlyWhitelist: updatedSettings.CodexCLIOnlyWhitelist, - CodexCLIOnlyAllowAppServerClients: updatedSettings.CodexCLIOnlyAllowAppServerClients, - CodexCLIOnlyEngineFingerprintSignals: updatedSettings.CodexCLIOnlyEngineFingerprintSignals, - PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource, - PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource, - PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled, - PaymentVisibleMethodWxpayEnabled: updatedSettings.PaymentVisibleMethodWxpayEnabled, - OpenAIAdvancedSchedulerEnabled: updatedSettings.OpenAIAdvancedSchedulerEnabled, - OpenAIAdvancedSchedulerStickyWeightedEnabled: updatedSettings.OpenAIAdvancedSchedulerStickyWeightedEnabled, - OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: updatedSettings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled, - OpenAIAdvancedSchedulerLBTopK: updatedSettings.OpenAIAdvancedSchedulerLBTopK, - OpenAIAdvancedSchedulerWeightPriority: updatedSettings.OpenAIAdvancedSchedulerWeightPriority, - OpenAIAdvancedSchedulerWeightLoad: updatedSettings.OpenAIAdvancedSchedulerWeightLoad, - OpenAIAdvancedSchedulerWeightQueue: updatedSettings.OpenAIAdvancedSchedulerWeightQueue, - OpenAIAdvancedSchedulerWeightErrorRate: updatedSettings.OpenAIAdvancedSchedulerWeightErrorRate, - OpenAIAdvancedSchedulerWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerWeightTTFT, - OpenAIAdvancedSchedulerWeightReset: updatedSettings.OpenAIAdvancedSchedulerWeightReset, - OpenAIAdvancedSchedulerWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, - OpenAIAdvancedSchedulerWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerWeightPreviousResponse, - OpenAIAdvancedSchedulerWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerWeightSessionSticky, - OpenAIAdvancedSchedulerEffectiveLBTopK: updatedSettings.OpenAIAdvancedSchedulerEffectiveLBTopK, - OpenAIAdvancedSchedulerEffectiveWeightPriority: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightPriority, - OpenAIAdvancedSchedulerEffectiveWeightLoad: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightLoad, - OpenAIAdvancedSchedulerEffectiveWeightQueue: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightQueue, - OpenAIAdvancedSchedulerEffectiveWeightErrorRate: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightErrorRate, - OpenAIAdvancedSchedulerEffectiveWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightTTFT, - OpenAIAdvancedSchedulerEffectiveWeightReset: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightReset, - OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom, - OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse, - OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky, - BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled, - BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: updatedSettings.BalanceLowNotifyRechargeURL, - SubscriptionExpiryNotifyEnabled: updatedSettings.SubscriptionExpiryNotifyEnabled, - AccountQuotaNotifyEnabled: updatedSettings.AccountQuotaNotifyEnabled, - AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails), - PaymentEnabled: updatedPaymentCfg.Enabled, - PaymentMinAmount: updatedPaymentCfg.MinAmount, - PaymentMaxAmount: updatedPaymentCfg.MaxAmount, - PaymentDailyLimit: updatedPaymentCfg.DailyLimit, - PaymentOrderTimeoutMin: updatedPaymentCfg.OrderTimeoutMin, - PaymentMaxPendingOrders: updatedPaymentCfg.MaxPendingOrders, - PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes, - PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled, - PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier, - PaymentSubscriptionUSDToCNYRate: updatedPaymentCfg.SubscriptionUSDToCNYRate, - PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate, - PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy, - PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix, - PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix, - PaymentHelpImageURL: updatedPaymentCfg.HelpImageURL, - PaymentHelpText: updatedPaymentCfg.HelpText, - PaymentCancelRateLimitEnabled: updatedPaymentCfg.CancelRateLimitEnabled, - PaymentCancelRateLimitMax: updatedPaymentCfg.CancelRateLimitMax, - PaymentCancelRateLimitWindow: updatedPaymentCfg.CancelRateLimitWindow, - PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit, - PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode, - PaymentAlipayForceQRCode: updatedPaymentCfg.AlipayForceQRCode, - - ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled, - ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds, - - AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled, - - AffiliateEnabled: updatedSettings.AffiliateEnabled, - - RiskControlEnabled: updatedSettings.RiskControlEnabled, - CyberSessionBlockEnabled: updatedSettings.CyberSessionBlockEnabled, - CyberSessionBlockTTLSeconds: updatedSettings.CyberSessionBlockTTLSeconds, - AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests, - } - if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil { - slog.Error("openai_fast_policy_settings_get_failed", "error", err) - } else if fastPolicy != nil { - payload.OpenAIFastPolicySettings = openaiFastPolicySettingsToDTO(fastPolicy) - } - - // Default platform quotas(JSON map)—— 与 GetSettings 一致,避免保存后响应缺失该字段 - if platformQuotas, err := h.settingService.GetDefaultPlatformQuotas(c.Request.Context()); err != nil { - slog.Error("default_platform_quotas_get_failed", "error", err) - } else { - payload.DefaultPlatformQuotas = platformQuotas - } - response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults)) -} - -// hasPaymentFields returns true if any payment-related field was explicitly provided. -// mapDingTalkValidateError maps ValidateDingTalkConfig errors to machine-readable reason codes. -func mapDingTalkValidateError(err error) string { - switch { - case errors.Is(err, config.ErrDingTalkV1AppTypeMismatch): - return "dingtalk_apptype_mismatch" - case errors.Is(err, config.ErrDingTalkV4InvalidAppKind): - return "dingtalk_app_kind_invalid" - default: - return "dingtalk_corp_config_invalid" - } -} - -func hasPaymentFields(req UpdateSettingsRequest) bool { - return req.PaymentEnabled != nil || req.PaymentMinAmount != nil || - req.PaymentMaxAmount != nil || req.PaymentDailyLimit != nil || - req.PaymentOrderTimeoutMin != nil || req.PaymentMaxPendingOrders != nil || - req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil || - req.PaymentBalanceRechargeMultiplier != nil || req.PaymentSubscriptionUSDToCNYRate != nil || - req.PaymentRechargeFeeRate != nil || - req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil || - req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil || - req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil || - req.PaymentCancelRateLimitMax != nil || req.PaymentCancelRateLimitWindow != nil || - req.PaymentCancelRateLimitUnit != nil || req.PaymentCancelRateLimitMode != nil || - req.PaymentAlipayForceQRCode != nil -} - -func (h *SettingHandler) auditSettingsUpdate(c *gin.Context, before *service.SystemSettings, after *service.SystemSettings, beforeAuthSourceDefaults *service.AuthSourceDefaultSettings, afterAuthSourceDefaults *service.AuthSourceDefaultSettings, req UpdateSettingsRequest) { - if before == nil || after == nil { - return - } - - changed := diffSettings(before, after, beforeAuthSourceDefaults, afterAuthSourceDefaults, req) - if len(changed) == 0 { - return - } - - subject, _ := middleware.GetAuthSubjectFromContext(c) - role, _ := middleware.GetUserRoleFromContext(c) - slog.Info("settings updated", - "audit", true, - "user_id", subject.UserID, - "role", role, - "changed", changed, - ) -} - -func diffSettings(before *service.SystemSettings, after *service.SystemSettings, beforeAuthSourceDefaults *service.AuthSourceDefaultSettings, afterAuthSourceDefaults *service.AuthSourceDefaultSettings, req UpdateSettingsRequest) []string { - changed := make([]string, 0, 20) - if before.RegistrationEnabled != after.RegistrationEnabled { - changed = append(changed, "registration_enabled") - } - if before.EmailVerifyEnabled != after.EmailVerifyEnabled { - changed = append(changed, "email_verify_enabled") - } - if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) { - changed = append(changed, "registration_email_suffix_whitelist") - } - if before.PromoCodeEnabled != after.PromoCodeEnabled { - changed = append(changed, "promo_code_enabled") - } - if before.InvitationCodeEnabled != after.InvitationCodeEnabled { - changed = append(changed, "invitation_code_enabled") - } - if before.PasswordResetEnabled != after.PasswordResetEnabled { - changed = append(changed, "password_reset_enabled") - } - if before.FrontendURL != after.FrontendURL { - changed = append(changed, "frontend_url") - } - if before.TotpEnabled != after.TotpEnabled { - changed = append(changed, "totp_enabled") - } - if before.LoginAgreementEnabled != after.LoginAgreementEnabled { - changed = append(changed, "login_agreement_enabled") - } - if before.LoginAgreementMode != after.LoginAgreementMode { - changed = append(changed, "login_agreement_mode") - } - if before.LoginAgreementUpdatedAt != after.LoginAgreementUpdatedAt { - changed = append(changed, "login_agreement_updated_at") - } - if !equalLoginAgreementDocuments(before.LoginAgreementDocuments, after.LoginAgreementDocuments) { - changed = append(changed, "login_agreement_documents") - } - if before.SMTPHost != after.SMTPHost { - changed = append(changed, "smtp_host") - } - if before.SMTPPort != after.SMTPPort { - changed = append(changed, "smtp_port") - } - if before.SMTPUsername != after.SMTPUsername { - changed = append(changed, "smtp_username") - } - if req.SMTPPassword != "" { - changed = append(changed, "smtp_password") - } - if before.SMTPFrom != after.SMTPFrom { - changed = append(changed, "smtp_from_email") - } - if before.SMTPFromName != after.SMTPFromName { - changed = append(changed, "smtp_from_name") - } - if before.SMTPUseTLS != after.SMTPUseTLS { - changed = append(changed, "smtp_use_tls") - } - if before.TurnstileEnabled != after.TurnstileEnabled { - changed = append(changed, "turnstile_enabled") - } - if before.TurnstileSiteKey != after.TurnstileSiteKey { - changed = append(changed, "turnstile_site_key") - } - if req.TurnstileSecretKey != "" { - changed = append(changed, "turnstile_secret_key") - } - if before.APIKeyACLTrustForwardedIP != after.APIKeyACLTrustForwardedIP { - changed = append(changed, "api_key_acl_trust_forwarded_ip") - } - if before.LinuxDoConnectEnabled != after.LinuxDoConnectEnabled { - changed = append(changed, "linuxdo_connect_enabled") - } - if before.LinuxDoConnectClientID != after.LinuxDoConnectClientID { - changed = append(changed, "linuxdo_connect_client_id") - } - if req.LinuxDoConnectClientSecret != "" { - changed = append(changed, "linuxdo_connect_client_secret") - } - if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL { - changed = append(changed, "linuxdo_connect_redirect_url") - } - if before.DingTalkConnectEnabled != after.DingTalkConnectEnabled { - changed = append(changed, "dingtalk_connect_enabled") - } - if before.DingTalkConnectClientID != after.DingTalkConnectClientID { - changed = append(changed, "dingtalk_connect_client_id") - } - if req.DingTalkConnectClientSecret != "" { - changed = append(changed, "dingtalk_connect_client_secret") - } - if before.DingTalkConnectRedirectURL != after.DingTalkConnectRedirectURL { - changed = append(changed, "dingtalk_connect_redirect_url") - } - if before.DingTalkConnectCorpRestrictionPolicy != after.DingTalkConnectCorpRestrictionPolicy { - changed = append(changed, "dingtalk_connect_corp_restriction_policy") - } - if before.DingTalkConnectInternalCorpID != after.DingTalkConnectInternalCorpID { - changed = append(changed, "dingtalk_connect_internal_corp_id") - } - if before.DingTalkConnectBypassRegistration != after.DingTalkConnectBypassRegistration { - changed = append(changed, "dingtalk_connect_bypass_registration") - } - if before.DingTalkConnectSyncCorpEmail != after.DingTalkConnectSyncCorpEmail { - changed = append(changed, "dingtalk_connect_sync_corp_email") - } - if before.DingTalkConnectSyncDisplayName != after.DingTalkConnectSyncDisplayName { - changed = append(changed, "dingtalk_connect_sync_display_name") - } - if before.DingTalkConnectSyncDept != after.DingTalkConnectSyncDept { - changed = append(changed, "dingtalk_connect_sync_dept") - } - if before.DingTalkConnectSyncCorpEmailAttrKey != after.DingTalkConnectSyncCorpEmailAttrKey { - changed = append(changed, "dingtalk_connect_sync_corp_email_attr_key") - } - if before.DingTalkConnectSyncDisplayNameAttrKey != after.DingTalkConnectSyncDisplayNameAttrKey { - changed = append(changed, "dingtalk_connect_sync_display_name_attr_key") - } - if before.DingTalkConnectSyncDeptAttrKey != after.DingTalkConnectSyncDeptAttrKey { - changed = append(changed, "dingtalk_connect_sync_dept_attr_key") - } - if before.WeChatConnectEnabled != after.WeChatConnectEnabled { - changed = append(changed, "wechat_connect_enabled") - } - if before.WeChatConnectAppID != after.WeChatConnectAppID { - changed = append(changed, "wechat_connect_app_id") - } - if req.WeChatConnectAppSecret != "" { - changed = append(changed, "wechat_connect_app_secret") - } - if before.WeChatConnectOpenAppID != after.WeChatConnectOpenAppID { - changed = append(changed, "wechat_connect_open_app_id") - } - if req.WeChatConnectOpenAppSecret != "" { - changed = append(changed, "wechat_connect_open_app_secret") - } - if before.WeChatConnectMPAppID != after.WeChatConnectMPAppID { - changed = append(changed, "wechat_connect_mp_app_id") - } - if req.WeChatConnectMPAppSecret != "" { - changed = append(changed, "wechat_connect_mp_app_secret") - } - if before.WeChatConnectMobileAppID != after.WeChatConnectMobileAppID { - changed = append(changed, "wechat_connect_mobile_app_id") - } - if req.WeChatConnectMobileAppSecret != "" { - changed = append(changed, "wechat_connect_mobile_app_secret") - } - if before.WeChatConnectOpenEnabled != after.WeChatConnectOpenEnabled { - changed = append(changed, "wechat_connect_open_enabled") - } - if before.WeChatConnectMPEnabled != after.WeChatConnectMPEnabled { - changed = append(changed, "wechat_connect_mp_enabled") - } - if before.WeChatConnectMobileEnabled != after.WeChatConnectMobileEnabled { - changed = append(changed, "wechat_connect_mobile_enabled") - } - if before.WeChatConnectMode != after.WeChatConnectMode { - changed = append(changed, "wechat_connect_mode") - } - if before.WeChatConnectScopes != after.WeChatConnectScopes { - changed = append(changed, "wechat_connect_scopes") - } - if before.WeChatConnectRedirectURL != after.WeChatConnectRedirectURL { - changed = append(changed, "wechat_connect_redirect_url") - } - if before.WeChatConnectFrontendRedirectURL != after.WeChatConnectFrontendRedirectURL { - changed = append(changed, "wechat_connect_frontend_redirect_url") - } - if before.OIDCConnectEnabled != after.OIDCConnectEnabled { - changed = append(changed, "oidc_connect_enabled") - } - if before.OIDCConnectProviderName != after.OIDCConnectProviderName { - changed = append(changed, "oidc_connect_provider_name") - } - if before.OIDCConnectClientID != after.OIDCConnectClientID { - changed = append(changed, "oidc_connect_client_id") - } - if req.OIDCConnectClientSecret != "" { - changed = append(changed, "oidc_connect_client_secret") - } - if before.OIDCConnectIssuerURL != after.OIDCConnectIssuerURL { - changed = append(changed, "oidc_connect_issuer_url") - } - if before.OIDCConnectDiscoveryURL != after.OIDCConnectDiscoveryURL { - changed = append(changed, "oidc_connect_discovery_url") - } - if before.OIDCConnectAuthorizeURL != after.OIDCConnectAuthorizeURL { - changed = append(changed, "oidc_connect_authorize_url") - } - if before.OIDCConnectTokenURL != after.OIDCConnectTokenURL { - changed = append(changed, "oidc_connect_token_url") - } - if before.OIDCConnectUserInfoURL != after.OIDCConnectUserInfoURL { - changed = append(changed, "oidc_connect_userinfo_url") - } - if before.OIDCConnectJWKSURL != after.OIDCConnectJWKSURL { - changed = append(changed, "oidc_connect_jwks_url") - } - if before.OIDCConnectScopes != after.OIDCConnectScopes { - changed = append(changed, "oidc_connect_scopes") - } - if before.OIDCConnectRedirectURL != after.OIDCConnectRedirectURL { - changed = append(changed, "oidc_connect_redirect_url") - } - if before.OIDCConnectFrontendRedirectURL != after.OIDCConnectFrontendRedirectURL { - changed = append(changed, "oidc_connect_frontend_redirect_url") - } - if before.OIDCConnectTokenAuthMethod != after.OIDCConnectTokenAuthMethod { - changed = append(changed, "oidc_connect_token_auth_method") - } - if before.OIDCConnectUsePKCE != after.OIDCConnectUsePKCE { - changed = append(changed, "oidc_connect_use_pkce") - } - if before.OIDCConnectValidateIDToken != after.OIDCConnectValidateIDToken { - changed = append(changed, "oidc_connect_validate_id_token") - } - if before.OIDCConnectAllowedSigningAlgs != after.OIDCConnectAllowedSigningAlgs { - changed = append(changed, "oidc_connect_allowed_signing_algs") - } - if before.OIDCConnectClockSkewSeconds != after.OIDCConnectClockSkewSeconds { - changed = append(changed, "oidc_connect_clock_skew_seconds") - } - if before.OIDCConnectRequireEmailVerified != after.OIDCConnectRequireEmailVerified { - changed = append(changed, "oidc_connect_require_email_verified") - } - if before.OIDCConnectUserInfoEmailPath != after.OIDCConnectUserInfoEmailPath { - changed = append(changed, "oidc_connect_userinfo_email_path") - } - if before.OIDCConnectUserInfoIDPath != after.OIDCConnectUserInfoIDPath { - changed = append(changed, "oidc_connect_userinfo_id_path") - } - if before.OIDCConnectUserInfoUsernamePath != after.OIDCConnectUserInfoUsernamePath { - changed = append(changed, "oidc_connect_userinfo_username_path") - } - if before.SiteName != after.SiteName { - changed = append(changed, "site_name") - } - if before.SiteLogo != after.SiteLogo { - changed = append(changed, "site_logo") - } - if before.SiteSubtitle != after.SiteSubtitle { - changed = append(changed, "site_subtitle") - } - if before.APIBaseURL != after.APIBaseURL { - changed = append(changed, "api_base_url") - } - if before.ContactInfo != after.ContactInfo { - changed = append(changed, "contact_info") - } - if before.DocURL != after.DocURL { - changed = append(changed, "doc_url") - } - if before.HomeContent != after.HomeContent { - changed = append(changed, "home_content") - } - if before.HideCcsImportButton != after.HideCcsImportButton { - changed = append(changed, "hide_ccs_import_button") - } - if before.DefaultConcurrency != after.DefaultConcurrency { - changed = append(changed, "default_concurrency") - } - if before.DefaultBalance != after.DefaultBalance { - changed = append(changed, "default_balance") - } - if before.AffiliateRebateRate != after.AffiliateRebateRate { - changed = append(changed, "affiliate_rebate_rate") - } - if before.AffiliateRebateFreezeHours != after.AffiliateRebateFreezeHours { - changed = append(changed, "affiliate_rebate_freeze_hours") - } - if before.AffiliateRebateDurationDays != after.AffiliateRebateDurationDays { - changed = append(changed, "affiliate_rebate_duration_days") - } - if before.AffiliateRebatePerInviteeCap != after.AffiliateRebatePerInviteeCap { - changed = append(changed, "affiliate_rebate_per_invitee_cap") - } - if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) { - changed = append(changed, "default_subscriptions") - } - if before.EnableModelFallback != after.EnableModelFallback { - changed = append(changed, "enable_model_fallback") - } - if before.FallbackModelAnthropic != after.FallbackModelAnthropic { - changed = append(changed, "fallback_model_anthropic") - } - if before.FallbackModelOpenAI != after.FallbackModelOpenAI { - changed = append(changed, "fallback_model_openai") - } - if before.FallbackModelGemini != after.FallbackModelGemini { - changed = append(changed, "fallback_model_gemini") - } - if before.FallbackModelAntigravity != after.FallbackModelAntigravity { - changed = append(changed, "fallback_model_antigravity") - } - if before.EnableIdentityPatch != after.EnableIdentityPatch { - changed = append(changed, "enable_identity_patch") - } - if before.IdentityPatchPrompt != after.IdentityPatchPrompt { - changed = append(changed, "identity_patch_prompt") - } - if before.OpsMonitoringEnabled != after.OpsMonitoringEnabled { - changed = append(changed, "ops_monitoring_enabled") - } - if before.OpsRealtimeMonitoringEnabled != after.OpsRealtimeMonitoringEnabled { - changed = append(changed, "ops_realtime_monitoring_enabled") - } - if before.OpsQueryModeDefault != after.OpsQueryModeDefault { - changed = append(changed, "ops_query_mode_default") - } - if before.OpsMetricsIntervalSeconds != after.OpsMetricsIntervalSeconds { - changed = append(changed, "ops_metrics_interval_seconds") - } - if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion { - changed = append(changed, "min_claude_code_version") - } - if before.MaxClaudeCodeVersion != after.MaxClaudeCodeVersion { - changed = append(changed, "max_claude_code_version") - } - if before.MinCodexVersion != after.MinCodexVersion { - changed = append(changed, "min_codex_version") - } - if before.MaxCodexVersion != after.MaxCodexVersion { - changed = append(changed, "max_codex_version") - } - if before.CodexCLIOnlyAllowAppServerClients != after.CodexCLIOnlyAllowAppServerClients { - changed = append(changed, "codex_cli_only_allow_app_server_clients") - } - if before.CodexCLIOnlyEngineFingerprintSignals != after.CodexCLIOnlyEngineFingerprintSignals { - changed = append(changed, "codex_cli_only_engine_fingerprint_signals") - } - if before.CodexCLIOnlyBlacklist != after.CodexCLIOnlyBlacklist { - changed = append(changed, "codex_cli_only_blacklist") - } - if before.CodexCLIOnlyWhitelist != after.CodexCLIOnlyWhitelist { - changed = append(changed, "codex_cli_only_whitelist") - } - if before.AllowUngroupedKeyScheduling != after.AllowUngroupedKeyScheduling { - changed = append(changed, "allow_ungrouped_key_scheduling") - } - if before.BackendModeEnabled != after.BackendModeEnabled { - changed = append(changed, "backend_mode_enabled") - } - if before.PurchaseSubscriptionEnabled != after.PurchaseSubscriptionEnabled { - changed = append(changed, "purchase_subscription_enabled") - } - if before.PurchaseSubscriptionURL != after.PurchaseSubscriptionURL { - changed = append(changed, "purchase_subscription_url") - } - if before.TableDefaultPageSize != after.TableDefaultPageSize { - changed = append(changed, "table_default_page_size") - } - if !equalIntSlice(before.TablePageSizeOptions, after.TablePageSizeOptions) { - changed = append(changed, "table_page_size_options") - } - if before.CustomMenuItems != after.CustomMenuItems { - changed = append(changed, "custom_menu_items") - } - if before.CustomEndpoints != after.CustomEndpoints { - changed = append(changed, "custom_endpoints") - } - if before.EnableFingerprintUnification != after.EnableFingerprintUnification { - changed = append(changed, "enable_fingerprint_unification") - } - if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough { - changed = append(changed, "enable_metadata_passthrough") - } - if before.EnableCCHSigning != after.EnableCCHSigning { - changed = append(changed, "enable_cch_signing") - } - if before.EnableClaudeOAuthSystemPromptInjection != after.EnableClaudeOAuthSystemPromptInjection { - changed = append(changed, "enable_claude_oauth_system_prompt_injection") - } - if before.ClaudeOAuthSystemPrompt != after.ClaudeOAuthSystemPrompt { - changed = append(changed, "claude_oauth_system_prompt") - } - if before.ClaudeOAuthSystemPromptBlocks != after.ClaudeOAuthSystemPromptBlocks { - changed = append(changed, "claude_oauth_system_prompt_blocks") - } - if before.EnableAnthropicCacheTTL1hInjection != after.EnableAnthropicCacheTTL1hInjection { - changed = append(changed, "enable_anthropic_cache_ttl_1h_injection") - } - if before.RewriteMessageCacheControl != after.RewriteMessageCacheControl { - changed = append(changed, "rewrite_message_cache_control") - } - if before.EnableClientDatelineNormalization != after.EnableClientDatelineNormalization { - changed = append(changed, "enable_client_dateline_normalization") - } - if before.AntigravityUserAgentVersion != after.AntigravityUserAgentVersion { - changed = append(changed, "antigravity_user_agent_version") - } - if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent { - changed = append(changed, "openai_codex_user_agent") - } - if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource { - changed = append(changed, "payment_visible_method_alipay_source") - } - if before.PaymentVisibleMethodWxpaySource != after.PaymentVisibleMethodWxpaySource { - changed = append(changed, "payment_visible_method_wxpay_source") - } - if before.PaymentVisibleMethodAlipayEnabled != after.PaymentVisibleMethodAlipayEnabled { - changed = append(changed, "payment_visible_method_alipay_enabled") - } - if before.PaymentVisibleMethodWxpayEnabled != after.PaymentVisibleMethodWxpayEnabled { - changed = append(changed, "payment_visible_method_wxpay_enabled") - } - if before.OpenAIAdvancedSchedulerEnabled != after.OpenAIAdvancedSchedulerEnabled { - changed = append(changed, "openai_advanced_scheduler_enabled") - } - if before.OpenAIAdvancedSchedulerStickyWeightedEnabled != after.OpenAIAdvancedSchedulerStickyWeightedEnabled { - changed = append(changed, "openai_advanced_scheduler_sticky_weighted_enabled") - } - if before.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled != after.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled { - changed = append(changed, "openai_advanced_scheduler_subscription_priority_enabled") - } - if before.OpenAIAdvancedSchedulerLBTopK != after.OpenAIAdvancedSchedulerLBTopK { - changed = append(changed, "openai_advanced_scheduler_lb_top_k") - } - if before.OpenAIAdvancedSchedulerWeightPriority != after.OpenAIAdvancedSchedulerWeightPriority { - changed = append(changed, "openai_advanced_scheduler_weight_priority") - } - if before.OpenAIAdvancedSchedulerWeightLoad != after.OpenAIAdvancedSchedulerWeightLoad { - changed = append(changed, "openai_advanced_scheduler_weight_load") - } - if before.OpenAIAdvancedSchedulerWeightQueue != after.OpenAIAdvancedSchedulerWeightQueue { - changed = append(changed, "openai_advanced_scheduler_weight_queue") - } - if before.OpenAIAdvancedSchedulerWeightErrorRate != after.OpenAIAdvancedSchedulerWeightErrorRate { - changed = append(changed, "openai_advanced_scheduler_weight_error_rate") - } - if before.OpenAIAdvancedSchedulerWeightTTFT != after.OpenAIAdvancedSchedulerWeightTTFT { - changed = append(changed, "openai_advanced_scheduler_weight_ttft") - } - if before.OpenAIAdvancedSchedulerWeightReset != after.OpenAIAdvancedSchedulerWeightReset { - changed = append(changed, "openai_advanced_scheduler_weight_reset") - } - if before.OpenAIAdvancedSchedulerWeightQuotaHeadroom != after.OpenAIAdvancedSchedulerWeightQuotaHeadroom { - changed = append(changed, "openai_advanced_scheduler_weight_quota_headroom") - } - if before.OpenAIAdvancedSchedulerWeightPreviousResponse != after.OpenAIAdvancedSchedulerWeightPreviousResponse { - changed = append(changed, "openai_advanced_scheduler_weight_previous_response") - } - if before.OpenAIAdvancedSchedulerWeightSessionSticky != after.OpenAIAdvancedSchedulerWeightSessionSticky { - changed = append(changed, "openai_advanced_scheduler_weight_session_sticky") - } - // 余额、订阅到期与账号限额通知 - if before.BalanceLowNotifyEnabled != after.BalanceLowNotifyEnabled { - changed = append(changed, "balance_low_notify_enabled") - } - if before.BalanceLowNotifyThreshold != after.BalanceLowNotifyThreshold { - changed = append(changed, "balance_low_notify_threshold") - } - if before.BalanceLowNotifyRechargeURL != after.BalanceLowNotifyRechargeURL { - changed = append(changed, "balance_low_notify_recharge_url") - } - if before.SubscriptionExpiryNotifyEnabled != after.SubscriptionExpiryNotifyEnabled { - changed = append(changed, "subscription_expiry_notify_enabled") - } - if before.AccountQuotaNotifyEnabled != after.AccountQuotaNotifyEnabled { - changed = append(changed, "account_quota_notify_enabled") - } - if !equalNotifyEmailEntries(before.AccountQuotaNotifyEmails, after.AccountQuotaNotifyEmails) { - changed = append(changed, "account_quota_notify_emails") - } - if before.ChannelMonitorEnabled != after.ChannelMonitorEnabled { - changed = append(changed, "channel_monitor_enabled") - } - if before.ChannelMonitorDefaultIntervalSeconds != after.ChannelMonitorDefaultIntervalSeconds { - changed = append(changed, "channel_monitor_default_interval_seconds") - } - if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled { - changed = append(changed, "available_channels_enabled") - } - if before.AffiliateEnabled != after.AffiliateEnabled { - changed = append(changed, "affiliate_enabled") - } - if before.RiskControlEnabled != after.RiskControlEnabled { - changed = append(changed, "risk_control_enabled") - } - if before.CyberSessionBlockEnabled != after.CyberSessionBlockEnabled { - changed = append(changed, "cyber_session_block_enabled") - } - if before.CyberSessionBlockTTLSeconds != after.CyberSessionBlockTTLSeconds { - changed = append(changed, "cyber_session_block_ttl_seconds") - } - // Default platform quotas(JSON map,整体比较) - if !equalPlatformQuotaSettings(before.DefaultPlatformQuotas, after.DefaultPlatformQuotas) { - changed = append(changed, service.SettingKeyDefaultPlatformQuotas) - } - changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults) - return changed -} - -func appendAuthSourceDefaultChanges(changed []string, before *service.AuthSourceDefaultSettings, after *service.AuthSourceDefaultSettings) []string { - if before == nil { - before = &service.AuthSourceDefaultSettings{} - } - if after == nil { - after = &service.AuthSourceDefaultSettings{} - } - - type providerDefaultGrantField struct { - name string - before service.ProviderDefaultGrantSettings - after service.ProviderDefaultGrantSettings - } - - fields := []providerDefaultGrantField{ - {name: "email", before: before.Email, after: after.Email}, - {name: "linuxdo", before: before.LinuxDo, after: after.LinuxDo}, - {name: "oidc", before: before.OIDC, after: after.OIDC}, - {name: "wechat", before: before.WeChat, after: after.WeChat}, - {name: "github", before: before.GitHub, after: after.GitHub}, - {name: "google", before: before.Google, after: after.Google}, - {name: "dingtalk", before: before.DingTalk, after: after.DingTalk}, - } - for _, field := range fields { - if field.before.Balance != field.after.Balance { - changed = append(changed, "auth_source_default_"+field.name+"_balance") - } - if field.before.Concurrency != field.after.Concurrency { - changed = append(changed, "auth_source_default_"+field.name+"_concurrency") - } - if !equalDefaultSubscriptions(field.before.Subscriptions, field.after.Subscriptions) { - changed = append(changed, "auth_source_default_"+field.name+"_subscriptions") - } - if field.before.GrantOnSignup != field.after.GrantOnSignup { - changed = append(changed, "auth_source_default_"+field.name+"_grant_on_signup") - } - if field.before.GrantOnFirstBind != field.after.GrantOnFirstBind { - changed = append(changed, "auth_source_default_"+field.name+"_grant_on_first_bind") - } - // Platform quotas diff:整体替换语义,发单个 JSON key。 - if !equalPlatformQuotaSettings(field.before.PlatformQuotas, field.after.PlatformQuotas) { - changed = append(changed, service.SettingKeyAuthSourcePlatformQuotas(field.name)) - } - } - if before.ForceEmailOnThirdPartySignup != after.ForceEmailOnThirdPartySignup { - changed = append(changed, "force_email_on_third_party_signup") - } - return changed -} - -func normalizeDefaultSubscriptions(input []dto.DefaultSubscriptionSetting) []dto.DefaultSubscriptionSetting { - if len(input) == 0 { - return nil - } - normalized := make([]dto.DefaultSubscriptionSetting, 0, len(input)) - for _, item := range input { - if item.GroupID <= 0 || item.ValidityDays <= 0 { - continue - } - if item.ValidityDays > service.MaxValidityDays { - item.ValidityDays = service.MaxValidityDays - } - normalized = append(normalized, item) - } - return normalized -} - -func normalizeOptionalDefaultSubscriptions(input *[]dto.DefaultSubscriptionSetting) *[]dto.DefaultSubscriptionSetting { - if input == nil { - return nil - } - normalized := normalizeDefaultSubscriptions(*input) - return &normalized -} - -func float64ValueOrDefault(value *float64, fallback float64) float64 { - if value == nil { - return fallback - } - return *value -} - -func intValueOrDefault(value *int, fallback int) int { - if value == nil { - return fallback - } - return *value -} - -func boolValueOrDefault(value *bool, fallback bool) bool { - if value == nil { - return fallback - } - return *value -} - -func defaultSubscriptionsValueOrDefault(input *[]dto.DefaultSubscriptionSetting, fallback []service.DefaultSubscriptionSetting) []service.DefaultSubscriptionSetting { - if input == nil { - return fallback - } - result := make([]service.DefaultSubscriptionSetting, 0, len(*input)) - for _, item := range *input { - result = append(result, service.DefaultSubscriptionSetting{ - GroupID: item.GroupID, - ValidityDays: item.ValidityDays, - }) - } - return result -} - -// platformQuotasValueOrDefault 处理 auth-source platform quota 的 nil 语义: -// nil = 请求未包含该字段(保留 fallback),non-nil(含 empty map)= 整体覆盖。 -// 注意:JSON null 与字段省略等价——两者均反序列化为 nil map,因此都保留旧值; -// 若要清空某 source 的所有 quota 配置,须显式发空对象 {}。 -func platformQuotasValueOrDefault(value, fallback map[string]*service.DefaultPlatformQuotaSetting) map[string]*service.DefaultPlatformQuotaSetting { - if value == nil { - return fallback - } - return value -} - func systemSettingsResponseData(settings dto.SystemSettings, authSourceDefaults *service.AuthSourceDefaultSettings) map[string]any { data := make(map[string]any) raw, err := json.Marshal(settings) @@ -3021,937 +466,3 @@ func systemSettingsResponseData(settings dto.SystemSettings, authSourceDefaults return data } - -func equalStringSlice(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i].GroupID != b[i].GroupID || a[i].ValidityDays != b[i].ValidityDays { - return false - } - } - return true -} - -func equalLoginAgreementDocuments(a, b []service.LoginAgreementDocument) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i].ID != b[i].ID || a[i].Title != b[i].Title || a[i].ContentMD != b[i].ContentMD { - return false - } - } - return true -} - -func equalIntSlice(a, b []int) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func equalNotifyEmailEntries(a, b []service.NotifyEmailEntry) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i].Email != b[i].Email || a[i].Verified != b[i].Verified || a[i].Disabled != b[i].Disabled { - return false - } - } - return true -} - -// TestSMTPRequest 测试SMTP连接请求 -type TestSMTPRequest struct { - SMTPHost string `json:"smtp_host"` - SMTPPort int `json:"smtp_port"` - SMTPUsername string `json:"smtp_username"` - SMTPPassword string `json:"smtp_password"` - SMTPUseTLS bool `json:"smtp_use_tls"` -} - -// TestSMTPConnection 测试SMTP连接 -// POST /api/v1/admin/settings/test-smtp -func (h *SettingHandler) TestSMTPConnection(c *gin.Context) { - var req TestSMTPRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - - req.SMTPHost = strings.TrimSpace(req.SMTPHost) - req.SMTPUsername = strings.TrimSpace(req.SMTPUsername) - - var savedConfig *service.SMTPConfig - if cfg, err := h.emailService.GetSMTPConfig(c.Request.Context()); err == nil && cfg != nil { - savedConfig = cfg - } - - if req.SMTPHost == "" && savedConfig != nil { - req.SMTPHost = savedConfig.Host - } - if req.SMTPPort <= 0 { - if savedConfig != nil && savedConfig.Port > 0 { - req.SMTPPort = savedConfig.Port - } else { - req.SMTPPort = 587 - } - } - if req.SMTPUsername == "" && savedConfig != nil { - req.SMTPUsername = savedConfig.Username - } - password := strings.TrimSpace(req.SMTPPassword) - if password == "" && savedConfig != nil { - password = savedConfig.Password - } - if req.SMTPHost == "" { - response.BadRequest(c, "SMTP host is required") - return - } - - config := &service.SMTPConfig{ - Host: req.SMTPHost, - Port: req.SMTPPort, - Username: req.SMTPUsername, - Password: password, - UseTLS: req.SMTPUseTLS, - } - - err := h.emailService.TestSMTPConnectionWithConfig(config) - if err != nil { - response.BadRequest(c, "SMTP connection test failed: "+err.Error()) - return - } - - response.Success(c, gin.H{"message": "SMTP connection successful"}) -} - -// SendTestEmailRequest 发送测试邮件请求 -type SendTestEmailRequest struct { - Email string `json:"email" binding:"required,email"` - SMTPHost string `json:"smtp_host"` - SMTPPort int `json:"smtp_port"` - SMTPUsername string `json:"smtp_username"` - SMTPPassword string `json:"smtp_password"` - SMTPFrom string `json:"smtp_from_email"` - SMTPFromName string `json:"smtp_from_name"` - SMTPUseTLS bool `json:"smtp_use_tls"` -} - -// SendTestEmail 发送测试邮件 -// POST /api/v1/admin/settings/send-test-email -func (h *SettingHandler) SendTestEmail(c *gin.Context) { - var req SendTestEmailRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - - req.SMTPHost = strings.TrimSpace(req.SMTPHost) - req.SMTPUsername = strings.TrimSpace(req.SMTPUsername) - req.SMTPFrom = strings.TrimSpace(req.SMTPFrom) - req.SMTPFromName = strings.TrimSpace(req.SMTPFromName) - - var savedConfig *service.SMTPConfig - if cfg, err := h.emailService.GetSMTPConfig(c.Request.Context()); err == nil && cfg != nil { - savedConfig = cfg - } - - if req.SMTPHost == "" && savedConfig != nil { - req.SMTPHost = savedConfig.Host - } - if req.SMTPPort <= 0 { - if savedConfig != nil && savedConfig.Port > 0 { - req.SMTPPort = savedConfig.Port - } else { - req.SMTPPort = 587 - } - } - if req.SMTPUsername == "" && savedConfig != nil { - req.SMTPUsername = savedConfig.Username - } - password := strings.TrimSpace(req.SMTPPassword) - if password == "" && savedConfig != nil { - password = savedConfig.Password - } - if req.SMTPFrom == "" && savedConfig != nil { - req.SMTPFrom = savedConfig.From - } - if req.SMTPFromName == "" && savedConfig != nil { - req.SMTPFromName = savedConfig.FromName - } - if req.SMTPHost == "" { - response.BadRequest(c, "SMTP host is required") - return - } - - config := &service.SMTPConfig{ - Host: req.SMTPHost, - Port: req.SMTPPort, - Username: req.SMTPUsername, - Password: password, - From: req.SMTPFrom, - FromName: req.SMTPFromName, - UseTLS: req.SMTPUseTLS, - } - - siteName := h.settingService.GetSiteName(c.Request.Context()) - subject := "[" + siteName + "] Test Email" - body := ` - - - - - - - -
-
-

` + siteName + `

-
-
-
✓
-

Email Configuration Successful!

-

This is a test email to verify your SMTP settings are working correctly.

-
- -
- - -` - - if err := h.emailService.SendEmailWithConfig(config, req.Email, subject, body); err != nil { - response.BadRequest(c, "Failed to send test email: "+err.Error()) - return - } - - response.Success(c, gin.H{"message": "Test email sent successfully"}) -} - -// GetAdminAPIKey 获取管理员 API Key 状态 -// GET /api/v1/admin/settings/admin-api-key -func (h *SettingHandler) GetAdminAPIKey(c *gin.Context) { - maskedKey, exists, err := h.settingService.GetAdminAPIKeyStatus(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - - response.Success(c, gin.H{ - "exists": exists, - "masked_key": maskedKey, - }) -} - -// RegenerateAdminAPIKey 生成/重新生成管理员 API Key -// POST /api/v1/admin/settings/admin-api-key/regenerate -func (h *SettingHandler) RegenerateAdminAPIKey(c *gin.Context) { - key, err := h.settingService.GenerateAdminAPIKey(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - - response.Success(c, gin.H{ - "key": key, // 完整 key 只在生成时返回一次 - }) -} - -// DeleteAdminAPIKey 删除管理员 API Key -// DELETE /api/v1/admin/settings/admin-api-key -func (h *SettingHandler) DeleteAdminAPIKey(c *gin.Context) { - if err := h.settingService.DeleteAdminAPIKey(c.Request.Context()); err != nil { - response.ErrorFrom(c, err) - return - } - - response.Success(c, gin.H{"message": "Admin API key deleted"}) -} - -// GetOverloadCooldownSettings 获取529过载冷却配置 -// GET /api/v1/admin/settings/overload-cooldown -func (h *SettingHandler) GetOverloadCooldownSettings(c *gin.Context) { - settings, err := h.settingService.GetOverloadCooldownSettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - - response.Success(c, dto.OverloadCooldownSettings{ - Enabled: settings.Enabled, - CooldownMinutes: settings.CooldownMinutes, - }) -} - -// UpdateOverloadCooldownSettingsRequest 更新529过载冷却配置请求 -type UpdateOverloadCooldownSettingsRequest struct { - Enabled bool `json:"enabled"` - CooldownMinutes int `json:"cooldown_minutes"` -} - -// UpdateOverloadCooldownSettings 更新529过载冷却配置 -// PUT /api/v1/admin/settings/overload-cooldown -func (h *SettingHandler) UpdateOverloadCooldownSettings(c *gin.Context) { - var req UpdateOverloadCooldownSettingsRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - - settings := &service.OverloadCooldownSettings{ - Enabled: req.Enabled, - CooldownMinutes: req.CooldownMinutes, - } - - if err := h.settingService.SetOverloadCooldownSettings(c.Request.Context(), settings); err != nil { - response.BadRequest(c, err.Error()) - return - } - - updatedSettings, err := h.settingService.GetOverloadCooldownSettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - - response.Success(c, dto.OverloadCooldownSettings{ - Enabled: updatedSettings.Enabled, - CooldownMinutes: updatedSettings.CooldownMinutes, - }) -} - -// GetRateLimit429CooldownSettings 获取429默认回避配置 -// GET /api/v1/admin/settings/rate-limit-429-cooldown -func (h *SettingHandler) GetRateLimit429CooldownSettings(c *gin.Context) { - settings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - - response.Success(c, dto.RateLimit429CooldownSettings{ - Enabled: settings.Enabled, - CooldownSeconds: settings.CooldownSeconds, - }) -} - -// UpdateRateLimit429CooldownSettingsRequest 更新429默认回避配置请求 -type UpdateRateLimit429CooldownSettingsRequest struct { - Enabled bool `json:"enabled"` - CooldownSeconds int `json:"cooldown_seconds"` -} - -// UpdateRateLimit429CooldownSettings 更新429默认回避配置 -// PUT /api/v1/admin/settings/rate-limit-429-cooldown -func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) { - var req UpdateRateLimit429CooldownSettingsRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - - settings := &service.RateLimit429CooldownSettings{ - Enabled: req.Enabled, - CooldownSeconds: req.CooldownSeconds, - } - - if err := h.settingService.SetRateLimit429CooldownSettings(c.Request.Context(), settings); err != nil { - response.BadRequest(c, err.Error()) - return - } - - updatedSettings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - - response.Success(c, dto.RateLimit429CooldownSettings{ - Enabled: updatedSettings.Enabled, - CooldownSeconds: updatedSettings.CooldownSeconds, - }) -} - -// GetStreamTimeoutSettings 获取流超时处理配置 -// GET /api/v1/admin/settings/stream-timeout -func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) { - settings, err := h.settingService.GetStreamTimeoutSettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - - response.Success(c, dto.StreamTimeoutSettings{ - Enabled: settings.Enabled, - Action: settings.Action, - TempUnschedMinutes: settings.TempUnschedMinutes, - ThresholdCount: settings.ThresholdCount, - ThresholdWindowMinutes: settings.ThresholdWindowMinutes, - }) -} - -// GetRectifierSettings 获取请求整流器配置 -// GET /api/v1/admin/settings/rectifier -func (h *SettingHandler) GetRectifierSettings(c *gin.Context) { - settings, err := h.settingService.GetRectifierSettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - - patterns := settings.APIKeySignaturePatterns - if patterns == nil { - patterns = []string{} - } - response.Success(c, dto.RectifierSettings{ - Enabled: settings.Enabled, - ThinkingSignatureEnabled: settings.ThinkingSignatureEnabled, - ThinkingBudgetEnabled: settings.ThinkingBudgetEnabled, - APIKeySignatureEnabled: settings.APIKeySignatureEnabled, - APIKeySignaturePatterns: patterns, - }) -} - -// UpdateRectifierSettingsRequest 更新整流器配置请求 -type UpdateRectifierSettingsRequest struct { - Enabled bool `json:"enabled"` - ThinkingSignatureEnabled bool `json:"thinking_signature_enabled"` - ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"` - APIKeySignatureEnabled bool `json:"apikey_signature_enabled"` - APIKeySignaturePatterns []string `json:"apikey_signature_patterns"` -} - -// UpdateRectifierSettings 更新请求整流器配置 -// PUT /api/v1/admin/settings/rectifier -func (h *SettingHandler) UpdateRectifierSettings(c *gin.Context) { - var req UpdateRectifierSettingsRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - - // 校验并清理自定义匹配关键词 - const maxPatterns = 50 - const maxPatternLen = 500 - if len(req.APIKeySignaturePatterns) > maxPatterns { - response.BadRequest(c, "Too many signature patterns (max 50)") - return - } - var cleanedPatterns []string - for _, p := range req.APIKeySignaturePatterns { - p = strings.TrimSpace(p) - if p == "" { - continue - } - if len(p) > maxPatternLen { - response.BadRequest(c, "Signature pattern too long (max 500 characters)") - return - } - cleanedPatterns = append(cleanedPatterns, p) - } - - settings := &service.RectifierSettings{ - Enabled: req.Enabled, - ThinkingSignatureEnabled: req.ThinkingSignatureEnabled, - ThinkingBudgetEnabled: req.ThinkingBudgetEnabled, - APIKeySignatureEnabled: req.APIKeySignatureEnabled, - APIKeySignaturePatterns: cleanedPatterns, - } - - if err := h.settingService.SetRectifierSettings(c.Request.Context(), settings); err != nil { - response.BadRequest(c, err.Error()) - return - } - - // 重新获取设置返回 - updatedSettings, err := h.settingService.GetRectifierSettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - - updatedPatterns := updatedSettings.APIKeySignaturePatterns - if updatedPatterns == nil { - updatedPatterns = []string{} - } - response.Success(c, dto.RectifierSettings{ - Enabled: updatedSettings.Enabled, - ThinkingSignatureEnabled: updatedSettings.ThinkingSignatureEnabled, - ThinkingBudgetEnabled: updatedSettings.ThinkingBudgetEnabled, - APIKeySignatureEnabled: updatedSettings.APIKeySignatureEnabled, - APIKeySignaturePatterns: updatedPatterns, - }) -} - -// GetBetaPolicySettings 获取 Beta 策略配置 -// GET /api/v1/admin/settings/beta-policy -func (h *SettingHandler) GetBetaPolicySettings(c *gin.Context) { - settings, err := h.settingService.GetBetaPolicySettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - - rules := make([]dto.BetaPolicyRule, len(settings.Rules)) - for i, r := range settings.Rules { - rules[i] = dto.BetaPolicyRule(r) - } - response.Success(c, dto.BetaPolicySettings{Rules: rules}) -} - -// UpdateBetaPolicySettingsRequest 更新 Beta 策略配置请求 -type UpdateBetaPolicySettingsRequest struct { - Rules []dto.BetaPolicyRule `json:"rules"` -} - -// UpdateBetaPolicySettings 更新 Beta 策略配置 -// PUT /api/v1/admin/settings/beta-policy -func (h *SettingHandler) UpdateBetaPolicySettings(c *gin.Context) { - var req UpdateBetaPolicySettingsRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - - rules := make([]service.BetaPolicyRule, len(req.Rules)) - for i, r := range req.Rules { - rules[i] = service.BetaPolicyRule(r) - } - - settings := &service.BetaPolicySettings{Rules: rules} - if err := h.settingService.SetBetaPolicySettings(c.Request.Context(), settings); err != nil { - response.BadRequest(c, err.Error()) - return - } - - // Re-fetch to return updated settings - updated, err := h.settingService.GetBetaPolicySettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - - outRules := make([]dto.BetaPolicyRule, len(updated.Rules)) - for i, r := range updated.Rules { - outRules[i] = dto.BetaPolicyRule(r) - } - response.Success(c, dto.BetaPolicySettings{Rules: outRules}) -} - -// UpdateStreamTimeoutSettingsRequest 更新流超时配置请求 -type UpdateStreamTimeoutSettingsRequest struct { - Enabled bool `json:"enabled"` - Action string `json:"action"` - TempUnschedMinutes int `json:"temp_unsched_minutes"` - ThresholdCount int `json:"threshold_count"` - ThresholdWindowMinutes int `json:"threshold_window_minutes"` -} - -// UpdateStreamTimeoutSettings 更新流超时处理配置 -// PUT /api/v1/admin/settings/stream-timeout -func (h *SettingHandler) UpdateStreamTimeoutSettings(c *gin.Context) { - var req UpdateStreamTimeoutSettingsRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - - settings := &service.StreamTimeoutSettings{ - Enabled: req.Enabled, - Action: req.Action, - TempUnschedMinutes: req.TempUnschedMinutes, - ThresholdCount: req.ThresholdCount, - ThresholdWindowMinutes: req.ThresholdWindowMinutes, - } - - if err := h.settingService.SetStreamTimeoutSettings(c.Request.Context(), settings); err != nil { - response.BadRequest(c, err.Error()) - return - } - - // 重新获取设置返回 - updatedSettings, err := h.settingService.GetStreamTimeoutSettings(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - - response.Success(c, dto.StreamTimeoutSettings{ - Enabled: updatedSettings.Enabled, - Action: updatedSettings.Action, - TempUnschedMinutes: updatedSettings.TempUnschedMinutes, - ThresholdCount: updatedSettings.ThresholdCount, - ThresholdWindowMinutes: updatedSettings.ThresholdWindowMinutes, - }) -} - -// GetWebSearchEmulationConfig 获取 Web Search 模拟配置 -// GET /api/v1/admin/settings/web-search-emulation -func (h *SettingHandler) GetWebSearchEmulationConfig(c *gin.Context) { - cfg, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, service.PopulateWebSearchUsage(c.Request.Context(), cfg)) -} - -// UpdateWebSearchEmulationConfig 更新 Web Search 模拟配置 -// PUT /api/v1/admin/settings/web-search-emulation -func (h *SettingHandler) UpdateWebSearchEmulationConfig(c *gin.Context) { - var cfg service.WebSearchEmulationConfig - if err := c.ShouldBindJSON(&cfg); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - - if err := h.settingService.SaveWebSearchEmulationConfig(c.Request.Context(), &cfg); err != nil { - response.ErrorFrom(c, err) - return - } - - // Re-read (with sanitized api keys) to return current state - updated, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, service.PopulateWebSearchUsage(c.Request.Context(), updated)) -} - -// ResetWebSearchUsage 重置指定 provider 的配额用量 -// POST /api/v1/admin/settings/web-search-emulation/reset-usage -func (h *SettingHandler) ResetWebSearchUsage(c *gin.Context) { - var req struct { - ProviderType string `json:"provider_type"` - } - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - if req.ProviderType == "" { - response.BadRequest(c, "provider_type is required") - return - } - if err := service.ResetWebSearchUsage(c.Request.Context(), req.ProviderType); err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, nil) -} - -// TestWebSearchEmulation 测试 Web Search 搜索 -// POST /api/v1/admin/settings/web-search-emulation/test -func (h *SettingHandler) TestWebSearchEmulation(c *gin.Context) { - var req struct { - Query string `json:"query"` - } - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - if strings.TrimSpace(req.Query) == "" { - req.Query = "搜索今年世界大事件" - } - - result, err := service.TestWebSearch(c.Request.Context(), req.Query) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, result) -} - -// ensureDingTalkSyncAttributes 在保存 settings 后,按 admin 配置的 (attr key, attr name) -// 兜底 upsert 对应 user attribute definition:不存在则创建;存在但 name 不同则更新 name -// (type/options/required 不变)。仅 internal_only + 对应 sync 开关开启时执行。 -// 失败仅记录日志,不阻塞 settings 保存。 -func (h *SettingHandler) ensureDingTalkSyncAttributes(ctx context.Context, settings *service.SystemSettings) { - if h.userAttributeService == nil || settings == nil { - return - } - if settings.DingTalkConnectCorpRestrictionPolicy != "internal_only" { - return - } - if settings.DingTalkConnectSyncDisplayName { - h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncDisplayNameAttrKey, settings.DingTalkConnectSyncDisplayNameAttrName, "钉钉 internal_only 登录时同步的钉钉姓名", service.AttributeTypeText) - } - if settings.DingTalkConnectSyncCorpEmail { - h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncCorpEmailAttrKey, settings.DingTalkConnectSyncCorpEmailAttrName, "钉钉 internal_only 登录时同步的企业邮箱", service.AttributeTypeEmail) - } - if settings.DingTalkConnectSyncDept { - h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncDeptAttrKey, settings.DingTalkConnectSyncDeptAttrName, "钉钉 internal_only 登录时同步的完整部门路径(如:公司/研发部)", service.AttributeTypeText) - } -} - -func (h *SettingHandler) ensureUserAttributeDefinition(ctx context.Context, key, name, description string, attrType service.UserAttributeType) { - key = strings.TrimSpace(key) - if key == "" { - return - } - existing, err := h.userAttributeService.GetDefinitionByKey(ctx, key) - if err == nil && existing != nil { - if strings.TrimSpace(name) != "" && existing.Name != name { - if _, err := h.userAttributeService.UpdateDefinition(ctx, existing.ID, service.UpdateAttributeDefinitionInput{ - Name: &name, - }); err != nil { - slog.Warn("dingtalk: update user attribute definition name failed", "key", key, "err", err.Error()) - return - } - slog.Info("dingtalk: updated user attribute definition name", "key", key, "name", name) - } - return - } - if _, err := h.userAttributeService.CreateDefinition(ctx, service.CreateAttributeDefinitionInput{ - Key: key, - Name: name, - Description: description, - Type: attrType, - Enabled: true, - }); err != nil { - slog.Warn("dingtalk: ensure user attribute definition failed", "key", key, "err", err.Error()) - return - } - slog.Info("dingtalk: created user attribute definition", "key", key, "name", name, "type", attrType) -} - -// ListEmailTemplates returns all editable notification email templates. -// GET /api/v1/admin/settings/email-templates -func (h *SettingHandler) ListEmailTemplates(c *gin.Context) { - if h.notificationEmailService == nil { - response.InternalError(c, "notification email service is not configured") - return - } - events := h.notificationEmailService.ListEventInfos() - templates, err := h.notificationEmailService.ListTemplates(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, dto.EmailTemplateListResponse{ - Events: emailTemplateEventOptionsToDTO(events), - Locales: h.notificationEmailService.SupportedLocales(), - Templates: emailTemplateSummariesToDTO(templates), - Placeholders: emailTemplatePlaceholderUnion(events), - }) -} - -// GetEmailTemplate returns one editable notification email template. -// GET /api/v1/admin/settings/email-templates/:event/:locale -func (h *SettingHandler) GetEmailTemplate(c *gin.Context) { - if h.notificationEmailService == nil { - response.InternalError(c, "notification email service is not configured") - return - } - tmpl, err := h.notificationEmailService.GetTemplate(c.Request.Context(), c.Param("event"), c.Param("locale")) - if err != nil { - response.BadRequest(c, err.Error()) - return - } - response.Success(c, emailTemplateDetailToDTO(tmpl)) -} - -// UpdateEmailTemplate saves an override for one event/locale template. -// PUT /api/v1/admin/settings/email-templates/:event/:locale -func (h *SettingHandler) UpdateEmailTemplate(c *gin.Context) { - if h.notificationEmailService == nil { - response.InternalError(c, "notification email service is not configured") - return - } - var req dto.UpdateEmailTemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - tmpl, err := h.notificationEmailService.UpdateTemplate(c.Request.Context(), c.Param("event"), c.Param("locale"), req.Subject, req.HTML) - if err != nil { - response.BadRequest(c, err.Error()) - return - } - response.Success(c, emailTemplateDetailToDTO(tmpl)) -} - -// RestoreOfficialEmailTemplate removes an override and returns the built-in template. -// POST /api/v1/admin/settings/email-templates/:event/:locale/restore-official -func (h *SettingHandler) RestoreOfficialEmailTemplate(c *gin.Context) { - if h.notificationEmailService == nil { - response.InternalError(c, "notification email service is not configured") - return - } - tmpl, err := h.notificationEmailService.RestoreOfficialTemplate(c.Request.Context(), c.Param("event"), c.Param("locale")) - if err != nil { - response.BadRequest(c, err.Error()) - return - } - response.Success(c, emailTemplateDetailToDTO(tmpl)) -} - -// PreviewEmailTemplate renders a template with safe sample variables without saving it. -// POST /api/v1/admin/settings/email-templates/preview -func (h *SettingHandler) PreviewEmailTemplate(c *gin.Context) { - if h.notificationEmailService == nil { - response.InternalError(c, "notification email service is not configured") - return - } - var req dto.PreviewEmailTemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - preview, err := h.notificationEmailService.PreviewTemplate(c.Request.Context(), service.NotificationEmailPreviewInput{ - Event: req.Event, - Locale: req.Locale, - Subject: req.Subject, - HTML: req.HTML, - Variables: req.Variables, - }) - if err != nil { - response.BadRequest(c, err.Error()) - return - } - response.Success(c, dto.EmailTemplatePreviewResponse{Subject: preview.Subject, HTML: preview.HTML}) -} - -func emailTemplateEventOptionsToDTO(events []service.NotificationEmailEventInfo) []dto.EmailTemplateEventOption { - items := make([]dto.EmailTemplateEventOption, 0, len(events)) - for _, event := range events { - items = append(items, dto.EmailTemplateEventOption{ - Value: event.Event, - Label: event.Label, - Description: event.Description, - Category: event.Category, - Optional: event.Optional, - }) - } - return items -} - -func emailTemplateSummariesToDTO(templates []service.NotificationEmailTemplate) []dto.EmailTemplateSummary { - items := make([]dto.EmailTemplateSummary, 0, len(templates)) - for _, tmpl := range templates { - items = append(items, dto.EmailTemplateSummary{ - Event: tmpl.Event, - Locale: tmpl.Locale, - Subject: tmpl.Subject, - IsCustom: tmpl.IsCustom, - UpdatedAt: emailTemplateUpdatedAt(tmpl), - }) - } - return items -} - -func emailTemplateDetailToDTO(tmpl service.NotificationEmailTemplate) dto.EmailTemplateDetail { - return dto.EmailTemplateDetail{ - Event: tmpl.Event, - Locale: tmpl.Locale, - Subject: tmpl.Subject, - HTML: tmpl.HTML, - IsCustom: tmpl.IsCustom, - UpdatedAt: emailTemplateUpdatedAt(tmpl), - Placeholders: tmpl.Placeholders, - } -} - -func emailTemplateUpdatedAt(tmpl service.NotificationEmailTemplate) string { - if tmpl.UpdatedAt == nil { - return "" - } - return tmpl.UpdatedAt.Format("2006-01-02T15:04:05Z07:00") -} - -func emailTemplatePlaceholderUnion(events []service.NotificationEmailEventInfo) []string { - seen := make(map[string]struct{}) - placeholders := make([]string, 0) - for _, event := range events { - for _, placeholder := range event.Placeholders { - if _, ok := seen[placeholder]; ok { - continue - } - seen[placeholder] = struct{}{} - placeholders = append(placeholders, placeholder) - } - } - return placeholders -} - -// equalNullableFloat compares two *float64 values treating nil as a distinct case. -func equalNullableFloat(a, b *float64) bool { - if a == nil && b == nil { - return true - } - if a == nil || b == nil { - return false - } - return *a == *b -} - -// slotOf returns the *float64 for the given window from a DefaultPlatformQuotaSetting. -func slotOf(s *service.DefaultPlatformQuotaSetting, win string) *float64 { - if s == nil { - return nil - } - switch win { - case "daily": - return s.DailyLimitUSD - case "weekly": - return s.WeeklyLimitUSD - case "monthly": - return s.MonthlyLimitUSD - } - return nil -} - -// equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all allowed slots. -func equalPlatformQuotaSettings(before, after map[string]*service.DefaultPlatformQuotaSetting) bool { - for _, platform := range service.AllowedQuotaPlatforms { - b := before[platform] - a := after[platform] - if !equalNullableFloat(slotOf(b, "daily"), slotOf(a, "daily")) { - return false - } - if !equalNullableFloat(slotOf(b, "weekly"), slotOf(a, "weekly")) { - return false - } - if !equalNullableFloat(slotOf(b, "monthly"), slotOf(a, "monthly")) { - return false - } - } - return true -} - -func stringSetting(value *string, fallback string) string { - if value == nil { - return fallback - } - return *value -} diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go new file mode 100644 index 0000000000..899030d1b9 --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -0,0 +1,758 @@ +package admin + +import ( + "log/slog" + + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +func (h *SettingHandler) auditSettingsUpdate(c *gin.Context, before *service.SystemSettings, after *service.SystemSettings, beforeAuthSourceDefaults *service.AuthSourceDefaultSettings, afterAuthSourceDefaults *service.AuthSourceDefaultSettings, req UpdateSettingsRequest) { + if before == nil || after == nil { + return + } + + changed := diffSettings(before, after, beforeAuthSourceDefaults, afterAuthSourceDefaults, req) + if len(changed) == 0 { + return + } + + subject, _ := middleware.GetAuthSubjectFromContext(c) + role, _ := middleware.GetUserRoleFromContext(c) + slog.Info("settings updated", + "audit", true, + "user_id", subject.UserID, + "role", role, + "changed", changed, + ) +} + +func diffSettings(before *service.SystemSettings, after *service.SystemSettings, beforeAuthSourceDefaults *service.AuthSourceDefaultSettings, afterAuthSourceDefaults *service.AuthSourceDefaultSettings, req UpdateSettingsRequest) []string { + changed := make([]string, 0, 20) + if before.RegistrationEnabled != after.RegistrationEnabled { + changed = append(changed, "registration_enabled") + } + if before.EmailVerifyEnabled != after.EmailVerifyEnabled { + changed = append(changed, "email_verify_enabled") + } + if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) { + changed = append(changed, "registration_email_suffix_whitelist") + } + if before.PromoCodeEnabled != after.PromoCodeEnabled { + changed = append(changed, "promo_code_enabled") + } + if before.InvitationCodeEnabled != after.InvitationCodeEnabled { + changed = append(changed, "invitation_code_enabled") + } + if before.PasswordResetEnabled != after.PasswordResetEnabled { + changed = append(changed, "password_reset_enabled") + } + if before.FrontendURL != after.FrontendURL { + changed = append(changed, "frontend_url") + } + if before.TotpEnabled != after.TotpEnabled { + changed = append(changed, "totp_enabled") + } + if before.LoginAgreementEnabled != after.LoginAgreementEnabled { + changed = append(changed, "login_agreement_enabled") + } + if before.LoginAgreementMode != after.LoginAgreementMode { + changed = append(changed, "login_agreement_mode") + } + if before.LoginAgreementUpdatedAt != after.LoginAgreementUpdatedAt { + changed = append(changed, "login_agreement_updated_at") + } + if !equalLoginAgreementDocuments(before.LoginAgreementDocuments, after.LoginAgreementDocuments) { + changed = append(changed, "login_agreement_documents") + } + if before.SMTPHost != after.SMTPHost { + changed = append(changed, "smtp_host") + } + if before.SMTPPort != after.SMTPPort { + changed = append(changed, "smtp_port") + } + if before.SMTPUsername != after.SMTPUsername { + changed = append(changed, "smtp_username") + } + if req.SMTPPassword != "" { + changed = append(changed, "smtp_password") + } + if before.SMTPFrom != after.SMTPFrom { + changed = append(changed, "smtp_from_email") + } + if before.SMTPFromName != after.SMTPFromName { + changed = append(changed, "smtp_from_name") + } + if before.SMTPUseTLS != after.SMTPUseTLS { + changed = append(changed, "smtp_use_tls") + } + if before.TurnstileEnabled != after.TurnstileEnabled { + changed = append(changed, "turnstile_enabled") + } + if before.TurnstileSiteKey != after.TurnstileSiteKey { + changed = append(changed, "turnstile_site_key") + } + if req.TurnstileSecretKey != "" { + changed = append(changed, "turnstile_secret_key") + } + if before.APIKeyACLTrustForwardedIP != after.APIKeyACLTrustForwardedIP { + changed = append(changed, "api_key_acl_trust_forwarded_ip") + } + if before.LinuxDoConnectEnabled != after.LinuxDoConnectEnabled { + changed = append(changed, "linuxdo_connect_enabled") + } + if before.LinuxDoConnectClientID != after.LinuxDoConnectClientID { + changed = append(changed, "linuxdo_connect_client_id") + } + if req.LinuxDoConnectClientSecret != "" { + changed = append(changed, "linuxdo_connect_client_secret") + } + if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL { + changed = append(changed, "linuxdo_connect_redirect_url") + } + if before.DingTalkConnectEnabled != after.DingTalkConnectEnabled { + changed = append(changed, "dingtalk_connect_enabled") + } + if before.DingTalkConnectClientID != after.DingTalkConnectClientID { + changed = append(changed, "dingtalk_connect_client_id") + } + if req.DingTalkConnectClientSecret != "" { + changed = append(changed, "dingtalk_connect_client_secret") + } + if before.DingTalkConnectRedirectURL != after.DingTalkConnectRedirectURL { + changed = append(changed, "dingtalk_connect_redirect_url") + } + if before.DingTalkConnectCorpRestrictionPolicy != after.DingTalkConnectCorpRestrictionPolicy { + changed = append(changed, "dingtalk_connect_corp_restriction_policy") + } + if before.DingTalkConnectInternalCorpID != after.DingTalkConnectInternalCorpID { + changed = append(changed, "dingtalk_connect_internal_corp_id") + } + if before.DingTalkConnectBypassRegistration != after.DingTalkConnectBypassRegistration { + changed = append(changed, "dingtalk_connect_bypass_registration") + } + if before.DingTalkConnectSyncCorpEmail != after.DingTalkConnectSyncCorpEmail { + changed = append(changed, "dingtalk_connect_sync_corp_email") + } + if before.DingTalkConnectSyncDisplayName != after.DingTalkConnectSyncDisplayName { + changed = append(changed, "dingtalk_connect_sync_display_name") + } + if before.DingTalkConnectSyncDept != after.DingTalkConnectSyncDept { + changed = append(changed, "dingtalk_connect_sync_dept") + } + if before.DingTalkConnectSyncCorpEmailAttrKey != after.DingTalkConnectSyncCorpEmailAttrKey { + changed = append(changed, "dingtalk_connect_sync_corp_email_attr_key") + } + if before.DingTalkConnectSyncDisplayNameAttrKey != after.DingTalkConnectSyncDisplayNameAttrKey { + changed = append(changed, "dingtalk_connect_sync_display_name_attr_key") + } + if before.DingTalkConnectSyncDeptAttrKey != after.DingTalkConnectSyncDeptAttrKey { + changed = append(changed, "dingtalk_connect_sync_dept_attr_key") + } + if before.WeChatConnectEnabled != after.WeChatConnectEnabled { + changed = append(changed, "wechat_connect_enabled") + } + if before.WeChatConnectAppID != after.WeChatConnectAppID { + changed = append(changed, "wechat_connect_app_id") + } + if req.WeChatConnectAppSecret != "" { + changed = append(changed, "wechat_connect_app_secret") + } + if before.WeChatConnectOpenAppID != after.WeChatConnectOpenAppID { + changed = append(changed, "wechat_connect_open_app_id") + } + if req.WeChatConnectOpenAppSecret != "" { + changed = append(changed, "wechat_connect_open_app_secret") + } + if before.WeChatConnectMPAppID != after.WeChatConnectMPAppID { + changed = append(changed, "wechat_connect_mp_app_id") + } + if req.WeChatConnectMPAppSecret != "" { + changed = append(changed, "wechat_connect_mp_app_secret") + } + if before.WeChatConnectMobileAppID != after.WeChatConnectMobileAppID { + changed = append(changed, "wechat_connect_mobile_app_id") + } + if req.WeChatConnectMobileAppSecret != "" { + changed = append(changed, "wechat_connect_mobile_app_secret") + } + if before.WeChatConnectOpenEnabled != after.WeChatConnectOpenEnabled { + changed = append(changed, "wechat_connect_open_enabled") + } + if before.WeChatConnectMPEnabled != after.WeChatConnectMPEnabled { + changed = append(changed, "wechat_connect_mp_enabled") + } + if before.WeChatConnectMobileEnabled != after.WeChatConnectMobileEnabled { + changed = append(changed, "wechat_connect_mobile_enabled") + } + if before.WeChatConnectMode != after.WeChatConnectMode { + changed = append(changed, "wechat_connect_mode") + } + if before.WeChatConnectScopes != after.WeChatConnectScopes { + changed = append(changed, "wechat_connect_scopes") + } + if before.WeChatConnectRedirectURL != after.WeChatConnectRedirectURL { + changed = append(changed, "wechat_connect_redirect_url") + } + if before.WeChatConnectFrontendRedirectURL != after.WeChatConnectFrontendRedirectURL { + changed = append(changed, "wechat_connect_frontend_redirect_url") + } + if before.OIDCConnectEnabled != after.OIDCConnectEnabled { + changed = append(changed, "oidc_connect_enabled") + } + if before.OIDCConnectProviderName != after.OIDCConnectProviderName { + changed = append(changed, "oidc_connect_provider_name") + } + if before.OIDCConnectClientID != after.OIDCConnectClientID { + changed = append(changed, "oidc_connect_client_id") + } + if req.OIDCConnectClientSecret != "" { + changed = append(changed, "oidc_connect_client_secret") + } + if before.OIDCConnectIssuerURL != after.OIDCConnectIssuerURL { + changed = append(changed, "oidc_connect_issuer_url") + } + if before.OIDCConnectDiscoveryURL != after.OIDCConnectDiscoveryURL { + changed = append(changed, "oidc_connect_discovery_url") + } + if before.OIDCConnectAuthorizeURL != after.OIDCConnectAuthorizeURL { + changed = append(changed, "oidc_connect_authorize_url") + } + if before.OIDCConnectTokenURL != after.OIDCConnectTokenURL { + changed = append(changed, "oidc_connect_token_url") + } + if before.OIDCConnectUserInfoURL != after.OIDCConnectUserInfoURL { + changed = append(changed, "oidc_connect_userinfo_url") + } + if before.OIDCConnectJWKSURL != after.OIDCConnectJWKSURL { + changed = append(changed, "oidc_connect_jwks_url") + } + if before.OIDCConnectScopes != after.OIDCConnectScopes { + changed = append(changed, "oidc_connect_scopes") + } + if before.OIDCConnectRedirectURL != after.OIDCConnectRedirectURL { + changed = append(changed, "oidc_connect_redirect_url") + } + if before.OIDCConnectFrontendRedirectURL != after.OIDCConnectFrontendRedirectURL { + changed = append(changed, "oidc_connect_frontend_redirect_url") + } + if before.OIDCConnectTokenAuthMethod != after.OIDCConnectTokenAuthMethod { + changed = append(changed, "oidc_connect_token_auth_method") + } + if before.OIDCConnectUsePKCE != after.OIDCConnectUsePKCE { + changed = append(changed, "oidc_connect_use_pkce") + } + if before.OIDCConnectValidateIDToken != after.OIDCConnectValidateIDToken { + changed = append(changed, "oidc_connect_validate_id_token") + } + if before.OIDCConnectAllowedSigningAlgs != after.OIDCConnectAllowedSigningAlgs { + changed = append(changed, "oidc_connect_allowed_signing_algs") + } + if before.OIDCConnectClockSkewSeconds != after.OIDCConnectClockSkewSeconds { + changed = append(changed, "oidc_connect_clock_skew_seconds") + } + if before.OIDCConnectRequireEmailVerified != after.OIDCConnectRequireEmailVerified { + changed = append(changed, "oidc_connect_require_email_verified") + } + if before.OIDCConnectUserInfoEmailPath != after.OIDCConnectUserInfoEmailPath { + changed = append(changed, "oidc_connect_userinfo_email_path") + } + if before.OIDCConnectUserInfoIDPath != after.OIDCConnectUserInfoIDPath { + changed = append(changed, "oidc_connect_userinfo_id_path") + } + if before.OIDCConnectUserInfoUsernamePath != after.OIDCConnectUserInfoUsernamePath { + changed = append(changed, "oidc_connect_userinfo_username_path") + } + if before.SiteName != after.SiteName { + changed = append(changed, "site_name") + } + if before.SiteLogo != after.SiteLogo { + changed = append(changed, "site_logo") + } + if before.SiteSubtitle != after.SiteSubtitle { + changed = append(changed, "site_subtitle") + } + if before.APIBaseURL != after.APIBaseURL { + changed = append(changed, "api_base_url") + } + if before.ContactInfo != after.ContactInfo { + changed = append(changed, "contact_info") + } + if before.DocURL != after.DocURL { + changed = append(changed, "doc_url") + } + if before.HomeContent != after.HomeContent { + changed = append(changed, "home_content") + } + if before.HideCcsImportButton != after.HideCcsImportButton { + changed = append(changed, "hide_ccs_import_button") + } + if before.DefaultConcurrency != after.DefaultConcurrency { + changed = append(changed, "default_concurrency") + } + if before.DefaultBalance != after.DefaultBalance { + changed = append(changed, "default_balance") + } + if before.AffiliateRebateRate != after.AffiliateRebateRate { + changed = append(changed, "affiliate_rebate_rate") + } + if before.AffiliateRebateFreezeHours != after.AffiliateRebateFreezeHours { + changed = append(changed, "affiliate_rebate_freeze_hours") + } + if before.AffiliateRebateDurationDays != after.AffiliateRebateDurationDays { + changed = append(changed, "affiliate_rebate_duration_days") + } + if before.AffiliateRebatePerInviteeCap != after.AffiliateRebatePerInviteeCap { + changed = append(changed, "affiliate_rebate_per_invitee_cap") + } + if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) { + changed = append(changed, "default_subscriptions") + } + if before.EnableModelFallback != after.EnableModelFallback { + changed = append(changed, "enable_model_fallback") + } + if before.FallbackModelAnthropic != after.FallbackModelAnthropic { + changed = append(changed, "fallback_model_anthropic") + } + if before.FallbackModelOpenAI != after.FallbackModelOpenAI { + changed = append(changed, "fallback_model_openai") + } + if before.FallbackModelGemini != after.FallbackModelGemini { + changed = append(changed, "fallback_model_gemini") + } + if before.FallbackModelAntigravity != after.FallbackModelAntigravity { + changed = append(changed, "fallback_model_antigravity") + } + if before.EnableIdentityPatch != after.EnableIdentityPatch { + changed = append(changed, "enable_identity_patch") + } + if before.IdentityPatchPrompt != after.IdentityPatchPrompt { + changed = append(changed, "identity_patch_prompt") + } + if before.OpsMonitoringEnabled != after.OpsMonitoringEnabled { + changed = append(changed, "ops_monitoring_enabled") + } + if before.OpsRealtimeMonitoringEnabled != after.OpsRealtimeMonitoringEnabled { + changed = append(changed, "ops_realtime_monitoring_enabled") + } + if before.OpsQueryModeDefault != after.OpsQueryModeDefault { + changed = append(changed, "ops_query_mode_default") + } + if before.OpsMetricsIntervalSeconds != after.OpsMetricsIntervalSeconds { + changed = append(changed, "ops_metrics_interval_seconds") + } + if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion { + changed = append(changed, "min_claude_code_version") + } + if before.MaxClaudeCodeVersion != after.MaxClaudeCodeVersion { + changed = append(changed, "max_claude_code_version") + } + if before.MinCodexVersion != after.MinCodexVersion { + changed = append(changed, "min_codex_version") + } + if before.MaxCodexVersion != after.MaxCodexVersion { + changed = append(changed, "max_codex_version") + } + if before.CodexCLIOnlyAllowAppServerClients != after.CodexCLIOnlyAllowAppServerClients { + changed = append(changed, "codex_cli_only_allow_app_server_clients") + } + if before.CodexCLIOnlyEngineFingerprintSignals != after.CodexCLIOnlyEngineFingerprintSignals { + changed = append(changed, "codex_cli_only_engine_fingerprint_signals") + } + if before.CodexCLIOnlyBlacklist != after.CodexCLIOnlyBlacklist { + changed = append(changed, "codex_cli_only_blacklist") + } + if before.CodexCLIOnlyWhitelist != after.CodexCLIOnlyWhitelist { + changed = append(changed, "codex_cli_only_whitelist") + } + if before.AllowUngroupedKeyScheduling != after.AllowUngroupedKeyScheduling { + changed = append(changed, "allow_ungrouped_key_scheduling") + } + if before.BackendModeEnabled != after.BackendModeEnabled { + changed = append(changed, "backend_mode_enabled") + } + if before.PurchaseSubscriptionEnabled != after.PurchaseSubscriptionEnabled { + changed = append(changed, "purchase_subscription_enabled") + } + if before.PurchaseSubscriptionURL != after.PurchaseSubscriptionURL { + changed = append(changed, "purchase_subscription_url") + } + if before.TableDefaultPageSize != after.TableDefaultPageSize { + changed = append(changed, "table_default_page_size") + } + if !equalIntSlice(before.TablePageSizeOptions, after.TablePageSizeOptions) { + changed = append(changed, "table_page_size_options") + } + if before.CustomMenuItems != after.CustomMenuItems { + changed = append(changed, "custom_menu_items") + } + if before.CustomEndpoints != after.CustomEndpoints { + changed = append(changed, "custom_endpoints") + } + if before.EnableFingerprintUnification != after.EnableFingerprintUnification { + changed = append(changed, "enable_fingerprint_unification") + } + if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough { + changed = append(changed, "enable_metadata_passthrough") + } + if before.EnableCCHSigning != after.EnableCCHSigning { + changed = append(changed, "enable_cch_signing") + } + if before.EnableClaudeOAuthSystemPromptInjection != after.EnableClaudeOAuthSystemPromptInjection { + changed = append(changed, "enable_claude_oauth_system_prompt_injection") + } + if before.ClaudeOAuthSystemPrompt != after.ClaudeOAuthSystemPrompt { + changed = append(changed, "claude_oauth_system_prompt") + } + if before.ClaudeOAuthSystemPromptBlocks != after.ClaudeOAuthSystemPromptBlocks { + changed = append(changed, "claude_oauth_system_prompt_blocks") + } + if before.EnableAnthropicCacheTTL1hInjection != after.EnableAnthropicCacheTTL1hInjection { + changed = append(changed, "enable_anthropic_cache_ttl_1h_injection") + } + if before.RewriteMessageCacheControl != after.RewriteMessageCacheControl { + changed = append(changed, "rewrite_message_cache_control") + } + if before.EnableClientDatelineNormalization != after.EnableClientDatelineNormalization { + changed = append(changed, "enable_client_dateline_normalization") + } + if before.AntigravityUserAgentVersion != after.AntigravityUserAgentVersion { + changed = append(changed, "antigravity_user_agent_version") + } + if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent { + changed = append(changed, "openai_codex_user_agent") + } + if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource { + changed = append(changed, "payment_visible_method_alipay_source") + } + if before.PaymentVisibleMethodWxpaySource != after.PaymentVisibleMethodWxpaySource { + changed = append(changed, "payment_visible_method_wxpay_source") + } + if before.PaymentVisibleMethodAlipayEnabled != after.PaymentVisibleMethodAlipayEnabled { + changed = append(changed, "payment_visible_method_alipay_enabled") + } + if before.PaymentVisibleMethodWxpayEnabled != after.PaymentVisibleMethodWxpayEnabled { + changed = append(changed, "payment_visible_method_wxpay_enabled") + } + if before.OpenAIAdvancedSchedulerEnabled != after.OpenAIAdvancedSchedulerEnabled { + changed = append(changed, "openai_advanced_scheduler_enabled") + } + if before.OpenAIAdvancedSchedulerStickyWeightedEnabled != after.OpenAIAdvancedSchedulerStickyWeightedEnabled { + changed = append(changed, "openai_advanced_scheduler_sticky_weighted_enabled") + } + if before.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled != after.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled { + changed = append(changed, "openai_advanced_scheduler_subscription_priority_enabled") + } + if before.OpenAIAdvancedSchedulerLBTopK != after.OpenAIAdvancedSchedulerLBTopK { + changed = append(changed, "openai_advanced_scheduler_lb_top_k") + } + if before.OpenAIAdvancedSchedulerWeightPriority != after.OpenAIAdvancedSchedulerWeightPriority { + changed = append(changed, "openai_advanced_scheduler_weight_priority") + } + if before.OpenAIAdvancedSchedulerWeightLoad != after.OpenAIAdvancedSchedulerWeightLoad { + changed = append(changed, "openai_advanced_scheduler_weight_load") + } + if before.OpenAIAdvancedSchedulerWeightQueue != after.OpenAIAdvancedSchedulerWeightQueue { + changed = append(changed, "openai_advanced_scheduler_weight_queue") + } + if before.OpenAIAdvancedSchedulerWeightErrorRate != after.OpenAIAdvancedSchedulerWeightErrorRate { + changed = append(changed, "openai_advanced_scheduler_weight_error_rate") + } + if before.OpenAIAdvancedSchedulerWeightTTFT != after.OpenAIAdvancedSchedulerWeightTTFT { + changed = append(changed, "openai_advanced_scheduler_weight_ttft") + } + if before.OpenAIAdvancedSchedulerWeightReset != after.OpenAIAdvancedSchedulerWeightReset { + changed = append(changed, "openai_advanced_scheduler_weight_reset") + } + if before.OpenAIAdvancedSchedulerWeightQuotaHeadroom != after.OpenAIAdvancedSchedulerWeightQuotaHeadroom { + changed = append(changed, "openai_advanced_scheduler_weight_quota_headroom") + } + if before.OpenAIAdvancedSchedulerWeightPreviousResponse != after.OpenAIAdvancedSchedulerWeightPreviousResponse { + changed = append(changed, "openai_advanced_scheduler_weight_previous_response") + } + if before.OpenAIAdvancedSchedulerWeightSessionSticky != after.OpenAIAdvancedSchedulerWeightSessionSticky { + changed = append(changed, "openai_advanced_scheduler_weight_session_sticky") + } + // 余额、订阅到期与账号限额通知 + if before.BalanceLowNotifyEnabled != after.BalanceLowNotifyEnabled { + changed = append(changed, "balance_low_notify_enabled") + } + if before.BalanceLowNotifyThreshold != after.BalanceLowNotifyThreshold { + changed = append(changed, "balance_low_notify_threshold") + } + if before.BalanceLowNotifyRechargeURL != after.BalanceLowNotifyRechargeURL { + changed = append(changed, "balance_low_notify_recharge_url") + } + if before.SubscriptionExpiryNotifyEnabled != after.SubscriptionExpiryNotifyEnabled { + changed = append(changed, "subscription_expiry_notify_enabled") + } + if before.AccountQuotaNotifyEnabled != after.AccountQuotaNotifyEnabled { + changed = append(changed, "account_quota_notify_enabled") + } + if !equalNotifyEmailEntries(before.AccountQuotaNotifyEmails, after.AccountQuotaNotifyEmails) { + changed = append(changed, "account_quota_notify_emails") + } + if before.ChannelMonitorEnabled != after.ChannelMonitorEnabled { + changed = append(changed, "channel_monitor_enabled") + } + if before.ChannelMonitorDefaultIntervalSeconds != after.ChannelMonitorDefaultIntervalSeconds { + changed = append(changed, "channel_monitor_default_interval_seconds") + } + if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled { + changed = append(changed, "available_channels_enabled") + } + if before.AffiliateEnabled != after.AffiliateEnabled { + changed = append(changed, "affiliate_enabled") + } + if before.RiskControlEnabled != after.RiskControlEnabled { + changed = append(changed, "risk_control_enabled") + } + if before.CyberSessionBlockEnabled != after.CyberSessionBlockEnabled { + changed = append(changed, "cyber_session_block_enabled") + } + if before.CyberSessionBlockTTLSeconds != after.CyberSessionBlockTTLSeconds { + changed = append(changed, "cyber_session_block_ttl_seconds") + } + // Default platform quotas(JSON map,整体比较) + if !equalPlatformQuotaSettings(before.DefaultPlatformQuotas, after.DefaultPlatformQuotas) { + changed = append(changed, service.SettingKeyDefaultPlatformQuotas) + } + changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults) + return changed +} + +func appendAuthSourceDefaultChanges(changed []string, before *service.AuthSourceDefaultSettings, after *service.AuthSourceDefaultSettings) []string { + if before == nil { + before = &service.AuthSourceDefaultSettings{} + } + if after == nil { + after = &service.AuthSourceDefaultSettings{} + } + + type providerDefaultGrantField struct { + name string + before service.ProviderDefaultGrantSettings + after service.ProviderDefaultGrantSettings + } + + fields := []providerDefaultGrantField{ + {name: "email", before: before.Email, after: after.Email}, + {name: "linuxdo", before: before.LinuxDo, after: after.LinuxDo}, + {name: "oidc", before: before.OIDC, after: after.OIDC}, + {name: "wechat", before: before.WeChat, after: after.WeChat}, + {name: "github", before: before.GitHub, after: after.GitHub}, + {name: "google", before: before.Google, after: after.Google}, + {name: "dingtalk", before: before.DingTalk, after: after.DingTalk}, + } + for _, field := range fields { + if field.before.Balance != field.after.Balance { + changed = append(changed, "auth_source_default_"+field.name+"_balance") + } + if field.before.Concurrency != field.after.Concurrency { + changed = append(changed, "auth_source_default_"+field.name+"_concurrency") + } + if !equalDefaultSubscriptions(field.before.Subscriptions, field.after.Subscriptions) { + changed = append(changed, "auth_source_default_"+field.name+"_subscriptions") + } + if field.before.GrantOnSignup != field.after.GrantOnSignup { + changed = append(changed, "auth_source_default_"+field.name+"_grant_on_signup") + } + if field.before.GrantOnFirstBind != field.after.GrantOnFirstBind { + changed = append(changed, "auth_source_default_"+field.name+"_grant_on_first_bind") + } + // Platform quotas diff:整体替换语义,发单个 JSON key。 + if !equalPlatformQuotaSettings(field.before.PlatformQuotas, field.after.PlatformQuotas) { + changed = append(changed, service.SettingKeyAuthSourcePlatformQuotas(field.name)) + } + } + if before.ForceEmailOnThirdPartySignup != after.ForceEmailOnThirdPartySignup { + changed = append(changed, "force_email_on_third_party_signup") + } + return changed +} + +func normalizeDefaultSubscriptions(input []dto.DefaultSubscriptionSetting) []dto.DefaultSubscriptionSetting { + if len(input) == 0 { + return nil + } + normalized := make([]dto.DefaultSubscriptionSetting, 0, len(input)) + for _, item := range input { + if item.GroupID <= 0 || item.ValidityDays <= 0 { + continue + } + if item.ValidityDays > service.MaxValidityDays { + item.ValidityDays = service.MaxValidityDays + } + normalized = append(normalized, item) + } + return normalized +} + +func normalizeOptionalDefaultSubscriptions(input *[]dto.DefaultSubscriptionSetting) *[]dto.DefaultSubscriptionSetting { + if input == nil { + return nil + } + normalized := normalizeDefaultSubscriptions(*input) + return &normalized +} + +func float64ValueOrDefault(value *float64, fallback float64) float64 { + if value == nil { + return fallback + } + return *value +} + +func intValueOrDefault(value *int, fallback int) int { + if value == nil { + return fallback + } + return *value +} + +func boolValueOrDefault(value *bool, fallback bool) bool { + if value == nil { + return fallback + } + return *value +} + +func defaultSubscriptionsValueOrDefault(input *[]dto.DefaultSubscriptionSetting, fallback []service.DefaultSubscriptionSetting) []service.DefaultSubscriptionSetting { + if input == nil { + return fallback + } + result := make([]service.DefaultSubscriptionSetting, 0, len(*input)) + for _, item := range *input { + result = append(result, service.DefaultSubscriptionSetting{ + GroupID: item.GroupID, + ValidityDays: item.ValidityDays, + }) + } + return result +} + +// platformQuotasValueOrDefault 处理 auth-source platform quota 的 nil 语义: +// nil = 请求未包含该字段(保留 fallback),non-nil(含 empty map)= 整体覆盖。 +// 注意:JSON null 与字段省略等价——两者均反序列化为 nil map,因此都保留旧值; +// 若要清空某 source 的所有 quota 配置,须显式发空对象 {}。 +func platformQuotasValueOrDefault(value, fallback map[string]*service.DefaultPlatformQuotaSetting) map[string]*service.DefaultPlatformQuotaSetting { + if value == nil { + return fallback + } + return value +} + +func equalStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].GroupID != b[i].GroupID || a[i].ValidityDays != b[i].ValidityDays { + return false + } + } + return true +} + +func equalLoginAgreementDocuments(a, b []service.LoginAgreementDocument) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].ID != b[i].ID || a[i].Title != b[i].Title || a[i].ContentMD != b[i].ContentMD { + return false + } + } + return true +} + +func equalIntSlice(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func equalNotifyEmailEntries(a, b []service.NotifyEmailEntry) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Email != b[i].Email || a[i].Verified != b[i].Verified || a[i].Disabled != b[i].Disabled { + return false + } + } + return true +} + +// equalNullableFloat compares two *float64 values treating nil as a distinct case. +func equalNullableFloat(a, b *float64) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + +// slotOf returns the *float64 for the given window from a DefaultPlatformQuotaSetting. +func slotOf(s *service.DefaultPlatformQuotaSetting, win string) *float64 { + if s == nil { + return nil + } + switch win { + case "daily": + return s.DailyLimitUSD + case "weekly": + return s.WeeklyLimitUSD + case "monthly": + return s.MonthlyLimitUSD + } + return nil +} + +// equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all allowed slots. +func equalPlatformQuotaSettings(before, after map[string]*service.DefaultPlatformQuotaSetting) bool { + for _, platform := range service.AllowedQuotaPlatforms { + b := before[platform] + a := after[platform] + if !equalNullableFloat(slotOf(b, "daily"), slotOf(a, "daily")) { + return false + } + if !equalNullableFloat(slotOf(b, "weekly"), slotOf(a, "weekly")) { + return false + } + if !equalNullableFloat(slotOf(b, "monthly"), slotOf(a, "monthly")) { + return false + } + } + return true +} + +func stringSetting(value *string, fallback string) string { + if value == nil { + return fallback + } + return *value +} diff --git a/backend/internal/handler/admin/setting_handler_email.go b/backend/internal/handler/admin/setting_handler_email.go new file mode 100644 index 0000000000..9ff0529a5b --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_email.go @@ -0,0 +1,346 @@ +package admin + +import ( + "strings" + + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// TestSMTPRequest 测试SMTP连接请求 +type TestSMTPRequest struct { + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password"` + SMTPUseTLS bool `json:"smtp_use_tls"` +} + +// TestSMTPConnection 测试SMTP连接 +// POST /api/v1/admin/settings/test-smtp +func (h *SettingHandler) TestSMTPConnection(c *gin.Context) { + var req TestSMTPRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + req.SMTPHost = strings.TrimSpace(req.SMTPHost) + req.SMTPUsername = strings.TrimSpace(req.SMTPUsername) + + var savedConfig *service.SMTPConfig + if cfg, err := h.emailService.GetSMTPConfig(c.Request.Context()); err == nil && cfg != nil { + savedConfig = cfg + } + + if req.SMTPHost == "" && savedConfig != nil { + req.SMTPHost = savedConfig.Host + } + if req.SMTPPort <= 0 { + if savedConfig != nil && savedConfig.Port > 0 { + req.SMTPPort = savedConfig.Port + } else { + req.SMTPPort = 587 + } + } + if req.SMTPUsername == "" && savedConfig != nil { + req.SMTPUsername = savedConfig.Username + } + password := strings.TrimSpace(req.SMTPPassword) + if password == "" && savedConfig != nil { + password = savedConfig.Password + } + if req.SMTPHost == "" { + response.BadRequest(c, "SMTP host is required") + return + } + + config := &service.SMTPConfig{ + Host: req.SMTPHost, + Port: req.SMTPPort, + Username: req.SMTPUsername, + Password: password, + UseTLS: req.SMTPUseTLS, + } + + err := h.emailService.TestSMTPConnectionWithConfig(config) + if err != nil { + response.BadRequest(c, "SMTP connection test failed: "+err.Error()) + return + } + + response.Success(c, gin.H{"message": "SMTP connection successful"}) +} + +// SendTestEmailRequest 发送测试邮件请求 +type SendTestEmailRequest struct { + Email string `json:"email" binding:"required,email"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password"` + SMTPFrom string `json:"smtp_from_email"` + SMTPFromName string `json:"smtp_from_name"` + SMTPUseTLS bool `json:"smtp_use_tls"` +} + +// SendTestEmail 发送测试邮件 +// POST /api/v1/admin/settings/send-test-email +func (h *SettingHandler) SendTestEmail(c *gin.Context) { + var req SendTestEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + req.SMTPHost = strings.TrimSpace(req.SMTPHost) + req.SMTPUsername = strings.TrimSpace(req.SMTPUsername) + req.SMTPFrom = strings.TrimSpace(req.SMTPFrom) + req.SMTPFromName = strings.TrimSpace(req.SMTPFromName) + + var savedConfig *service.SMTPConfig + if cfg, err := h.emailService.GetSMTPConfig(c.Request.Context()); err == nil && cfg != nil { + savedConfig = cfg + } + + if req.SMTPHost == "" && savedConfig != nil { + req.SMTPHost = savedConfig.Host + } + if req.SMTPPort <= 0 { + if savedConfig != nil && savedConfig.Port > 0 { + req.SMTPPort = savedConfig.Port + } else { + req.SMTPPort = 587 + } + } + if req.SMTPUsername == "" && savedConfig != nil { + req.SMTPUsername = savedConfig.Username + } + password := strings.TrimSpace(req.SMTPPassword) + if password == "" && savedConfig != nil { + password = savedConfig.Password + } + if req.SMTPFrom == "" && savedConfig != nil { + req.SMTPFrom = savedConfig.From + } + if req.SMTPFromName == "" && savedConfig != nil { + req.SMTPFromName = savedConfig.FromName + } + if req.SMTPHost == "" { + response.BadRequest(c, "SMTP host is required") + return + } + + config := &service.SMTPConfig{ + Host: req.SMTPHost, + Port: req.SMTPPort, + Username: req.SMTPUsername, + Password: password, + From: req.SMTPFrom, + FromName: req.SMTPFromName, + UseTLS: req.SMTPUseTLS, + } + + siteName := h.settingService.GetSiteName(c.Request.Context()) + subject := "[" + siteName + "] Test Email" + body := ` + + + + + + + +
+
+

` + siteName + `

+
+
+
✓
+

Email Configuration Successful!

+

This is a test email to verify your SMTP settings are working correctly.

+
+ +
+ + +` + + if err := h.emailService.SendEmailWithConfig(config, req.Email, subject, body); err != nil { + response.BadRequest(c, "Failed to send test email: "+err.Error()) + return + } + + response.Success(c, gin.H{"message": "Test email sent successfully"}) +} + +// ListEmailTemplates returns all editable notification email templates. +// GET /api/v1/admin/settings/email-templates +func (h *SettingHandler) ListEmailTemplates(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + events := h.notificationEmailService.ListEventInfos() + templates, err := h.notificationEmailService.ListTemplates(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, dto.EmailTemplateListResponse{ + Events: emailTemplateEventOptionsToDTO(events), + Locales: h.notificationEmailService.SupportedLocales(), + Templates: emailTemplateSummariesToDTO(templates), + Placeholders: emailTemplatePlaceholderUnion(events), + }) +} + +// GetEmailTemplate returns one editable notification email template. +// GET /api/v1/admin/settings/email-templates/:event/:locale +func (h *SettingHandler) GetEmailTemplate(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + tmpl, err := h.notificationEmailService.GetTemplate(c.Request.Context(), c.Param("event"), c.Param("locale")) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, emailTemplateDetailToDTO(tmpl)) +} + +// UpdateEmailTemplate saves an override for one event/locale template. +// PUT /api/v1/admin/settings/email-templates/:event/:locale +func (h *SettingHandler) UpdateEmailTemplate(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + var req dto.UpdateEmailTemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + tmpl, err := h.notificationEmailService.UpdateTemplate(c.Request.Context(), c.Param("event"), c.Param("locale"), req.Subject, req.HTML) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, emailTemplateDetailToDTO(tmpl)) +} + +// RestoreOfficialEmailTemplate removes an override and returns the built-in template. +// POST /api/v1/admin/settings/email-templates/:event/:locale/restore-official +func (h *SettingHandler) RestoreOfficialEmailTemplate(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + tmpl, err := h.notificationEmailService.RestoreOfficialTemplate(c.Request.Context(), c.Param("event"), c.Param("locale")) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, emailTemplateDetailToDTO(tmpl)) +} + +// PreviewEmailTemplate renders a template with safe sample variables without saving it. +// POST /api/v1/admin/settings/email-templates/preview +func (h *SettingHandler) PreviewEmailTemplate(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + var req dto.PreviewEmailTemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + preview, err := h.notificationEmailService.PreviewTemplate(c.Request.Context(), service.NotificationEmailPreviewInput{ + Event: req.Event, + Locale: req.Locale, + Subject: req.Subject, + HTML: req.HTML, + Variables: req.Variables, + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, dto.EmailTemplatePreviewResponse{Subject: preview.Subject, HTML: preview.HTML}) +} + +func emailTemplateEventOptionsToDTO(events []service.NotificationEmailEventInfo) []dto.EmailTemplateEventOption { + items := make([]dto.EmailTemplateEventOption, 0, len(events)) + for _, event := range events { + items = append(items, dto.EmailTemplateEventOption{ + Value: event.Event, + Label: event.Label, + Description: event.Description, + Category: event.Category, + Optional: event.Optional, + }) + } + return items +} + +func emailTemplateSummariesToDTO(templates []service.NotificationEmailTemplate) []dto.EmailTemplateSummary { + items := make([]dto.EmailTemplateSummary, 0, len(templates)) + for _, tmpl := range templates { + items = append(items, dto.EmailTemplateSummary{ + Event: tmpl.Event, + Locale: tmpl.Locale, + Subject: tmpl.Subject, + IsCustom: tmpl.IsCustom, + UpdatedAt: emailTemplateUpdatedAt(tmpl), + }) + } + return items +} + +func emailTemplateDetailToDTO(tmpl service.NotificationEmailTemplate) dto.EmailTemplateDetail { + return dto.EmailTemplateDetail{ + Event: tmpl.Event, + Locale: tmpl.Locale, + Subject: tmpl.Subject, + HTML: tmpl.HTML, + IsCustom: tmpl.IsCustom, + UpdatedAt: emailTemplateUpdatedAt(tmpl), + Placeholders: tmpl.Placeholders, + } +} + +func emailTemplateUpdatedAt(tmpl service.NotificationEmailTemplate) string { + if tmpl.UpdatedAt == nil { + return "" + } + return tmpl.UpdatedAt.Format("2006-01-02T15:04:05Z07:00") +} + +func emailTemplatePlaceholderUnion(events []service.NotificationEmailEventInfo) []string { + seen := make(map[string]struct{}) + placeholders := make([]string, 0) + for _, event := range events { + for _, placeholder := range event.Placeholders { + if _, ok := seen[placeholder]; ok { + continue + } + seen[placeholder] = struct{}{} + placeholders = append(placeholders, placeholder) + } + } + return placeholders +} diff --git a/backend/internal/handler/admin/setting_handler_runtime.go b/backend/internal/handler/admin/setting_handler_runtime.go new file mode 100644 index 0000000000..4891c9b7ad --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_runtime.go @@ -0,0 +1,445 @@ +package admin + +import ( + "strings" + + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// GetAdminAPIKey 获取管理员 API Key 状态 +// GET /api/v1/admin/settings/admin-api-key +func (h *SettingHandler) GetAdminAPIKey(c *gin.Context) { + maskedKey, exists, err := h.settingService.GetAdminAPIKeyStatus(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{ + "exists": exists, + "masked_key": maskedKey, + }) +} + +// RegenerateAdminAPIKey 生成/重新生成管理员 API Key +// POST /api/v1/admin/settings/admin-api-key/regenerate +func (h *SettingHandler) RegenerateAdminAPIKey(c *gin.Context) { + key, err := h.settingService.GenerateAdminAPIKey(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{ + "key": key, // 完整 key 只在生成时返回一次 + }) +} + +// DeleteAdminAPIKey 删除管理员 API Key +// DELETE /api/v1/admin/settings/admin-api-key +func (h *SettingHandler) DeleteAdminAPIKey(c *gin.Context) { + if err := h.settingService.DeleteAdminAPIKey(c.Request.Context()); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{"message": "Admin API key deleted"}) +} + +// GetOverloadCooldownSettings 获取529过载冷却配置 +// GET /api/v1/admin/settings/overload-cooldown +func (h *SettingHandler) GetOverloadCooldownSettings(c *gin.Context) { + settings, err := h.settingService.GetOverloadCooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.OverloadCooldownSettings{ + Enabled: settings.Enabled, + CooldownMinutes: settings.CooldownMinutes, + }) +} + +// UpdateOverloadCooldownSettingsRequest 更新529过载冷却配置请求 +type UpdateOverloadCooldownSettingsRequest struct { + Enabled bool `json:"enabled"` + CooldownMinutes int `json:"cooldown_minutes"` +} + +// UpdateOverloadCooldownSettings 更新529过载冷却配置 +// PUT /api/v1/admin/settings/overload-cooldown +func (h *SettingHandler) UpdateOverloadCooldownSettings(c *gin.Context) { + var req UpdateOverloadCooldownSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + settings := &service.OverloadCooldownSettings{ + Enabled: req.Enabled, + CooldownMinutes: req.CooldownMinutes, + } + + if err := h.settingService.SetOverloadCooldownSettings(c.Request.Context(), settings); err != nil { + response.BadRequest(c, err.Error()) + return + } + + updatedSettings, err := h.settingService.GetOverloadCooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.OverloadCooldownSettings{ + Enabled: updatedSettings.Enabled, + CooldownMinutes: updatedSettings.CooldownMinutes, + }) +} + +// GetRateLimit429CooldownSettings 获取429默认回避配置 +// GET /api/v1/admin/settings/rate-limit-429-cooldown +func (h *SettingHandler) GetRateLimit429CooldownSettings(c *gin.Context) { + settings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.RateLimit429CooldownSettings{ + Enabled: settings.Enabled, + CooldownSeconds: settings.CooldownSeconds, + }) +} + +// UpdateRateLimit429CooldownSettingsRequest 更新429默认回避配置请求 +type UpdateRateLimit429CooldownSettingsRequest struct { + Enabled bool `json:"enabled"` + CooldownSeconds int `json:"cooldown_seconds"` +} + +// UpdateRateLimit429CooldownSettings 更新429默认回避配置 +// PUT /api/v1/admin/settings/rate-limit-429-cooldown +func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) { + var req UpdateRateLimit429CooldownSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + settings := &service.RateLimit429CooldownSettings{ + Enabled: req.Enabled, + CooldownSeconds: req.CooldownSeconds, + } + + if err := h.settingService.SetRateLimit429CooldownSettings(c.Request.Context(), settings); err != nil { + response.BadRequest(c, err.Error()) + return + } + + updatedSettings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.RateLimit429CooldownSettings{ + Enabled: updatedSettings.Enabled, + CooldownSeconds: updatedSettings.CooldownSeconds, + }) +} + +// GetStreamTimeoutSettings 获取流超时处理配置 +// GET /api/v1/admin/settings/stream-timeout +func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) { + settings, err := h.settingService.GetStreamTimeoutSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.StreamTimeoutSettings{ + Enabled: settings.Enabled, + Action: settings.Action, + TempUnschedMinutes: settings.TempUnschedMinutes, + ThresholdCount: settings.ThresholdCount, + ThresholdWindowMinutes: settings.ThresholdWindowMinutes, + }) +} + +// GetRectifierSettings 获取请求整流器配置 +// GET /api/v1/admin/settings/rectifier +func (h *SettingHandler) GetRectifierSettings(c *gin.Context) { + settings, err := h.settingService.GetRectifierSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + patterns := settings.APIKeySignaturePatterns + if patterns == nil { + patterns = []string{} + } + response.Success(c, dto.RectifierSettings{ + Enabled: settings.Enabled, + ThinkingSignatureEnabled: settings.ThinkingSignatureEnabled, + ThinkingBudgetEnabled: settings.ThinkingBudgetEnabled, + APIKeySignatureEnabled: settings.APIKeySignatureEnabled, + APIKeySignaturePatterns: patterns, + }) +} + +// UpdateRectifierSettingsRequest 更新整流器配置请求 +type UpdateRectifierSettingsRequest struct { + Enabled bool `json:"enabled"` + ThinkingSignatureEnabled bool `json:"thinking_signature_enabled"` + ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"` + APIKeySignatureEnabled bool `json:"apikey_signature_enabled"` + APIKeySignaturePatterns []string `json:"apikey_signature_patterns"` +} + +// UpdateRectifierSettings 更新请求整流器配置 +// PUT /api/v1/admin/settings/rectifier +func (h *SettingHandler) UpdateRectifierSettings(c *gin.Context) { + var req UpdateRectifierSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + // 校验并清理自定义匹配关键词 + const maxPatterns = 50 + const maxPatternLen = 500 + if len(req.APIKeySignaturePatterns) > maxPatterns { + response.BadRequest(c, "Too many signature patterns (max 50)") + return + } + var cleanedPatterns []string + for _, p := range req.APIKeySignaturePatterns { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if len(p) > maxPatternLen { + response.BadRequest(c, "Signature pattern too long (max 500 characters)") + return + } + cleanedPatterns = append(cleanedPatterns, p) + } + + settings := &service.RectifierSettings{ + Enabled: req.Enabled, + ThinkingSignatureEnabled: req.ThinkingSignatureEnabled, + ThinkingBudgetEnabled: req.ThinkingBudgetEnabled, + APIKeySignatureEnabled: req.APIKeySignatureEnabled, + APIKeySignaturePatterns: cleanedPatterns, + } + + if err := h.settingService.SetRectifierSettings(c.Request.Context(), settings); err != nil { + response.BadRequest(c, err.Error()) + return + } + + // 重新获取设置返回 + updatedSettings, err := h.settingService.GetRectifierSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + updatedPatterns := updatedSettings.APIKeySignaturePatterns + if updatedPatterns == nil { + updatedPatterns = []string{} + } + response.Success(c, dto.RectifierSettings{ + Enabled: updatedSettings.Enabled, + ThinkingSignatureEnabled: updatedSettings.ThinkingSignatureEnabled, + ThinkingBudgetEnabled: updatedSettings.ThinkingBudgetEnabled, + APIKeySignatureEnabled: updatedSettings.APIKeySignatureEnabled, + APIKeySignaturePatterns: updatedPatterns, + }) +} + +// GetBetaPolicySettings 获取 Beta 策略配置 +// GET /api/v1/admin/settings/beta-policy +func (h *SettingHandler) GetBetaPolicySettings(c *gin.Context) { + settings, err := h.settingService.GetBetaPolicySettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + rules := make([]dto.BetaPolicyRule, len(settings.Rules)) + for i, r := range settings.Rules { + rules[i] = dto.BetaPolicyRule(r) + } + response.Success(c, dto.BetaPolicySettings{Rules: rules}) +} + +// UpdateBetaPolicySettingsRequest 更新 Beta 策略配置请求 +type UpdateBetaPolicySettingsRequest struct { + Rules []dto.BetaPolicyRule `json:"rules"` +} + +// UpdateBetaPolicySettings 更新 Beta 策略配置 +// PUT /api/v1/admin/settings/beta-policy +func (h *SettingHandler) UpdateBetaPolicySettings(c *gin.Context) { + var req UpdateBetaPolicySettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + rules := make([]service.BetaPolicyRule, len(req.Rules)) + for i, r := range req.Rules { + rules[i] = service.BetaPolicyRule(r) + } + + settings := &service.BetaPolicySettings{Rules: rules} + if err := h.settingService.SetBetaPolicySettings(c.Request.Context(), settings); err != nil { + response.BadRequest(c, err.Error()) + return + } + + // Re-fetch to return updated settings + updated, err := h.settingService.GetBetaPolicySettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + outRules := make([]dto.BetaPolicyRule, len(updated.Rules)) + for i, r := range updated.Rules { + outRules[i] = dto.BetaPolicyRule(r) + } + response.Success(c, dto.BetaPolicySettings{Rules: outRules}) +} + +// UpdateStreamTimeoutSettingsRequest 更新流超时配置请求 +type UpdateStreamTimeoutSettingsRequest struct { + Enabled bool `json:"enabled"` + Action string `json:"action"` + TempUnschedMinutes int `json:"temp_unsched_minutes"` + ThresholdCount int `json:"threshold_count"` + ThresholdWindowMinutes int `json:"threshold_window_minutes"` +} + +// UpdateStreamTimeoutSettings 更新流超时处理配置 +// PUT /api/v1/admin/settings/stream-timeout +func (h *SettingHandler) UpdateStreamTimeoutSettings(c *gin.Context) { + var req UpdateStreamTimeoutSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + settings := &service.StreamTimeoutSettings{ + Enabled: req.Enabled, + Action: req.Action, + TempUnschedMinutes: req.TempUnschedMinutes, + ThresholdCount: req.ThresholdCount, + ThresholdWindowMinutes: req.ThresholdWindowMinutes, + } + + if err := h.settingService.SetStreamTimeoutSettings(c.Request.Context(), settings); err != nil { + response.BadRequest(c, err.Error()) + return + } + + // 重新获取设置返回 + updatedSettings, err := h.settingService.GetStreamTimeoutSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.StreamTimeoutSettings{ + Enabled: updatedSettings.Enabled, + Action: updatedSettings.Action, + TempUnschedMinutes: updatedSettings.TempUnschedMinutes, + ThresholdCount: updatedSettings.ThresholdCount, + ThresholdWindowMinutes: updatedSettings.ThresholdWindowMinutes, + }) +} + +// GetWebSearchEmulationConfig 获取 Web Search 模拟配置 +// GET /api/v1/admin/settings/web-search-emulation +func (h *SettingHandler) GetWebSearchEmulationConfig(c *gin.Context) { + cfg, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, service.PopulateWebSearchUsage(c.Request.Context(), cfg)) +} + +// UpdateWebSearchEmulationConfig 更新 Web Search 模拟配置 +// PUT /api/v1/admin/settings/web-search-emulation +func (h *SettingHandler) UpdateWebSearchEmulationConfig(c *gin.Context) { + var cfg service.WebSearchEmulationConfig + if err := c.ShouldBindJSON(&cfg); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + if err := h.settingService.SaveWebSearchEmulationConfig(c.Request.Context(), &cfg); err != nil { + response.ErrorFrom(c, err) + return + } + + // Re-read (with sanitized api keys) to return current state + updated, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, service.PopulateWebSearchUsage(c.Request.Context(), updated)) +} + +// ResetWebSearchUsage 重置指定 provider 的配额用量 +// POST /api/v1/admin/settings/web-search-emulation/reset-usage +func (h *SettingHandler) ResetWebSearchUsage(c *gin.Context) { + var req struct { + ProviderType string `json:"provider_type"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if req.ProviderType == "" { + response.BadRequest(c, "provider_type is required") + return + } + if err := service.ResetWebSearchUsage(c.Request.Context(), req.ProviderType); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, nil) +} + +// TestWebSearchEmulation 测试 Web Search 搜索 +// POST /api/v1/admin/settings/web-search-emulation/test +func (h *SettingHandler) TestWebSearchEmulation(c *gin.Context) { + var req struct { + Query string `json:"query"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if strings.TrimSpace(req.Query) == "" { + req.Query = "搜索今年世界大事件" + } + + result, err := service.TestWebSearch(c.Request.Context(), req.Query) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go new file mode 100644 index 0000000000..b4c1f3afc1 --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -0,0 +1,1985 @@ +package admin + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// UpdateSettingsRequest 更新设置请求 +type UpdateSettingsRequest struct { + // 注册设置 + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + FrontendURL string `json:"frontend_url"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementDocuments []dto.LoginAgreementDocument `json:"login_agreement_documents"` + + // 邮件服务设置 + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password"` + SMTPFrom string `json:"smtp_from_email"` + SMTPFromName string `json:"smtp_from_name"` + SMTPUseTLS bool `json:"smtp_use_tls"` + + // Cloudflare Turnstile 设置 + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + TurnstileSecretKey string `json:"turnstile_secret_key"` + + // API Key IP 访问控制设置 + APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"` + + // LinuxDo Connect OAuth 登录 + LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"` + LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"` + LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"` + LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` + + // DingTalk Connect OAuth 登录 + DingTalkConnectEnabled bool `json:"dingtalk_connect_enabled"` + DingTalkConnectClientID string `json:"dingtalk_connect_client_id"` + DingTalkConnectClientSecret string `json:"dingtalk_connect_client_secret"` + DingTalkConnectRedirectURL string `json:"dingtalk_connect_redirect_url"` + DingTalkConnectCorpRestrictionPolicy string `json:"dingtalk_connect_corp_restriction_policy"` + DingTalkConnectInternalCorpID string `json:"dingtalk_connect_internal_corp_id"` + DingTalkConnectBypassRegistration bool `json:"dingtalk_connect_bypass_registration"` + DingTalkConnectSyncCorpEmail bool `json:"dingtalk_connect_sync_corp_email"` + DingTalkConnectSyncDisplayName bool `json:"dingtalk_connect_sync_display_name"` + DingTalkConnectSyncDept bool `json:"dingtalk_connect_sync_dept"` + DingTalkConnectSyncCorpEmailAttrKey string `json:"dingtalk_connect_sync_corp_email_attr_key"` + DingTalkConnectSyncDisplayNameAttrKey string `json:"dingtalk_connect_sync_display_name_attr_key"` + DingTalkConnectSyncDeptAttrKey string `json:"dingtalk_connect_sync_dept_attr_key"` + DingTalkConnectSyncCorpEmailAttrName string `json:"dingtalk_connect_sync_corp_email_attr_name"` + DingTalkConnectSyncDisplayNameAttrName string `json:"dingtalk_connect_sync_display_name_attr_name"` + DingTalkConnectSyncDeptAttrName string `json:"dingtalk_connect_sync_dept_attr_name"` + + // WeChat Connect OAuth 登录 + WeChatConnectEnabled bool `json:"wechat_connect_enabled"` + WeChatConnectAppID string `json:"wechat_connect_app_id"` + WeChatConnectAppSecret string `json:"wechat_connect_app_secret"` + WeChatConnectOpenAppID string `json:"wechat_connect_open_app_id"` + WeChatConnectOpenAppSecret string `json:"wechat_connect_open_app_secret"` + WeChatConnectMPAppID string `json:"wechat_connect_mp_app_id"` + WeChatConnectMPAppSecret string `json:"wechat_connect_mp_app_secret"` + WeChatConnectMobileAppID string `json:"wechat_connect_mobile_app_id"` + WeChatConnectMobileAppSecret string `json:"wechat_connect_mobile_app_secret"` + WeChatConnectOpenEnabled bool `json:"wechat_connect_open_enabled"` + WeChatConnectMPEnabled bool `json:"wechat_connect_mp_enabled"` + WeChatConnectMobileEnabled bool `json:"wechat_connect_mobile_enabled"` + WeChatConnectMode string `json:"wechat_connect_mode"` + WeChatConnectScopes string `json:"wechat_connect_scopes"` + WeChatConnectRedirectURL string `json:"wechat_connect_redirect_url"` + WeChatConnectFrontendRedirectURL string `json:"wechat_connect_frontend_redirect_url"` + + // Generic OIDC OAuth 登录 + OIDCConnectEnabled bool `json:"oidc_connect_enabled"` + OIDCConnectProviderName string `json:"oidc_connect_provider_name"` + OIDCConnectClientID string `json:"oidc_connect_client_id"` + OIDCConnectClientSecret string `json:"oidc_connect_client_secret"` + OIDCConnectIssuerURL string `json:"oidc_connect_issuer_url"` + OIDCConnectDiscoveryURL string `json:"oidc_connect_discovery_url"` + OIDCConnectAuthorizeURL string `json:"oidc_connect_authorize_url"` + OIDCConnectTokenURL string `json:"oidc_connect_token_url"` + OIDCConnectUserInfoURL string `json:"oidc_connect_userinfo_url"` + OIDCConnectJWKSURL string `json:"oidc_connect_jwks_url"` + OIDCConnectScopes string `json:"oidc_connect_scopes"` + OIDCConnectRedirectURL string `json:"oidc_connect_redirect_url"` + OIDCConnectFrontendRedirectURL string `json:"oidc_connect_frontend_redirect_url"` + OIDCConnectTokenAuthMethod string `json:"oidc_connect_token_auth_method"` + OIDCConnectUsePKCE *bool `json:"oidc_connect_use_pkce"` + OIDCConnectValidateIDToken *bool `json:"oidc_connect_validate_id_token"` + OIDCConnectAllowedSigningAlgs string `json:"oidc_connect_allowed_signing_algs"` + OIDCConnectClockSkewSeconds int `json:"oidc_connect_clock_skew_seconds"` + OIDCConnectRequireEmailVerified bool `json:"oidc_connect_require_email_verified"` + OIDCConnectUserInfoEmailPath string `json:"oidc_connect_userinfo_email_path"` + OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"` + OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"` + + GitHubOAuthEnabled bool `json:"github_oauth_enabled"` + GitHubOAuthClientID string `json:"github_oauth_client_id"` + GitHubOAuthClientSecret string `json:"github_oauth_client_secret"` + GitHubOAuthRedirectURL string `json:"github_oauth_redirect_url"` + GitHubOAuthFrontendRedirectURL string `json:"github_oauth_frontend_redirect_url"` + GoogleOAuthEnabled bool `json:"google_oauth_enabled"` + GoogleOAuthClientID string `json:"google_oauth_client_id"` + GoogleOAuthClientSecret string `json:"google_oauth_client_secret"` + GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"` + GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"` + + // OEM设置 + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL *string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"` + CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"` + + // 默认配置 + DefaultConcurrency int `json:"default_concurrency"` + DefaultBalance float64 `json:"default_balance"` + AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"` + AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"` + AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"` + AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"` + DefaultUserRPMLimit int `json:"default_user_rpm_limit"` + DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"` + AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"` + AuthSourceDefaultEmailConcurrency *int `json:"auth_source_default_email_concurrency"` + AuthSourceDefaultEmailSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_email_subscriptions"` + AuthSourceDefaultEmailGrantOnSignup *bool `json:"auth_source_default_email_grant_on_signup"` + AuthSourceDefaultEmailGrantOnFirstBind *bool `json:"auth_source_default_email_grant_on_first_bind"` + AuthSourceDefaultLinuxDoBalance *float64 `json:"auth_source_default_linuxdo_balance"` + AuthSourceDefaultLinuxDoConcurrency *int `json:"auth_source_default_linuxdo_concurrency"` + AuthSourceDefaultLinuxDoSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_linuxdo_subscriptions"` + AuthSourceDefaultLinuxDoGrantOnSignup *bool `json:"auth_source_default_linuxdo_grant_on_signup"` + AuthSourceDefaultLinuxDoGrantOnFirstBind *bool `json:"auth_source_default_linuxdo_grant_on_first_bind"` + AuthSourceDefaultOIDCBalance *float64 `json:"auth_source_default_oidc_balance"` + AuthSourceDefaultOIDCConcurrency *int `json:"auth_source_default_oidc_concurrency"` + AuthSourceDefaultOIDCSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_oidc_subscriptions"` + AuthSourceDefaultOIDCGrantOnSignup *bool `json:"auth_source_default_oidc_grant_on_signup"` + AuthSourceDefaultOIDCGrantOnFirstBind *bool `json:"auth_source_default_oidc_grant_on_first_bind"` + AuthSourceDefaultWeChatBalance *float64 `json:"auth_source_default_wechat_balance"` + AuthSourceDefaultWeChatConcurrency *int `json:"auth_source_default_wechat_concurrency"` + AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"` + AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"` + AuthSourceDefaultWeChatGrantOnFirstBind *bool `json:"auth_source_default_wechat_grant_on_first_bind"` + AuthSourceDefaultGitHubBalance *float64 `json:"auth_source_default_github_balance"` + AuthSourceDefaultGitHubConcurrency *int `json:"auth_source_default_github_concurrency"` + AuthSourceDefaultGitHubSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_github_subscriptions"` + AuthSourceDefaultGitHubGrantOnSignup *bool `json:"auth_source_default_github_grant_on_signup"` + AuthSourceDefaultGitHubGrantOnFirstBind *bool `json:"auth_source_default_github_grant_on_first_bind"` + AuthSourceDefaultGoogleBalance *float64 `json:"auth_source_default_google_balance"` + AuthSourceDefaultGoogleConcurrency *int `json:"auth_source_default_google_concurrency"` + AuthSourceDefaultGoogleSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_google_subscriptions"` + AuthSourceDefaultGoogleGrantOnSignup *bool `json:"auth_source_default_google_grant_on_signup"` + AuthSourceDefaultGoogleGrantOnFirstBind *bool `json:"auth_source_default_google_grant_on_first_bind"` + AuthSourceDefaultDingTalkBalance *float64 `json:"auth_source_default_dingtalk_balance"` + AuthSourceDefaultDingTalkConcurrency *int `json:"auth_source_default_dingtalk_concurrency"` + AuthSourceDefaultDingTalkSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_dingtalk_subscriptions"` + AuthSourceDefaultDingTalkGrantOnSignup *bool `json:"auth_source_default_dingtalk_grant_on_signup"` + AuthSourceDefaultDingTalkGrantOnFirstBind *bool `json:"auth_source_default_dingtalk_grant_on_first_bind"` + ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"` + + // Model fallback configuration + EnableModelFallback bool `json:"enable_model_fallback"` + FallbackModelAnthropic string `json:"fallback_model_anthropic"` + FallbackModelOpenAI string `json:"fallback_model_openai"` + FallbackModelGemini string `json:"fallback_model_gemini"` + FallbackModelAntigravity string `json:"fallback_model_antigravity"` + + // Identity patch configuration (Claude -> Gemini) + EnableIdentityPatch bool `json:"enable_identity_patch"` + IdentityPatchPrompt string `json:"identity_patch_prompt"` + + // Ops monitoring (vNext) + OpsMonitoringEnabled *bool `json:"ops_monitoring_enabled"` + OpsRealtimeMonitoringEnabled *bool `json:"ops_realtime_monitoring_enabled"` + OpsQueryModeDefault *string `json:"ops_query_mode_default"` + OpsMetricsIntervalSeconds *int `json:"ops_metrics_interval_seconds"` + + MinClaudeCodeVersion string `json:"min_claude_code_version"` + MaxClaudeCodeVersion string `json:"max_claude_code_version"` + + // 分组隔离 + AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"` + + // Backend Mode + BackendModeEnabled bool `json:"backend_mode_enabled"` + + // Gateway forwarding behavior + EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"` + EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"` + EnableCCHSigning *bool `json:"enable_cch_signing"` + EnableClaudeOAuthSystemPromptInjection *bool `json:"enable_claude_oauth_system_prompt_injection"` + ClaudeOAuthSystemPrompt *string `json:"claude_oauth_system_prompt"` + ClaudeOAuthSystemPromptBlocks *string `json:"claude_oauth_system_prompt_blocks"` + EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"` + RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"` + EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"` + AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"` + OpenAICodexUserAgent *string `json:"openai_codex_user_agent"` + + // codex_cli_only 加固(global-only) + MinCodexVersion string `json:"min_codex_version"` + MaxCodexVersion string `json:"max_codex_version"` + CodexCLIOnlyBlacklist string `json:"codex_cli_only_blacklist"` + CodexCLIOnlyWhitelist string `json:"codex_cli_only_whitelist"` + CodexCLIOnlyAllowAppServerClients *bool `json:"codex_cli_only_allow_app_server_clients"` + CodexCLIOnlyEngineFingerprintSignals string `json:"codex_cli_only_engine_fingerprint_signals"` + + // Payment visible method routing + PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"` + PaymentVisibleMethodWxpaySource *string `json:"payment_visible_method_wxpay_source"` + PaymentVisibleMethodAlipayEnabled *bool `json:"payment_visible_method_alipay_enabled"` + PaymentVisibleMethodWxpayEnabled *bool `json:"payment_visible_method_wxpay_enabled"` + + // OpenAI account scheduling + OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"` + OpenAIAdvancedSchedulerStickyWeightedEnabled *bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"` + OpenAIAdvancedSchedulerSubscriptionPriorityEnabled *bool `json:"openai_advanced_scheduler_subscription_priority_enabled"` + OpenAIAdvancedSchedulerLBTopK *string `json:"openai_advanced_scheduler_lb_top_k"` + OpenAIAdvancedSchedulerWeightPriority *string `json:"openai_advanced_scheduler_weight_priority"` + OpenAIAdvancedSchedulerWeightLoad *string `json:"openai_advanced_scheduler_weight_load"` + OpenAIAdvancedSchedulerWeightQueue *string `json:"openai_advanced_scheduler_weight_queue"` + OpenAIAdvancedSchedulerWeightErrorRate *string `json:"openai_advanced_scheduler_weight_error_rate"` + OpenAIAdvancedSchedulerWeightTTFT *string `json:"openai_advanced_scheduler_weight_ttft"` + OpenAIAdvancedSchedulerWeightReset *string `json:"openai_advanced_scheduler_weight_reset"` + OpenAIAdvancedSchedulerWeightQuotaHeadroom *string `json:"openai_advanced_scheduler_weight_quota_headroom"` + OpenAIAdvancedSchedulerWeightPreviousResponse *string `json:"openai_advanced_scheduler_weight_previous_response"` + OpenAIAdvancedSchedulerWeightSessionSticky *string `json:"openai_advanced_scheduler_weight_session_sticky"` + + // 余额不足提醒 + BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"` + BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyRechargeURL *string `json:"balance_low_notify_recharge_url"` + SubscriptionExpiryNotifyEnabled *bool `json:"subscription_expiry_notify_enabled"` + AccountQuotaNotifyEnabled *bool `json:"account_quota_notify_enabled"` + AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"` + + // Payment configuration (integrated into settings, full replace) + PaymentEnabled *bool `json:"payment_enabled"` + PaymentMinAmount *float64 `json:"payment_min_amount"` + PaymentMaxAmount *float64 `json:"payment_max_amount"` + PaymentDailyLimit *float64 `json:"payment_daily_limit"` + PaymentOrderTimeoutMin *int `json:"payment_order_timeout_minutes"` + PaymentMaxPendingOrders *int `json:"payment_max_pending_orders"` + PaymentEnabledTypes []string `json:"payment_enabled_types"` + PaymentBalanceDisabled *bool `json:"payment_balance_disabled"` + PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"` + PaymentSubscriptionUSDToCNYRate *float64 `json:"payment_subscription_usd_to_cny_rate"` + PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"` + PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"` + PaymentProductNamePrefix *string `json:"payment_product_name_prefix"` + PaymentProductNameSuffix *string `json:"payment_product_name_suffix"` + PaymentHelpImageURL *string `json:"payment_help_image_url"` + PaymentHelpText *string `json:"payment_help_text"` + + // Cancel rate limit + PaymentCancelRateLimitEnabled *bool `json:"payment_cancel_rate_limit_enabled"` + PaymentCancelRateLimitMax *int `json:"payment_cancel_rate_limit_max"` + PaymentCancelRateLimitWindow *int `json:"payment_cancel_rate_limit_window"` + PaymentCancelRateLimitUnit *string `json:"payment_cancel_rate_limit_unit"` + PaymentCancelRateLimitMode *string `json:"payment_cancel_rate_limit_window_mode"` + + // Force Alipay mobile clients to use QR code payment instead of mobile redirect + PaymentAlipayForceQRCode *bool `json:"payment_alipay_force_qrcode"` + + // Channel Monitor feature switch + ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"` + ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"` + + // Available Channels feature switch (user-facing) + AvailableChannelsEnabled *bool `json:"available_channels_enabled"` + + // Affiliate (邀请返利) feature switch + AffiliateEnabled *bool `json:"affiliate_enabled"` + + // 风控中心功能开关 + RiskControlEnabled *bool `json:"risk_control_enabled"` + + // cyber 会话屏蔽开关 + TTL + CyberSessionBlockEnabled *bool `json:"cyber_session_block_enabled"` + CyberSessionBlockTTLSeconds *int `json:"cyber_session_block_ttl_seconds"` + + // OpenAI fast/flex policy (optional, only updated when provided) + OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"` + + // 系统全局 platform quota 默认值(整体替换语义:nil = 不修改,non-nil = 整体覆盖)。 + DefaultPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"default_platform_quotas"` + + // auth-source 层 platform quota 覆盖(override 语义:nil = 不修改,non-nil = 整体覆盖该 source 的 quota 配置)。 + AuthSourceEmailPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_email_platform_quotas"` + AuthSourceLinuxDoPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_linuxdo_platform_quotas"` + AuthSourceOIDCPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_oidc_platform_quotas"` + AuthSourceWeChatPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_wechat_platform_quotas"` + AuthSourceGitHubPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_github_platform_quotas"` + AuthSourceGooglePlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_google_platform_quotas"` + AuthSourceDingTalkPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_dingtalk_platform_quotas"` + + AllowUserViewErrorRequests *bool `json:"allow_user_view_error_requests"` +} + +// UpdateSettings 更新系统设置 +// PUT /api/v1/admin/settings +func (h *SettingHandler) UpdateSettings(c *gin.Context) { + var req UpdateSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + previousSettings, err := h.settingService.GetAllSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + previousAuthSourceDefaults, err := h.settingService.GetAuthSourceDefaultSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + // 验证参数 + if req.DefaultConcurrency < 1 { + req.DefaultConcurrency = 1 + } + if req.DefaultBalance < 0 { + req.DefaultBalance = 0 + } + affiliateRebateRate := previousSettings.AffiliateRebateRate + if req.AffiliateRebateRate != nil { + affiliateRebateRate = *req.AffiliateRebateRate + } + if affiliateRebateRate < service.AffiliateRebateRateMin { + affiliateRebateRate = service.AffiliateRebateRateMin + } + if affiliateRebateRate > service.AffiliateRebateRateMax { + affiliateRebateRate = service.AffiliateRebateRateMax + } + affiliateRebateFreezeHours := previousSettings.AffiliateRebateFreezeHours + if req.AffiliateRebateFreezeHours != nil { + affiliateRebateFreezeHours = *req.AffiliateRebateFreezeHours + } + if affiliateRebateFreezeHours < 0 { + affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursDefault + } + if affiliateRebateFreezeHours > service.AffiliateRebateFreezeHoursMax { + affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursMax + } + affiliateRebateDurationDays := previousSettings.AffiliateRebateDurationDays + if req.AffiliateRebateDurationDays != nil { + affiliateRebateDurationDays = *req.AffiliateRebateDurationDays + } + if affiliateRebateDurationDays < 0 { + affiliateRebateDurationDays = service.AffiliateRebateDurationDaysDefault + } + if affiliateRebateDurationDays > service.AffiliateRebateDurationDaysMax { + affiliateRebateDurationDays = service.AffiliateRebateDurationDaysMax + } + affiliateRebatePerInviteeCap := previousSettings.AffiliateRebatePerInviteeCap + if req.AffiliateRebatePerInviteeCap != nil { + affiliateRebatePerInviteeCap = *req.AffiliateRebatePerInviteeCap + } + if affiliateRebatePerInviteeCap < 0 { + affiliateRebatePerInviteeCap = service.AffiliateRebatePerInviteeCapDefault + } + // 通用表格配置:兼容旧客户端未传字段时保留当前值。 + if req.TableDefaultPageSize <= 0 { + req.TableDefaultPageSize = previousSettings.TableDefaultPageSize + } + if req.TablePageSizeOptions == nil { + req.TablePageSizeOptions = previousSettings.TablePageSizeOptions + } + req.SMTPHost = strings.TrimSpace(req.SMTPHost) + req.SMTPUsername = strings.TrimSpace(req.SMTPUsername) + req.SMTPPassword = strings.TrimSpace(req.SMTPPassword) + req.SMTPFrom = strings.TrimSpace(req.SMTPFrom) + req.SMTPFromName = strings.TrimSpace(req.SMTPFromName) + if req.SMTPPort <= 0 { + req.SMTPPort = 587 + } + req.DefaultSubscriptions = normalizeDefaultSubscriptions(req.DefaultSubscriptions) + req.AuthSourceDefaultEmailSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultEmailSubscriptions) + req.AuthSourceDefaultLinuxDoSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultLinuxDoSubscriptions) + req.AuthSourceDefaultOIDCSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultOIDCSubscriptions) + req.AuthSourceDefaultWeChatSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultWeChatSubscriptions) + req.AuthSourceDefaultDingTalkSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultDingTalkSubscriptions) + + // SMTP 配置保护:如果请求中 smtp_host 为空但数据库中已有配置,则保留已有 SMTP 配置 + // 防止前端加载设置失败时空表单覆盖已保存的 SMTP 配置 + if req.SMTPHost == "" && previousSettings.SMTPHost != "" { + req.SMTPHost = previousSettings.SMTPHost + req.SMTPPort = previousSettings.SMTPPort + req.SMTPUsername = previousSettings.SMTPUsername + req.SMTPFrom = previousSettings.SMTPFrom + req.SMTPFromName = previousSettings.SMTPFromName + req.SMTPUseTLS = previousSettings.SMTPUseTLS + } + + // Turnstile 参数验证 + if req.TurnstileEnabled { + // 检查必填字段 + if req.TurnstileSiteKey == "" { + response.BadRequest(c, "Turnstile Site Key is required when enabled") + return + } + // 如果未提供 secret key,使用已保存的值(留空保留当前值) + if req.TurnstileSecretKey == "" { + if previousSettings.TurnstileSecretKey == "" { + response.BadRequest(c, "Turnstile Secret Key is required when enabled") + return + } + req.TurnstileSecretKey = previousSettings.TurnstileSecretKey + } + + // 当 site_key 或 secret_key 任一变化时验证(避免配置错误导致无法登录) + siteKeyChanged := previousSettings.TurnstileSiteKey != req.TurnstileSiteKey + secretKeyChanged := previousSettings.TurnstileSecretKey != req.TurnstileSecretKey + if siteKeyChanged || secretKeyChanged { + if err := h.turnstileService.ValidateSecretKey(c.Request.Context(), req.TurnstileSecretKey); err != nil { + response.ErrorFrom(c, err) + return + } + } + } + + // TOTP 双因素认证参数验证 + // 只有手动配置了加密密钥才允许启用 TOTP 功能 + if req.TotpEnabled && !previousSettings.TotpEnabled { + // 尝试启用 TOTP,检查加密密钥是否已手动配置 + if !h.settingService.IsTotpEncryptionKeyConfigured() { + response.BadRequest(c, "Cannot enable TOTP: TOTP_ENCRYPTION_KEY environment variable must be configured first. Generate a key with 'openssl rand -hex 32' and set it in your environment.") + return + } + } + loginAgreementMode := strings.ToLower(strings.TrimSpace(req.LoginAgreementMode)) + if loginAgreementMode == "" { + loginAgreementMode = strings.ToLower(strings.TrimSpace(previousSettings.LoginAgreementMode)) + } + switch loginAgreementMode { + case "", "modal": + loginAgreementMode = "modal" + case "checkbox": + default: + response.BadRequest(c, "Login agreement mode must be modal or checkbox") + return + } + loginAgreementUpdatedAt := strings.TrimSpace(req.LoginAgreementUpdatedAt) + if loginAgreementUpdatedAt == "" { + loginAgreementUpdatedAt = strings.TrimSpace(previousSettings.LoginAgreementUpdatedAt) + } + loginAgreementDocuments := loginAgreementDocumentsToService(req.LoginAgreementDocuments) + if len(loginAgreementDocuments) == 0 { + loginAgreementDocuments = previousSettings.LoginAgreementDocuments + } + for _, doc := range loginAgreementDocuments { + if strings.TrimSpace(doc.Title) == "" { + response.BadRequest(c, "Login agreement document title is required") + return + } + if len(doc.Title) > 80 { + response.BadRequest(c, "Login agreement document title is too long (max 80 characters)") + return + } + if len(doc.ContentMD) > 200*1024 { + response.BadRequest(c, "Login agreement document content is too large (max 200KB)") + return + } + } + if req.LoginAgreementEnabled && len(loginAgreementDocuments) == 0 { + response.BadRequest(c, "Login agreement documents are required when enabled") + return + } + + // LinuxDo Connect 参数验证 + if req.LinuxDoConnectEnabled { + req.LinuxDoConnectClientID = strings.TrimSpace(req.LinuxDoConnectClientID) + req.LinuxDoConnectClientSecret = strings.TrimSpace(req.LinuxDoConnectClientSecret) + req.LinuxDoConnectRedirectURL = strings.TrimSpace(req.LinuxDoConnectRedirectURL) + + if req.LinuxDoConnectClientID == "" { + response.BadRequest(c, "LinuxDo Client ID is required when enabled") + return + } + if req.LinuxDoConnectRedirectURL == "" { + response.BadRequest(c, "LinuxDo Redirect URL is required when enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(req.LinuxDoConnectRedirectURL); err != nil { + response.BadRequest(c, "LinuxDo Redirect URL must be an absolute http(s) URL") + return + } + + // 如果未提供 client_secret,则保留现有值(如有)。 + if req.LinuxDoConnectClientSecret == "" { + if previousSettings.LinuxDoConnectClientSecret == "" { + response.BadRequest(c, "LinuxDo Client Secret is required when enabled") + return + } + req.LinuxDoConnectClientSecret = previousSettings.LinuxDoConnectClientSecret + } + } + + // DingTalk Connect 参数验证 + // 防御性:任何写入路径上把已废弃的 corp_restriction_policy=whitelist 入参 coerce 为 none, + // 避免任何直连 admin API 的客户端把死值写回 DB(前端 UI 已无此选项)。 + req.DingTalkConnectCorpRestrictionPolicy = service.CoerceDingTalkCorpPolicyForWrite(req.DingTalkConnectCorpRestrictionPolicy) + + if req.DingTalkConnectEnabled { + req.DingTalkConnectClientID = strings.TrimSpace(req.DingTalkConnectClientID) + req.DingTalkConnectClientSecret = strings.TrimSpace(req.DingTalkConnectClientSecret) + req.DingTalkConnectRedirectURL = strings.TrimSpace(req.DingTalkConnectRedirectURL) + req.DingTalkConnectCorpRestrictionPolicy = strings.TrimSpace(req.DingTalkConnectCorpRestrictionPolicy) + req.DingTalkConnectInternalCorpID = strings.TrimSpace(req.DingTalkConnectInternalCorpID) + + if req.DingTalkConnectClientID == "" { + response.BadRequest(c, "DingTalk Client ID is required when enabled") + return + } + if req.DingTalkConnectRedirectURL == "" { + response.BadRequest(c, "DingTalk Redirect URL is required when enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(req.DingTalkConnectRedirectURL); err != nil { + response.BadRequest(c, "DingTalk Redirect URL must be an absolute http(s) URL") + return + } + + // 如果未提供 client_secret,则保留现有值(如有)。 + if req.DingTalkConnectClientSecret == "" { + if previousSettings.DingTalkConnectClientSecret == "" { + response.BadRequest(c, "DingTalk Client Secret is required when enabled") + return + } + req.DingTalkConnectClientSecret = previousSettings.DingTalkConnectClientSecret + } + + // Corp 策略校验(V1/V4 fail-closed) + dingTalkCfg := config.DingTalkConnectConfig{ + Enabled: true, + DingTalkAppKind: "internal_app", // 硬编码:settings 层仅支持 internal_app + AppType: "internal", // 对于 internal_only 策略的默认值 + CorpRestrictionPolicy: req.DingTalkConnectCorpRestrictionPolicy, + InternalCorpID: req.DingTalkConnectInternalCorpID, + } + // 若未填 corp_restriction_policy,保留已有配置 + if dingTalkCfg.CorpRestrictionPolicy == "" { + dingTalkCfg.CorpRestrictionPolicy = previousSettings.DingTalkConnectCorpRestrictionPolicy + } + // 对于 internal_only 策略,app_type 必须为 internal(V1 校验) + if dingTalkCfg.CorpRestrictionPolicy == "internal_only" { + dingTalkCfg.AppType = "internal" + } else { + dingTalkCfg.AppType = "public" + } + if err := config.ValidateDingTalkConfig(dingTalkCfg); err != nil { + response.ErrorWithDetails(c, http.StatusBadRequest, err.Error(), mapDingTalkValidateError(err), nil) + return + } + + // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制为 false, + // 防止 admin 在切换 policy 时把 bypass 残留在 DB 中(前端 UI 也已隐藏该开关)。 + if dingTalkCfg.CorpRestrictionPolicy != "internal_only" { + req.DingTalkConnectBypassRegistration = false + // 身份同步三开关同理:仅 internal_only 模式下有意义,其它策略强制 false。 + req.DingTalkConnectSyncCorpEmail = false + req.DingTalkConnectSyncDisplayName = false + req.DingTalkConnectSyncDept = false + } + // 身份同步目标 attr key:trimSpace + 空值 fallback 到默认值 + req.DingTalkConnectSyncCorpEmailAttrKey = strings.TrimSpace(req.DingTalkConnectSyncCorpEmailAttrKey) + if req.DingTalkConnectSyncCorpEmailAttrKey == "" { + req.DingTalkConnectSyncCorpEmailAttrKey = "dingtalk_email" + } + req.DingTalkConnectSyncDisplayNameAttrKey = strings.TrimSpace(req.DingTalkConnectSyncDisplayNameAttrKey) + if req.DingTalkConnectSyncDisplayNameAttrKey == "" { + req.DingTalkConnectSyncDisplayNameAttrKey = "dingtalk_name" + } + req.DingTalkConnectSyncDeptAttrKey = strings.TrimSpace(req.DingTalkConnectSyncDeptAttrKey) + if req.DingTalkConnectSyncDeptAttrKey == "" { + req.DingTalkConnectSyncDeptAttrKey = "dingtalk_department" + } + // 身份同步目标 attr 显示名称:trim + 空值 fallback 到默认中文名 + req.DingTalkConnectSyncCorpEmailAttrName = strings.TrimSpace(req.DingTalkConnectSyncCorpEmailAttrName) + if req.DingTalkConnectSyncCorpEmailAttrName == "" { + req.DingTalkConnectSyncCorpEmailAttrName = "钉钉企业邮箱" + } + req.DingTalkConnectSyncDisplayNameAttrName = strings.TrimSpace(req.DingTalkConnectSyncDisplayNameAttrName) + if req.DingTalkConnectSyncDisplayNameAttrName == "" { + req.DingTalkConnectSyncDisplayNameAttrName = "钉钉姓名" + } + req.DingTalkConnectSyncDeptAttrName = strings.TrimSpace(req.DingTalkConnectSyncDeptAttrName) + if req.DingTalkConnectSyncDeptAttrName == "" { + req.DingTalkConnectSyncDeptAttrName = "钉钉部门" + } + } + + if req.WeChatConnectEnabled { + req.WeChatConnectAppID = strings.TrimSpace(req.WeChatConnectAppID) + req.WeChatConnectAppSecret = strings.TrimSpace(req.WeChatConnectAppSecret) + req.WeChatConnectOpenAppID = strings.TrimSpace(req.WeChatConnectOpenAppID) + req.WeChatConnectOpenAppSecret = strings.TrimSpace(req.WeChatConnectOpenAppSecret) + req.WeChatConnectMPAppID = strings.TrimSpace(req.WeChatConnectMPAppID) + req.WeChatConnectMPAppSecret = strings.TrimSpace(req.WeChatConnectMPAppSecret) + req.WeChatConnectMobileAppID = strings.TrimSpace(req.WeChatConnectMobileAppID) + req.WeChatConnectMobileAppSecret = strings.TrimSpace(req.WeChatConnectMobileAppSecret) + req.WeChatConnectMode = strings.ToLower(strings.TrimSpace(req.WeChatConnectMode)) + req.WeChatConnectScopes = strings.TrimSpace(req.WeChatConnectScopes) + req.WeChatConnectRedirectURL = strings.TrimSpace(req.WeChatConnectRedirectURL) + req.WeChatConnectFrontendRedirectURL = strings.TrimSpace(req.WeChatConnectFrontendRedirectURL) + req.WeChatConnectAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectAppID, previousSettings.WeChatConnectAppID)) + req.WeChatConnectRedirectURL = strings.TrimSpace(firstNonEmpty(req.WeChatConnectRedirectURL, previousSettings.WeChatConnectRedirectURL)) + req.WeChatConnectFrontendRedirectURL = strings.TrimSpace(firstNonEmpty(req.WeChatConnectFrontendRedirectURL, previousSettings.WeChatConnectFrontendRedirectURL)) + if req.WeChatConnectMode == "" { + req.WeChatConnectMode = strings.ToLower(strings.TrimSpace(previousSettings.WeChatConnectMode)) + } + if req.WeChatConnectScopes == "" { + req.WeChatConnectScopes = strings.TrimSpace(previousSettings.WeChatConnectScopes) + } + + if req.WeChatConnectMPEnabled && req.WeChatConnectMobileEnabled { + response.BadRequest(c, "WeChat Official Account and Mobile App cannot be enabled at the same time") + return + } + if req.WeChatConnectMode != "" { + switch req.WeChatConnectMode { + case "open", "mp", "mobile": + default: + response.BadRequest(c, "WeChat mode must be open, mp, or mobile") + return + } + } + if !req.WeChatConnectOpenEnabled && !req.WeChatConnectMPEnabled && !req.WeChatConnectMobileEnabled { + switch req.WeChatConnectMode { + case "mp": + req.WeChatConnectMPEnabled = true + case "mobile": + req.WeChatConnectMobileEnabled = true + default: + req.WeChatConnectOpenEnabled = true + } + } + if req.WeChatConnectMode == "" { + if req.WeChatConnectMPEnabled { + req.WeChatConnectMode = "mp" + } else if req.WeChatConnectMobileEnabled { + req.WeChatConnectMode = "mobile" + } else { + req.WeChatConnectMode = "open" + } + } + + req.WeChatConnectOpenAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectOpenAppID, req.WeChatConnectAppID, previousSettings.WeChatConnectOpenAppID, previousSettings.WeChatConnectAppID)) + req.WeChatConnectMPAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectMPAppID, req.WeChatConnectAppID, previousSettings.WeChatConnectMPAppID, previousSettings.WeChatConnectAppID)) + req.WeChatConnectMobileAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectMobileAppID, req.WeChatConnectAppID, previousSettings.WeChatConnectMobileAppID, previousSettings.WeChatConnectAppID)) + + if req.WeChatConnectOpenAppSecret == "" { + req.WeChatConnectOpenAppSecret = strings.TrimSpace(firstNonEmpty(previousSettings.WeChatConnectOpenAppSecret, previousSettings.WeChatConnectAppSecret, req.WeChatConnectAppSecret)) + } + if req.WeChatConnectMPAppSecret == "" { + req.WeChatConnectMPAppSecret = strings.TrimSpace(firstNonEmpty(previousSettings.WeChatConnectMPAppSecret, previousSettings.WeChatConnectAppSecret, req.WeChatConnectAppSecret)) + } + if req.WeChatConnectMobileAppSecret == "" { + req.WeChatConnectMobileAppSecret = strings.TrimSpace(firstNonEmpty(previousSettings.WeChatConnectMobileAppSecret, previousSettings.WeChatConnectAppSecret, req.WeChatConnectAppSecret)) + } + if req.WeChatConnectAppSecret == "" { + req.WeChatConnectAppSecret = strings.TrimSpace(firstNonEmpty(req.WeChatConnectOpenAppSecret, req.WeChatConnectMPAppSecret, req.WeChatConnectMobileAppSecret, previousSettings.WeChatConnectAppSecret)) + } + + if req.WeChatConnectOpenEnabled { + if req.WeChatConnectOpenAppID == "" { + response.BadRequest(c, "WeChat PC App ID is required when enabled") + return + } + if req.WeChatConnectOpenAppSecret == "" { + response.BadRequest(c, "WeChat PC App Secret is required when enabled") + return + } + } + if req.WeChatConnectMPEnabled { + if req.WeChatConnectMPAppID == "" { + response.BadRequest(c, "WeChat Official Account App ID is required when enabled") + return + } + if req.WeChatConnectMPAppSecret == "" { + response.BadRequest(c, "WeChat Official Account App Secret is required when enabled") + return + } + } + if req.WeChatConnectMobileEnabled { + if req.WeChatConnectMobileAppID == "" { + response.BadRequest(c, "WeChat Mobile App ID is required when enabled") + return + } + if req.WeChatConnectMobileAppSecret == "" { + response.BadRequest(c, "WeChat Mobile App Secret is required when enabled") + return + } + } + + if req.WeChatConnectScopes == "" { + if req.WeChatConnectMPEnabled { + req.WeChatConnectScopes = service.DefaultWeChatConnectScopesForMode("mp") + } else { + req.WeChatConnectScopes = service.DefaultWeChatConnectScopesForMode(req.WeChatConnectMode) + } + } + if req.WeChatConnectOpenEnabled || req.WeChatConnectMPEnabled { + if req.WeChatConnectRedirectURL == "" { + response.BadRequest(c, "WeChat Redirect URL is required when web oauth is enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(req.WeChatConnectRedirectURL); err != nil { + response.BadRequest(c, "WeChat Redirect URL must be an absolute http(s) URL") + return + } + if req.WeChatConnectFrontendRedirectURL == "" { + req.WeChatConnectFrontendRedirectURL = "/auth/wechat/callback" + } + if err := config.ValidateFrontendRedirectURL(req.WeChatConnectFrontendRedirectURL); err != nil { + response.BadRequest(c, "WeChat Frontend Redirect URL is invalid") + return + } + } + } + + // Generic OIDC 参数验证 + oidcUsePKCE, oidcValidateIDToken, err := h.settingService.OIDCSecurityWriteDefaults(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + if req.OIDCConnectEnabled { + req.OIDCConnectProviderName = strings.TrimSpace(req.OIDCConnectProviderName) + req.OIDCConnectClientID = strings.TrimSpace(req.OIDCConnectClientID) + req.OIDCConnectClientSecret = strings.TrimSpace(req.OIDCConnectClientSecret) + req.OIDCConnectIssuerURL = strings.TrimSpace(req.OIDCConnectIssuerURL) + req.OIDCConnectDiscoveryURL = strings.TrimSpace(req.OIDCConnectDiscoveryURL) + req.OIDCConnectAuthorizeURL = strings.TrimSpace(req.OIDCConnectAuthorizeURL) + req.OIDCConnectTokenURL = strings.TrimSpace(req.OIDCConnectTokenURL) + req.OIDCConnectUserInfoURL = strings.TrimSpace(req.OIDCConnectUserInfoURL) + req.OIDCConnectJWKSURL = strings.TrimSpace(req.OIDCConnectJWKSURL) + req.OIDCConnectScopes = strings.TrimSpace(req.OIDCConnectScopes) + req.OIDCConnectRedirectURL = strings.TrimSpace(req.OIDCConnectRedirectURL) + req.OIDCConnectFrontendRedirectURL = strings.TrimSpace(req.OIDCConnectFrontendRedirectURL) + req.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(req.OIDCConnectTokenAuthMethod)) + req.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(req.OIDCConnectAllowedSigningAlgs) + req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(req.OIDCConnectUserInfoEmailPath) + req.OIDCConnectUserInfoIDPath = strings.TrimSpace(req.OIDCConnectUserInfoIDPath) + req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(req.OIDCConnectUserInfoUsernamePath) + req.OIDCConnectProviderName = strings.TrimSpace(firstNonEmpty(req.OIDCConnectProviderName, previousSettings.OIDCConnectProviderName, "OIDC")) + req.OIDCConnectClientID = strings.TrimSpace(firstNonEmpty(req.OIDCConnectClientID, previousSettings.OIDCConnectClientID)) + req.OIDCConnectIssuerURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectIssuerURL, previousSettings.OIDCConnectIssuerURL)) + req.OIDCConnectDiscoveryURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectDiscoveryURL, previousSettings.OIDCConnectDiscoveryURL)) + req.OIDCConnectAuthorizeURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectAuthorizeURL, previousSettings.OIDCConnectAuthorizeURL)) + req.OIDCConnectTokenURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectTokenURL, previousSettings.OIDCConnectTokenURL)) + req.OIDCConnectUserInfoURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoURL, previousSettings.OIDCConnectUserInfoURL)) + req.OIDCConnectJWKSURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectJWKSURL, previousSettings.OIDCConnectJWKSURL)) + req.OIDCConnectScopes = strings.TrimSpace(firstNonEmpty(req.OIDCConnectScopes, previousSettings.OIDCConnectScopes, "openid email profile")) + req.OIDCConnectRedirectURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectRedirectURL, previousSettings.OIDCConnectRedirectURL)) + req.OIDCConnectFrontendRedirectURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectFrontendRedirectURL, previousSettings.OIDCConnectFrontendRedirectURL, "/auth/oidc/callback")) + req.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(firstNonEmpty(req.OIDCConnectTokenAuthMethod, previousSettings.OIDCConnectTokenAuthMethod, "client_secret_post"))) + req.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(firstNonEmpty(req.OIDCConnectAllowedSigningAlgs, previousSettings.OIDCConnectAllowedSigningAlgs, "RS256,ES256,PS256")) + req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoEmailPath, previousSettings.OIDCConnectUserInfoEmailPath)) + req.OIDCConnectUserInfoIDPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoIDPath, previousSettings.OIDCConnectUserInfoIDPath)) + req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoUsernamePath, previousSettings.OIDCConnectUserInfoUsernamePath)) + if req.OIDCConnectUsePKCE != nil { + oidcUsePKCE = *req.OIDCConnectUsePKCE + } + if req.OIDCConnectValidateIDToken != nil { + oidcValidateIDToken = *req.OIDCConnectValidateIDToken + } + if req.OIDCConnectClockSkewSeconds == 0 { + req.OIDCConnectClockSkewSeconds = previousSettings.OIDCConnectClockSkewSeconds + if req.OIDCConnectClockSkewSeconds == 0 { + req.OIDCConnectClockSkewSeconds = 120 + } + } + + if req.OIDCConnectClientID == "" { + response.BadRequest(c, "OIDC Client ID is required when enabled") + return + } + if req.OIDCConnectIssuerURL == "" { + response.BadRequest(c, "OIDC Issuer URL is required when enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectIssuerURL); err != nil { + response.BadRequest(c, "OIDC Issuer URL must be an absolute http(s) URL") + return + } + if req.OIDCConnectDiscoveryURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectDiscoveryURL); err != nil { + response.BadRequest(c, "OIDC Discovery URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectAuthorizeURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectAuthorizeURL); err != nil { + response.BadRequest(c, "OIDC Authorize URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectTokenURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectTokenURL); err != nil { + response.BadRequest(c, "OIDC Token URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectUserInfoURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectUserInfoURL); err != nil { + response.BadRequest(c, "OIDC UserInfo URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectRedirectURL == "" { + response.BadRequest(c, "OIDC Redirect URL is required when enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectRedirectURL); err != nil { + response.BadRequest(c, "OIDC Redirect URL must be an absolute http(s) URL") + return + } + if req.OIDCConnectFrontendRedirectURL == "" { + response.BadRequest(c, "OIDC Frontend Redirect URL is required when enabled") + return + } + if err := config.ValidateFrontendRedirectURL(req.OIDCConnectFrontendRedirectURL); err != nil { + response.BadRequest(c, "OIDC Frontend Redirect URL is invalid") + return + } + if !scopesContainOpenID(req.OIDCConnectScopes) { + response.BadRequest(c, "OIDC scopes must contain openid") + return + } + switch req.OIDCConnectTokenAuthMethod { + case "", "client_secret_post", "client_secret_basic", "none": + default: + response.BadRequest(c, "OIDC Token Auth Method must be one of client_secret_post/client_secret_basic/none") + return + } + if req.OIDCConnectClockSkewSeconds < 0 || req.OIDCConnectClockSkewSeconds > 600 { + response.BadRequest(c, "OIDC clock skew seconds must be between 0 and 600") + return + } + if oidcValidateIDToken && req.OIDCConnectAllowedSigningAlgs == "" { + response.BadRequest(c, "OIDC Allowed Signing Algs is required when validate_id_token=true") + return + } + if req.OIDCConnectJWKSURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectJWKSURL); err != nil { + response.BadRequest(c, "OIDC JWKS URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectTokenAuthMethod == "" || req.OIDCConnectTokenAuthMethod == "client_secret_post" || req.OIDCConnectTokenAuthMethod == "client_secret_basic" { + if req.OIDCConnectClientSecret == "" { + if previousSettings.OIDCConnectClientSecret == "" { + response.BadRequest(c, "OIDC Client Secret is required when enabled") + return + } + req.OIDCConnectClientSecret = previousSettings.OIDCConnectClientSecret + } + } + } + + // “购买订阅”页面配置验证 + purchaseEnabled := previousSettings.PurchaseSubscriptionEnabled + if req.PurchaseSubscriptionEnabled != nil { + purchaseEnabled = *req.PurchaseSubscriptionEnabled + } + purchaseURL := previousSettings.PurchaseSubscriptionURL + if req.PurchaseSubscriptionURL != nil { + purchaseURL = strings.TrimSpace(*req.PurchaseSubscriptionURL) + } + + // - 启用时要求 URL 合法且非空 + // - 禁用时允许为空;若提供了 URL 也做基本校验,避免误配置 + if purchaseEnabled { + if purchaseURL == "" { + response.BadRequest(c, "Purchase Subscription URL is required when enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(purchaseURL); err != nil { + response.BadRequest(c, "Purchase Subscription URL must be an absolute http(s) URL") + return + } + } else if purchaseURL != "" { + if err := config.ValidateAbsoluteHTTPURL(purchaseURL); err != nil { + response.BadRequest(c, "Purchase Subscription URL must be an absolute http(s) URL") + return + } + } + + // Frontend URL 验证 + req.FrontendURL = strings.TrimSpace(req.FrontendURL) + if req.FrontendURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.FrontendURL); err != nil { + response.BadRequest(c, "Frontend URL must be an absolute http(s) URL") + return + } + } + + // 自定义菜单项验证 + const ( + maxCustomMenuItems = 20 + maxMenuItemLabelLen = 50 + maxMenuItemURLLen = 2048 + maxMenuItemIconSVGLen = 10 * 1024 // 10KB + maxMenuItemIDLen = 32 + ) + + customMenuJSON := previousSettings.CustomMenuItems + if req.CustomMenuItems != nil { + items := *req.CustomMenuItems + if len(items) > maxCustomMenuItems { + response.BadRequest(c, "Too many custom menu items (max 20)") + return + } + for i, item := range items { + if strings.TrimSpace(item.Label) == "" { + response.BadRequest(c, "Custom menu item label is required") + return + } + if len(item.Label) > maxMenuItemLabelLen { + response.BadRequest(c, "Custom menu item label is too long (max 50 characters)") + return + } + urlTrimmed := strings.TrimSpace(item.URL) + if strings.HasPrefix(urlTrimmed, "md:") { + // Markdown page mode: URL = "md:" + slug := strings.TrimPrefix(urlTrimmed, "md:") + if slug == "" { + response.BadRequest(c, "Custom menu item markdown slug cannot be empty (use md:slug format)") + return + } + } else { + if urlTrimmed == "" { + response.BadRequest(c, "Custom menu item URL is required (use md:slug for markdown pages)") + return + } + if len(item.URL) > maxMenuItemURLLen { + response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)") + return + } + if err := config.ValidateAbsoluteHTTPURL(urlTrimmed); err != nil { + response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL or md:") + return + } + } + if item.Visibility != "user" && item.Visibility != "admin" { + response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'") + return + } + if len(item.IconSVG) > maxMenuItemIconSVGLen { + response.BadRequest(c, "Custom menu item icon SVG is too large (max 10KB)") + return + } + // Auto-generate ID if missing + if strings.TrimSpace(item.ID) == "" { + id, err := generateMenuItemID() + if err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to generate menu item ID") + return + } + items[i].ID = id + } else if len(item.ID) > maxMenuItemIDLen { + response.BadRequest(c, "Custom menu item ID is too long (max 32 characters)") + return + } else if !menuItemIDPattern.MatchString(item.ID) { + response.BadRequest(c, "Custom menu item ID contains invalid characters (only a-z, A-Z, 0-9, - and _ are allowed)") + return + } + } + // ID uniqueness check + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + if _, exists := seen[item.ID]; exists { + response.BadRequest(c, "Duplicate custom menu item ID: "+item.ID) + return + } + seen[item.ID] = struct{}{} + } + menuBytes, err := json.Marshal(items) + if err != nil { + response.BadRequest(c, "Failed to serialize custom menu items") + return + } + customMenuJSON = string(menuBytes) + } + + // 自定义端点验证 + const ( + maxCustomEndpoints = 10 + maxEndpointNameLen = 50 + maxEndpointURLLen = 2048 + maxEndpointDescriptionLen = 200 + ) + + customEndpointsJSON := previousSettings.CustomEndpoints + if req.CustomEndpoints != nil { + endpoints := *req.CustomEndpoints + if len(endpoints) > maxCustomEndpoints { + response.BadRequest(c, "Too many custom endpoints (max 10)") + return + } + for _, ep := range endpoints { + if strings.TrimSpace(ep.Name) == "" { + response.BadRequest(c, "Custom endpoint name is required") + return + } + if len(ep.Name) > maxEndpointNameLen { + response.BadRequest(c, "Custom endpoint name is too long (max 50 characters)") + return + } + if strings.TrimSpace(ep.Endpoint) == "" { + response.BadRequest(c, "Custom endpoint URL is required") + return + } + if len(ep.Endpoint) > maxEndpointURLLen { + response.BadRequest(c, "Custom endpoint URL is too long (max 2048 characters)") + return + } + if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(ep.Endpoint)); err != nil { + response.BadRequest(c, "Custom endpoint URL must be an absolute http(s) URL") + return + } + if len(ep.Description) > maxEndpointDescriptionLen { + response.BadRequest(c, "Custom endpoint description is too long (max 200 characters)") + return + } + } + endpointBytes, err := json.Marshal(endpoints) + if err != nil { + response.BadRequest(c, "Failed to serialize custom endpoints") + return + } + customEndpointsJSON = string(endpointBytes) + } + + // Ops metrics collector interval validation (seconds). + if req.OpsMetricsIntervalSeconds != nil { + v := *req.OpsMetricsIntervalSeconds + if v < 60 { + v = 60 + } + if v > 3600 { + v = 3600 + } + req.OpsMetricsIntervalSeconds = &v + } + defaultSubscriptions := make([]service.DefaultSubscriptionSetting, 0, len(req.DefaultSubscriptions)) + for _, sub := range req.DefaultSubscriptions { + defaultSubscriptions = append(defaultSubscriptions, service.DefaultSubscriptionSetting{ + GroupID: sub.GroupID, + ValidityDays: sub.ValidityDays, + }) + } + + // 验证最低版本号格式(空字符串=禁用,或合法 semver) + if req.MinClaudeCodeVersion != "" { + if !semverPattern.MatchString(req.MinClaudeCodeVersion) { + response.Error(c, http.StatusBadRequest, "min_claude_code_version must be empty or a valid semver (e.g. 2.1.63)") + return + } + } + + // 验证最高版本号格式(空字符串=禁用,或合法 semver) + if req.MaxClaudeCodeVersion != "" { + if !semverPattern.MatchString(req.MaxClaudeCodeVersion) { + response.Error(c, http.StatusBadRequest, "max_claude_code_version must be empty or a valid semver (e.g. 3.0.0)") + return + } + } + if req.AntigravityUserAgentVersion != nil { + normalized := strings.TrimSpace(*req.AntigravityUserAgentVersion) + req.AntigravityUserAgentVersion = &normalized + if normalized != "" && !semverPattern.MatchString(normalized) { + response.Error(c, http.StatusBadRequest, "antigravity_user_agent_version must be empty or a valid semver (e.g. 1.23.2)") + return + } + } + if req.OpenAICodexUserAgent != nil { + normalized := strings.TrimSpace(*req.OpenAICodexUserAgent) + req.OpenAICodexUserAgent = &normalized + // 仅做长度上限保护,不限制具体格式(运维需要可自由调整 codex 版本号) + if len(normalized) > 512 { + response.Error(c, http.StatusBadRequest, "openai_codex_user_agent must be at most 512 characters") + return + } + } + + // codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min) + if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) { + response.Error(c, http.StatusBadRequest, "min_codex_version must be empty or a valid semver (e.g. 0.141.0)") + return + } + if req.MaxCodexVersion != "" && !semverPattern.MatchString(req.MaxCodexVersion) { + response.Error(c, http.StatusBadRequest, "max_codex_version must be empty or a valid semver (e.g. 0.200.0)") + return + } + if req.MinCodexVersion != "" && req.MaxCodexVersion != "" && service.CompareVersions(req.MaxCodexVersion, req.MinCodexVersion) < 0 { + response.Error(c, http.StatusBadRequest, "max_codex_version must be greater than or equal to min_codex_version") + return + } + // codex_cli_only 黑/白名单:非空须为合法 []AllowedClientEntry JSON。 + // 黑名单 OR 宽 deny(允许 originator-only);白名单双因子 AND,额外要求每条可命中(非空 originator + ua_contains)。 + if err := service.ValidateCodexClientEntriesJSON(req.CodexCLIOnlyBlacklist); err != nil { + response.Error(c, http.StatusBadRequest, "codex_cli_only_blacklist "+err.Error()) + return + } + if err := service.ValidateCodexWhitelistEntriesJSON(req.CodexCLIOnlyWhitelist); err != nil { + response.Error(c, http.StatusBadRequest, "codex_cli_only_whitelist "+err.Error()) + return + } + if err := service.ValidateEngineFingerprintSignalsJSON(req.CodexCLIOnlyEngineFingerprintSignals); err != nil { + response.Error(c, http.StatusBadRequest, "codex_cli_only_engine_fingerprint_signals "+err.Error()) + return + } + + // 交叉验证:如果同时设置了最低和最高版本号,最高版本号必须 >= 最低版本号 + if req.MinClaudeCodeVersion != "" && req.MaxClaudeCodeVersion != "" { + if service.CompareVersions(req.MaxClaudeCodeVersion, req.MinClaudeCodeVersion) < 0 { + response.Error(c, http.StatusBadRequest, "max_claude_code_version must be greater than or equal to min_claude_code_version") + return + } + } + + // cyber 会话屏蔽 TTL 校验:提供时必须 > 0 + if req.CyberSessionBlockTTLSeconds != nil && *req.CyberSessionBlockTTLSeconds <= 0 { + response.BadRequest(c, "cyber_session_block_ttl_seconds must be > 0") + return + } + + settings := &service.SystemSettings{ + // 系统全局 platform quota 默认值(整体替换语义) + DefaultPlatformQuotas: req.DefaultPlatformQuotas, + + RegistrationEnabled: req.RegistrationEnabled, + EmailVerifyEnabled: req.EmailVerifyEnabled, + RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist, + PromoCodeEnabled: req.PromoCodeEnabled, + PasswordResetEnabled: req.PasswordResetEnabled, + FrontendURL: req.FrontendURL, + InvitationCodeEnabled: req.InvitationCodeEnabled, + TotpEnabled: req.TotpEnabled, + LoginAgreementEnabled: req.LoginAgreementEnabled, + LoginAgreementMode: loginAgreementMode, + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocuments, + SMTPHost: req.SMTPHost, + SMTPPort: req.SMTPPort, + SMTPUsername: req.SMTPUsername, + SMTPPassword: req.SMTPPassword, + SMTPFrom: req.SMTPFrom, + SMTPFromName: req.SMTPFromName, + SMTPUseTLS: req.SMTPUseTLS, + TurnstileEnabled: req.TurnstileEnabled, + TurnstileSiteKey: req.TurnstileSiteKey, + TurnstileSecretKey: req.TurnstileSecretKey, + APIKeyACLTrustForwardedIP: func() bool { + if req.APIKeyACLTrustForwardedIP != nil { + return *req.APIKeyACLTrustForwardedIP + } + return previousSettings.APIKeyACLTrustForwardedIP + }(), + LinuxDoConnectEnabled: req.LinuxDoConnectEnabled, + LinuxDoConnectClientID: req.LinuxDoConnectClientID, + LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, + LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL, + DingTalkConnectEnabled: req.DingTalkConnectEnabled, + DingTalkConnectClientID: req.DingTalkConnectClientID, + DingTalkConnectClientSecret: req.DingTalkConnectClientSecret, + DingTalkConnectRedirectURL: req.DingTalkConnectRedirectURL, + DingTalkConnectCorpRestrictionPolicy: req.DingTalkConnectCorpRestrictionPolicy, + DingTalkConnectInternalCorpID: req.DingTalkConnectInternalCorpID, + DingTalkConnectBypassRegistration: req.DingTalkConnectBypassRegistration, + DingTalkConnectSyncCorpEmail: req.DingTalkConnectSyncCorpEmail, + DingTalkConnectSyncDisplayName: req.DingTalkConnectSyncDisplayName, + DingTalkConnectSyncDept: req.DingTalkConnectSyncDept, + DingTalkConnectSyncCorpEmailAttrKey: req.DingTalkConnectSyncCorpEmailAttrKey, + DingTalkConnectSyncDisplayNameAttrKey: req.DingTalkConnectSyncDisplayNameAttrKey, + DingTalkConnectSyncDeptAttrKey: req.DingTalkConnectSyncDeptAttrKey, + DingTalkConnectSyncCorpEmailAttrName: req.DingTalkConnectSyncCorpEmailAttrName, + DingTalkConnectSyncDisplayNameAttrName: req.DingTalkConnectSyncDisplayNameAttrName, + DingTalkConnectSyncDeptAttrName: req.DingTalkConnectSyncDeptAttrName, + WeChatConnectEnabled: req.WeChatConnectEnabled, + WeChatConnectAppID: req.WeChatConnectAppID, + WeChatConnectAppSecret: req.WeChatConnectAppSecret, + WeChatConnectOpenAppID: req.WeChatConnectOpenAppID, + WeChatConnectOpenAppSecret: req.WeChatConnectOpenAppSecret, + WeChatConnectMPAppID: req.WeChatConnectMPAppID, + WeChatConnectMPAppSecret: req.WeChatConnectMPAppSecret, + WeChatConnectMobileAppID: req.WeChatConnectMobileAppID, + WeChatConnectMobileAppSecret: req.WeChatConnectMobileAppSecret, + WeChatConnectOpenEnabled: req.WeChatConnectOpenEnabled, + WeChatConnectMPEnabled: req.WeChatConnectMPEnabled, + WeChatConnectMobileEnabled: req.WeChatConnectMobileEnabled, + WeChatConnectMode: req.WeChatConnectMode, + WeChatConnectScopes: req.WeChatConnectScopes, + WeChatConnectRedirectURL: req.WeChatConnectRedirectURL, + WeChatConnectFrontendRedirectURL: req.WeChatConnectFrontendRedirectURL, + OIDCConnectEnabled: req.OIDCConnectEnabled, + OIDCConnectProviderName: req.OIDCConnectProviderName, + OIDCConnectClientID: req.OIDCConnectClientID, + OIDCConnectClientSecret: req.OIDCConnectClientSecret, + OIDCConnectIssuerURL: req.OIDCConnectIssuerURL, + OIDCConnectDiscoveryURL: req.OIDCConnectDiscoveryURL, + OIDCConnectAuthorizeURL: req.OIDCConnectAuthorizeURL, + OIDCConnectTokenURL: req.OIDCConnectTokenURL, + OIDCConnectUserInfoURL: req.OIDCConnectUserInfoURL, + OIDCConnectJWKSURL: req.OIDCConnectJWKSURL, + OIDCConnectScopes: req.OIDCConnectScopes, + OIDCConnectRedirectURL: req.OIDCConnectRedirectURL, + OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL, + OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod, + OIDCConnectUsePKCE: oidcUsePKCE, + OIDCConnectValidateIDToken: oidcValidateIDToken, + OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs, + OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds, + OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified, + OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath, + OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath, + OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath, + GitHubOAuthEnabled: req.GitHubOAuthEnabled, + GitHubOAuthClientID: req.GitHubOAuthClientID, + GitHubOAuthClientSecret: req.GitHubOAuthClientSecret, + GitHubOAuthRedirectURL: req.GitHubOAuthRedirectURL, + GitHubOAuthFrontendRedirectURL: req.GitHubOAuthFrontendRedirectURL, + GoogleOAuthEnabled: req.GoogleOAuthEnabled, + GoogleOAuthClientID: req.GoogleOAuthClientID, + GoogleOAuthClientSecret: req.GoogleOAuthClientSecret, + GoogleOAuthRedirectURL: req.GoogleOAuthRedirectURL, + GoogleOAuthFrontendRedirectURL: req.GoogleOAuthFrontendRedirectURL, + SiteName: req.SiteName, + SiteLogo: req.SiteLogo, + SiteSubtitle: req.SiteSubtitle, + APIBaseURL: req.APIBaseURL, + ContactInfo: req.ContactInfo, + DocURL: req.DocURL, + HomeContent: req.HomeContent, + HideCcsImportButton: req.HideCcsImportButton, + PurchaseSubscriptionEnabled: purchaseEnabled, + PurchaseSubscriptionURL: purchaseURL, + TableDefaultPageSize: req.TableDefaultPageSize, + TablePageSizeOptions: req.TablePageSizeOptions, + CustomMenuItems: customMenuJSON, + CustomEndpoints: customEndpointsJSON, + DefaultConcurrency: req.DefaultConcurrency, + DefaultBalance: req.DefaultBalance, + AffiliateRebateRate: affiliateRebateRate, + AffiliateRebateFreezeHours: affiliateRebateFreezeHours, + AffiliateRebateDurationDays: affiliateRebateDurationDays, + AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap, + DefaultUserRPMLimit: req.DefaultUserRPMLimit, + DefaultSubscriptions: defaultSubscriptions, + EnableModelFallback: req.EnableModelFallback, + FallbackModelAnthropic: req.FallbackModelAnthropic, + FallbackModelOpenAI: req.FallbackModelOpenAI, + FallbackModelGemini: req.FallbackModelGemini, + FallbackModelAntigravity: req.FallbackModelAntigravity, + EnableIdentityPatch: req.EnableIdentityPatch, + IdentityPatchPrompt: req.IdentityPatchPrompt, + MinClaudeCodeVersion: req.MinClaudeCodeVersion, + MaxClaudeCodeVersion: req.MaxClaudeCodeVersion, + AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling, + BackendModeEnabled: req.BackendModeEnabled, + AllowUserViewErrorRequests: func() bool { + if req.AllowUserViewErrorRequests != nil { + return *req.AllowUserViewErrorRequests + } + return previousSettings.AllowUserViewErrorRequests + }(), + OpsMonitoringEnabled: func() bool { + if req.OpsMonitoringEnabled != nil { + return *req.OpsMonitoringEnabled + } + return previousSettings.OpsMonitoringEnabled + }(), + OpsRealtimeMonitoringEnabled: func() bool { + if req.OpsRealtimeMonitoringEnabled != nil { + return *req.OpsRealtimeMonitoringEnabled + } + return previousSettings.OpsRealtimeMonitoringEnabled + }(), + OpsQueryModeDefault: func() string { + if req.OpsQueryModeDefault != nil { + return *req.OpsQueryModeDefault + } + return previousSettings.OpsQueryModeDefault + }(), + OpsMetricsIntervalSeconds: func() int { + if req.OpsMetricsIntervalSeconds != nil { + return *req.OpsMetricsIntervalSeconds + } + return previousSettings.OpsMetricsIntervalSeconds + }(), + EnableFingerprintUnification: func() bool { + if req.EnableFingerprintUnification != nil { + return *req.EnableFingerprintUnification + } + return previousSettings.EnableFingerprintUnification + }(), + EnableMetadataPassthrough: func() bool { + if req.EnableMetadataPassthrough != nil { + return *req.EnableMetadataPassthrough + } + return previousSettings.EnableMetadataPassthrough + }(), + EnableCCHSigning: func() bool { + if req.EnableCCHSigning != nil { + return *req.EnableCCHSigning + } + return previousSettings.EnableCCHSigning + }(), + EnableClaudeOAuthSystemPromptInjection: func() bool { + if req.EnableClaudeOAuthSystemPromptInjection != nil { + return *req.EnableClaudeOAuthSystemPromptInjection + } + return previousSettings.EnableClaudeOAuthSystemPromptInjection + }(), + ClaudeOAuthSystemPrompt: func() string { + if req.ClaudeOAuthSystemPrompt != nil { + return *req.ClaudeOAuthSystemPrompt + } + return previousSettings.ClaudeOAuthSystemPrompt + }(), + ClaudeOAuthSystemPromptBlocks: func() string { + if req.ClaudeOAuthSystemPromptBlocks != nil { + return *req.ClaudeOAuthSystemPromptBlocks + } + return previousSettings.ClaudeOAuthSystemPromptBlocks + }(), + EnableAnthropicCacheTTL1hInjection: func() bool { + if req.EnableAnthropicCacheTTL1hInjection != nil { + return *req.EnableAnthropicCacheTTL1hInjection + } + return previousSettings.EnableAnthropicCacheTTL1hInjection + }(), + RewriteMessageCacheControl: func() bool { + if req.RewriteMessageCacheControl != nil { + return *req.RewriteMessageCacheControl + } + return previousSettings.RewriteMessageCacheControl + }(), + EnableClientDatelineNormalization: func() bool { + if req.EnableClientDatelineNormalization != nil { + return *req.EnableClientDatelineNormalization + } + return previousSettings.EnableClientDatelineNormalization + }(), + AntigravityUserAgentVersion: func() string { + if req.AntigravityUserAgentVersion != nil { + return *req.AntigravityUserAgentVersion + } + return previousSettings.AntigravityUserAgentVersion + }(), + OpenAICodexUserAgent: func() string { + if req.OpenAICodexUserAgent != nil { + return *req.OpenAICodexUserAgent + } + return previousSettings.OpenAICodexUserAgent + }(), + MinCodexVersion: strings.TrimSpace(req.MinCodexVersion), + MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion), + CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist), + CodexCLIOnlyWhitelist: strings.TrimSpace(req.CodexCLIOnlyWhitelist), + CodexCLIOnlyAllowAppServerClients: func() bool { + if req.CodexCLIOnlyAllowAppServerClients != nil { + return *req.CodexCLIOnlyAllowAppServerClients + } + return previousSettings.CodexCLIOnlyAllowAppServerClients + }(), + CodexCLIOnlyEngineFingerprintSignals: strings.TrimSpace(req.CodexCLIOnlyEngineFingerprintSignals), + PaymentVisibleMethodAlipaySource: func() string { + if req.PaymentVisibleMethodAlipaySource != nil { + return strings.TrimSpace(*req.PaymentVisibleMethodAlipaySource) + } + return previousSettings.PaymentVisibleMethodAlipaySource + }(), + PaymentVisibleMethodWxpaySource: func() string { + if req.PaymentVisibleMethodWxpaySource != nil { + return strings.TrimSpace(*req.PaymentVisibleMethodWxpaySource) + } + return previousSettings.PaymentVisibleMethodWxpaySource + }(), + PaymentVisibleMethodAlipayEnabled: func() bool { + if req.PaymentVisibleMethodAlipayEnabled != nil { + return *req.PaymentVisibleMethodAlipayEnabled + } + return previousSettings.PaymentVisibleMethodAlipayEnabled + }(), + PaymentVisibleMethodWxpayEnabled: func() bool { + if req.PaymentVisibleMethodWxpayEnabled != nil { + return *req.PaymentVisibleMethodWxpayEnabled + } + return previousSettings.PaymentVisibleMethodWxpayEnabled + }(), + OpenAIAdvancedSchedulerEnabled: func() bool { + if req.OpenAIAdvancedSchedulerEnabled != nil { + return *req.OpenAIAdvancedSchedulerEnabled + } + return previousSettings.OpenAIAdvancedSchedulerEnabled + }(), + OpenAIAdvancedSchedulerStickyWeightedEnabled: func() bool { + if req.OpenAIAdvancedSchedulerStickyWeightedEnabled != nil { + return *req.OpenAIAdvancedSchedulerStickyWeightedEnabled + } + return previousSettings.OpenAIAdvancedSchedulerStickyWeightedEnabled + }(), + OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: func() bool { + if req.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled != nil { + return *req.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled + } + return previousSettings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled + }(), + OpenAIAdvancedSchedulerLBTopK: stringSetting(req.OpenAIAdvancedSchedulerLBTopK, previousSettings.OpenAIAdvancedSchedulerLBTopK), + OpenAIAdvancedSchedulerWeightPriority: stringSetting(req.OpenAIAdvancedSchedulerWeightPriority, previousSettings.OpenAIAdvancedSchedulerWeightPriority), + OpenAIAdvancedSchedulerWeightLoad: stringSetting(req.OpenAIAdvancedSchedulerWeightLoad, previousSettings.OpenAIAdvancedSchedulerWeightLoad), + OpenAIAdvancedSchedulerWeightQueue: stringSetting(req.OpenAIAdvancedSchedulerWeightQueue, previousSettings.OpenAIAdvancedSchedulerWeightQueue), + OpenAIAdvancedSchedulerWeightErrorRate: stringSetting(req.OpenAIAdvancedSchedulerWeightErrorRate, previousSettings.OpenAIAdvancedSchedulerWeightErrorRate), + OpenAIAdvancedSchedulerWeightTTFT: stringSetting(req.OpenAIAdvancedSchedulerWeightTTFT, previousSettings.OpenAIAdvancedSchedulerWeightTTFT), + OpenAIAdvancedSchedulerWeightReset: stringSetting(req.OpenAIAdvancedSchedulerWeightReset, previousSettings.OpenAIAdvancedSchedulerWeightReset), + OpenAIAdvancedSchedulerWeightQuotaHeadroom: stringSetting(req.OpenAIAdvancedSchedulerWeightQuotaHeadroom, previousSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom), + OpenAIAdvancedSchedulerWeightPreviousResponse: stringSetting(req.OpenAIAdvancedSchedulerWeightPreviousResponse, previousSettings.OpenAIAdvancedSchedulerWeightPreviousResponse), + OpenAIAdvancedSchedulerWeightSessionSticky: stringSetting(req.OpenAIAdvancedSchedulerWeightSessionSticky, previousSettings.OpenAIAdvancedSchedulerWeightSessionSticky), + BalanceLowNotifyEnabled: func() bool { + if req.BalanceLowNotifyEnabled != nil { + return *req.BalanceLowNotifyEnabled + } + return previousSettings.BalanceLowNotifyEnabled + }(), + BalanceLowNotifyThreshold: func() float64 { + if req.BalanceLowNotifyThreshold != nil { + return *req.BalanceLowNotifyThreshold + } + return previousSettings.BalanceLowNotifyThreshold + }(), + BalanceLowNotifyRechargeURL: func() string { + if req.BalanceLowNotifyRechargeURL != nil { + return *req.BalanceLowNotifyRechargeURL + } + return previousSettings.BalanceLowNotifyRechargeURL + }(), + SubscriptionExpiryNotifyEnabled: func() bool { + if req.SubscriptionExpiryNotifyEnabled != nil { + return *req.SubscriptionExpiryNotifyEnabled + } + return previousSettings.SubscriptionExpiryNotifyEnabled + }(), + AccountQuotaNotifyEnabled: func() bool { + if req.AccountQuotaNotifyEnabled != nil { + return *req.AccountQuotaNotifyEnabled + } + return previousSettings.AccountQuotaNotifyEnabled + }(), + AccountQuotaNotifyEmails: func() []service.NotifyEmailEntry { + if req.AccountQuotaNotifyEmails != nil { + return dto.NotifyEmailEntriesToService(*req.AccountQuotaNotifyEmails) + } + return previousSettings.AccountQuotaNotifyEmails + }(), + ChannelMonitorEnabled: func() bool { + if req.ChannelMonitorEnabled != nil { + return *req.ChannelMonitorEnabled + } + return previousSettings.ChannelMonitorEnabled + }(), + ChannelMonitorDefaultIntervalSeconds: func() int { + if req.ChannelMonitorDefaultIntervalSeconds != nil { + return *req.ChannelMonitorDefaultIntervalSeconds + } + return previousSettings.ChannelMonitorDefaultIntervalSeconds + }(), + AvailableChannelsEnabled: func() bool { + if req.AvailableChannelsEnabled != nil { + return *req.AvailableChannelsEnabled + } + return previousSettings.AvailableChannelsEnabled + }(), + AffiliateEnabled: func() bool { + if req.AffiliateEnabled != nil { + return *req.AffiliateEnabled + } + return previousSettings.AffiliateEnabled + }(), + RiskControlEnabled: func() bool { + if req.RiskControlEnabled != nil { + return *req.RiskControlEnabled + } + return previousSettings.RiskControlEnabled + }(), + CyberSessionBlockEnabled: func() bool { + if req.CyberSessionBlockEnabled != nil { + return *req.CyberSessionBlockEnabled + } + return previousSettings.CyberSessionBlockEnabled + }(), + CyberSessionBlockTTLSeconds: func() int { + if req.CyberSessionBlockTTLSeconds != nil { + return *req.CyberSessionBlockTTLSeconds + } + return previousSettings.CyberSessionBlockTTLSeconds + }(), + } + + // req.AuthSourceXxxPlatformQuotas 为 nil 表示本次请求未包含该 source 的 quota 配置(保留 previousAuthSourceDefaults 中的值); + // non-nil(含 empty map)表示整体覆盖:empty map = 清空该 source 的所有 quota 配置。 + authSourceDefaults := &service.AuthSourceDefaultSettings{ + Email: service.ProviderDefaultGrantSettings{ + Balance: float64ValueOrDefault(req.AuthSourceDefaultEmailBalance, previousAuthSourceDefaults.Email.Balance), + Concurrency: intValueOrDefault(req.AuthSourceDefaultEmailConcurrency, previousAuthSourceDefaults.Email.Concurrency), + Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultEmailSubscriptions, previousAuthSourceDefaults.Email.Subscriptions), + GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultEmailGrantOnSignup, previousAuthSourceDefaults.Email.GrantOnSignup), + GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultEmailGrantOnFirstBind, previousAuthSourceDefaults.Email.GrantOnFirstBind), + PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceEmailPlatformQuotas, previousAuthSourceDefaults.Email.PlatformQuotas), + }, + LinuxDo: service.ProviderDefaultGrantSettings{ + Balance: float64ValueOrDefault(req.AuthSourceDefaultLinuxDoBalance, previousAuthSourceDefaults.LinuxDo.Balance), + Concurrency: intValueOrDefault(req.AuthSourceDefaultLinuxDoConcurrency, previousAuthSourceDefaults.LinuxDo.Concurrency), + Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultLinuxDoSubscriptions, previousAuthSourceDefaults.LinuxDo.Subscriptions), + GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultLinuxDoGrantOnSignup, previousAuthSourceDefaults.LinuxDo.GrantOnSignup), + GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultLinuxDoGrantOnFirstBind, previousAuthSourceDefaults.LinuxDo.GrantOnFirstBind), + PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceLinuxDoPlatformQuotas, previousAuthSourceDefaults.LinuxDo.PlatformQuotas), + }, + OIDC: service.ProviderDefaultGrantSettings{ + Balance: float64ValueOrDefault(req.AuthSourceDefaultOIDCBalance, previousAuthSourceDefaults.OIDC.Balance), + Concurrency: intValueOrDefault(req.AuthSourceDefaultOIDCConcurrency, previousAuthSourceDefaults.OIDC.Concurrency), + Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultOIDCSubscriptions, previousAuthSourceDefaults.OIDC.Subscriptions), + GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultOIDCGrantOnSignup, previousAuthSourceDefaults.OIDC.GrantOnSignup), + GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultOIDCGrantOnFirstBind, previousAuthSourceDefaults.OIDC.GrantOnFirstBind), + PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceOIDCPlatformQuotas, previousAuthSourceDefaults.OIDC.PlatformQuotas), + }, + WeChat: service.ProviderDefaultGrantSettings{ + Balance: float64ValueOrDefault(req.AuthSourceDefaultWeChatBalance, previousAuthSourceDefaults.WeChat.Balance), + Concurrency: intValueOrDefault(req.AuthSourceDefaultWeChatConcurrency, previousAuthSourceDefaults.WeChat.Concurrency), + Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultWeChatSubscriptions, previousAuthSourceDefaults.WeChat.Subscriptions), + GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnSignup, previousAuthSourceDefaults.WeChat.GrantOnSignup), + GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnFirstBind, previousAuthSourceDefaults.WeChat.GrantOnFirstBind), + PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceWeChatPlatformQuotas, previousAuthSourceDefaults.WeChat.PlatformQuotas), + }, + GitHub: service.ProviderDefaultGrantSettings{ + Balance: float64ValueOrDefault(req.AuthSourceDefaultGitHubBalance, previousAuthSourceDefaults.GitHub.Balance), + Concurrency: intValueOrDefault(req.AuthSourceDefaultGitHubConcurrency, previousAuthSourceDefaults.GitHub.Concurrency), + Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGitHubSubscriptions, previousAuthSourceDefaults.GitHub.Subscriptions), + GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnSignup, previousAuthSourceDefaults.GitHub.GrantOnSignup), + GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnFirstBind, previousAuthSourceDefaults.GitHub.GrantOnFirstBind), + PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceGitHubPlatformQuotas, previousAuthSourceDefaults.GitHub.PlatformQuotas), + }, + Google: service.ProviderDefaultGrantSettings{ + Balance: float64ValueOrDefault(req.AuthSourceDefaultGoogleBalance, previousAuthSourceDefaults.Google.Balance), + Concurrency: intValueOrDefault(req.AuthSourceDefaultGoogleConcurrency, previousAuthSourceDefaults.Google.Concurrency), + Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGoogleSubscriptions, previousAuthSourceDefaults.Google.Subscriptions), + GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnSignup, previousAuthSourceDefaults.Google.GrantOnSignup), + GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnFirstBind, previousAuthSourceDefaults.Google.GrantOnFirstBind), + PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceGooglePlatformQuotas, previousAuthSourceDefaults.Google.PlatformQuotas), + }, + DingTalk: service.ProviderDefaultGrantSettings{ + Balance: float64ValueOrDefault(req.AuthSourceDefaultDingTalkBalance, previousAuthSourceDefaults.DingTalk.Balance), + Concurrency: intValueOrDefault(req.AuthSourceDefaultDingTalkConcurrency, previousAuthSourceDefaults.DingTalk.Concurrency), + Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultDingTalkSubscriptions, previousAuthSourceDefaults.DingTalk.Subscriptions), + GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultDingTalkGrantOnSignup, previousAuthSourceDefaults.DingTalk.GrantOnSignup), + GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultDingTalkGrantOnFirstBind, previousAuthSourceDefaults.DingTalk.GrantOnFirstBind), + PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceDingTalkPlatformQuotas, previousAuthSourceDefaults.DingTalk.PlatformQuotas), + }, + ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup), + } + if err := h.settingService.UpdateSettingsWithAuthSourceDefaults(c.Request.Context(), settings, authSourceDefaults); err != nil { + response.ErrorFrom(c, err) + return + } + + // Update OpenAI fast policy (stored under dedicated key, only when provided). + if req.OpenAIFastPolicySettings != nil { + if err := h.settingService.SetOpenAIFastPolicySettings(c.Request.Context(), openaiFastPolicySettingsFromDTO(req.OpenAIFastPolicySettings)); err != nil { + response.BadRequest(c, err.Error()) + return + } + } + + // Update payment configuration (integrated into system settings). + // Skip if no payment fields were provided (prevents accidental wipe). + if h.paymentConfigService != nil && hasPaymentFields(req) { + paymentReq := service.UpdatePaymentConfigRequest{ + Enabled: req.PaymentEnabled, + MinAmount: req.PaymentMinAmount, + MaxAmount: req.PaymentMaxAmount, + DailyLimit: req.PaymentDailyLimit, + OrderTimeoutMin: req.PaymentOrderTimeoutMin, + MaxPendingOrders: req.PaymentMaxPendingOrders, + EnabledTypes: req.PaymentEnabledTypes, + BalanceDisabled: req.PaymentBalanceDisabled, + BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier, + SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate, + RechargeFeeRate: req.PaymentRechargeFeeRate, + LoadBalanceStrategy: req.PaymentLoadBalanceStrat, + ProductNamePrefix: req.PaymentProductNamePrefix, + ProductNameSuffix: req.PaymentProductNameSuffix, + HelpImageURL: req.PaymentHelpImageURL, + HelpText: req.PaymentHelpText, + CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled, + CancelRateLimitMax: req.PaymentCancelRateLimitMax, + CancelRateLimitWindow: req.PaymentCancelRateLimitWindow, + CancelRateLimitUnit: req.PaymentCancelRateLimitUnit, + CancelRateLimitMode: req.PaymentCancelRateLimitMode, + AlipayForceQRCode: req.PaymentAlipayForceQRCode, + } + if err := h.paymentConfigService.UpdatePaymentConfig(c.Request.Context(), paymentReq); err != nil { + response.ErrorFrom(c, err) + return + } + // Refresh in-memory provider registry so config changes take effect immediately + if h.paymentService != nil { + h.paymentService.RefreshProviders(c.Request.Context()) + } + } + + h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, req) + + // 重新获取设置返回 + updatedSettings, err := h.settingService.GetAllSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + h.ensureDingTalkSyncAttributes(c.Request.Context(), updatedSettings) + updatedAuthSourceDefaults, err := h.settingService.GetAuthSourceDefaultSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + updatedDefaultSubscriptions := make([]dto.DefaultSubscriptionSetting, 0, len(updatedSettings.DefaultSubscriptions)) + for _, sub := range updatedSettings.DefaultSubscriptions { + updatedDefaultSubscriptions = append(updatedDefaultSubscriptions, dto.DefaultSubscriptionSetting{ + GroupID: sub.GroupID, + ValidityDays: sub.ValidityDays, + }) + } + + // Reload payment config for response + var updatedPaymentCfg *service.PaymentConfig + if h.paymentConfigService != nil { + updatedPaymentCfg, _ = h.paymentConfigService.GetPaymentConfig(c.Request.Context()) + } + if updatedPaymentCfg == nil { + updatedPaymentCfg = &service.PaymentConfig{} + } + + payload := dto.SystemSettings{ + RegistrationEnabled: updatedSettings.RegistrationEnabled, + EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, + RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist, + PromoCodeEnabled: updatedSettings.PromoCodeEnabled, + PasswordResetEnabled: updatedSettings.PasswordResetEnabled, + FrontendURL: updatedSettings.FrontendURL, + InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, + TotpEnabled: updatedSettings.TotpEnabled, + TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled, + LoginAgreementMode: updatedSettings.LoginAgreementMode, + LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocumentsToDTO(updatedSettings.LoginAgreementDocuments), + SMTPHost: updatedSettings.SMTPHost, + SMTPPort: updatedSettings.SMTPPort, + SMTPUsername: updatedSettings.SMTPUsername, + SMTPPasswordConfigured: updatedSettings.SMTPPasswordConfigured, + SMTPFrom: updatedSettings.SMTPFrom, + SMTPFromName: updatedSettings.SMTPFromName, + SMTPUseTLS: updatedSettings.SMTPUseTLS, + TurnstileEnabled: updatedSettings.TurnstileEnabled, + TurnstileSiteKey: updatedSettings.TurnstileSiteKey, + TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured, + APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP, + LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled, + LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID, + LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured, + LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL, + DingTalkConnectEnabled: updatedSettings.DingTalkConnectEnabled, + DingTalkConnectClientID: updatedSettings.DingTalkConnectClientID, + DingTalkConnectClientSecretConfigured: updatedSettings.DingTalkConnectClientSecretConfigured, + DingTalkConnectRedirectURL: updatedSettings.DingTalkConnectRedirectURL, + DingTalkConnectCorpRestrictionPolicy: updatedSettings.DingTalkConnectCorpRestrictionPolicy, + DingTalkConnectInternalCorpID: updatedSettings.DingTalkConnectInternalCorpID, + DingTalkConnectBypassRegistration: updatedSettings.DingTalkConnectBypassRegistration, + DingTalkConnectSyncCorpEmail: updatedSettings.DingTalkConnectSyncCorpEmail, + DingTalkConnectSyncDisplayName: updatedSettings.DingTalkConnectSyncDisplayName, + DingTalkConnectSyncDept: updatedSettings.DingTalkConnectSyncDept, + DingTalkConnectSyncCorpEmailAttrKey: updatedSettings.DingTalkConnectSyncCorpEmailAttrKey, + DingTalkConnectSyncDisplayNameAttrKey: updatedSettings.DingTalkConnectSyncDisplayNameAttrKey, + DingTalkConnectSyncDeptAttrKey: updatedSettings.DingTalkConnectSyncDeptAttrKey, + DingTalkConnectSyncCorpEmailAttrName: updatedSettings.DingTalkConnectSyncCorpEmailAttrName, + DingTalkConnectSyncDisplayNameAttrName: updatedSettings.DingTalkConnectSyncDisplayNameAttrName, + DingTalkConnectSyncDeptAttrName: updatedSettings.DingTalkConnectSyncDeptAttrName, + WeChatConnectEnabled: updatedSettings.WeChatConnectEnabled, + WeChatConnectAppID: updatedSettings.WeChatConnectAppID, + WeChatConnectAppSecretConfigured: updatedSettings.WeChatConnectAppSecretConfigured, + WeChatConnectOpenAppID: updatedSettings.WeChatConnectOpenAppID, + WeChatConnectOpenAppSecretConfigured: updatedSettings.WeChatConnectOpenAppSecretConfigured, + WeChatConnectMPAppID: updatedSettings.WeChatConnectMPAppID, + WeChatConnectMPAppSecretConfigured: updatedSettings.WeChatConnectMPAppSecretConfigured, + WeChatConnectMobileAppID: updatedSettings.WeChatConnectMobileAppID, + WeChatConnectMobileAppSecretConfigured: updatedSettings.WeChatConnectMobileAppSecretConfigured, + WeChatConnectOpenEnabled: updatedSettings.WeChatConnectOpenEnabled, + WeChatConnectMPEnabled: updatedSettings.WeChatConnectMPEnabled, + WeChatConnectMobileEnabled: updatedSettings.WeChatConnectMobileEnabled, + WeChatConnectMode: updatedSettings.WeChatConnectMode, + WeChatConnectScopes: updatedSettings.WeChatConnectScopes, + WeChatConnectRedirectURL: updatedSettings.WeChatConnectRedirectURL, + WeChatConnectFrontendRedirectURL: updatedSettings.WeChatConnectFrontendRedirectURL, + OIDCConnectEnabled: updatedSettings.OIDCConnectEnabled, + OIDCConnectProviderName: updatedSettings.OIDCConnectProviderName, + OIDCConnectClientID: updatedSettings.OIDCConnectClientID, + OIDCConnectClientSecretConfigured: updatedSettings.OIDCConnectClientSecretConfigured, + OIDCConnectIssuerURL: updatedSettings.OIDCConnectIssuerURL, + OIDCConnectDiscoveryURL: updatedSettings.OIDCConnectDiscoveryURL, + OIDCConnectAuthorizeURL: updatedSettings.OIDCConnectAuthorizeURL, + OIDCConnectTokenURL: updatedSettings.OIDCConnectTokenURL, + OIDCConnectUserInfoURL: updatedSettings.OIDCConnectUserInfoURL, + OIDCConnectJWKSURL: updatedSettings.OIDCConnectJWKSURL, + OIDCConnectScopes: updatedSettings.OIDCConnectScopes, + OIDCConnectRedirectURL: updatedSettings.OIDCConnectRedirectURL, + OIDCConnectFrontendRedirectURL: updatedSettings.OIDCConnectFrontendRedirectURL, + OIDCConnectTokenAuthMethod: updatedSettings.OIDCConnectTokenAuthMethod, + OIDCConnectUsePKCE: updatedSettings.OIDCConnectUsePKCE, + OIDCConnectValidateIDToken: updatedSettings.OIDCConnectValidateIDToken, + OIDCConnectAllowedSigningAlgs: updatedSettings.OIDCConnectAllowedSigningAlgs, + OIDCConnectClockSkewSeconds: updatedSettings.OIDCConnectClockSkewSeconds, + OIDCConnectRequireEmailVerified: updatedSettings.OIDCConnectRequireEmailVerified, + OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath, + OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath, + OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath, + GitHubOAuthEnabled: updatedSettings.GitHubOAuthEnabled, + GitHubOAuthClientID: updatedSettings.GitHubOAuthClientID, + GitHubOAuthClientSecretConfigured: updatedSettings.GitHubOAuthClientSecretConfigured, + GitHubOAuthRedirectURL: updatedSettings.GitHubOAuthRedirectURL, + GitHubOAuthFrontendRedirectURL: updatedSettings.GitHubOAuthFrontendRedirectURL, + GoogleOAuthEnabled: updatedSettings.GoogleOAuthEnabled, + GoogleOAuthClientID: updatedSettings.GoogleOAuthClientID, + GoogleOAuthClientSecretConfigured: updatedSettings.GoogleOAuthClientSecretConfigured, + GoogleOAuthRedirectURL: updatedSettings.GoogleOAuthRedirectURL, + GoogleOAuthFrontendRedirectURL: updatedSettings.GoogleOAuthFrontendRedirectURL, + SiteName: updatedSettings.SiteName, + SiteLogo: updatedSettings.SiteLogo, + SiteSubtitle: updatedSettings.SiteSubtitle, + APIBaseURL: updatedSettings.APIBaseURL, + ContactInfo: updatedSettings.ContactInfo, + DocURL: updatedSettings.DocURL, + HomeContent: updatedSettings.HomeContent, + HideCcsImportButton: updatedSettings.HideCcsImportButton, + PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled, + PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL, + TableDefaultPageSize: updatedSettings.TableDefaultPageSize, + TablePageSizeOptions: updatedSettings.TablePageSizeOptions, + CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems), + CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints), + DefaultConcurrency: updatedSettings.DefaultConcurrency, + DefaultBalance: updatedSettings.DefaultBalance, + AffiliateRebateRate: updatedSettings.AffiliateRebateRate, + AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours, + AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays, + AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap, + DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit, + DefaultSubscriptions: updatedDefaultSubscriptions, + EnableModelFallback: updatedSettings.EnableModelFallback, + FallbackModelAnthropic: updatedSettings.FallbackModelAnthropic, + FallbackModelOpenAI: updatedSettings.FallbackModelOpenAI, + FallbackModelGemini: updatedSettings.FallbackModelGemini, + FallbackModelAntigravity: updatedSettings.FallbackModelAntigravity, + EnableIdentityPatch: updatedSettings.EnableIdentityPatch, + IdentityPatchPrompt: updatedSettings.IdentityPatchPrompt, + OpsMonitoringEnabled: updatedSettings.OpsMonitoringEnabled, + OpsRealtimeMonitoringEnabled: updatedSettings.OpsRealtimeMonitoringEnabled, + OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault, + OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds, + MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion, + MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion, + AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling, + BackendModeEnabled: updatedSettings.BackendModeEnabled, + EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification, + EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough, + EnableCCHSigning: updatedSettings.EnableCCHSigning, + EnableClaudeOAuthSystemPromptInjection: updatedSettings.EnableClaudeOAuthSystemPromptInjection, + ClaudeOAuthSystemPrompt: updatedSettings.ClaudeOAuthSystemPrompt, + ClaudeOAuthSystemPromptBlocks: updatedSettings.ClaudeOAuthSystemPromptBlocks, + EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection, + RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl, + EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization, + AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion, + OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent, + MinCodexVersion: updatedSettings.MinCodexVersion, + MaxCodexVersion: updatedSettings.MaxCodexVersion, + CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist, + CodexCLIOnlyWhitelist: updatedSettings.CodexCLIOnlyWhitelist, + CodexCLIOnlyAllowAppServerClients: updatedSettings.CodexCLIOnlyAllowAppServerClients, + CodexCLIOnlyEngineFingerprintSignals: updatedSettings.CodexCLIOnlyEngineFingerprintSignals, + PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource, + PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource, + PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled, + PaymentVisibleMethodWxpayEnabled: updatedSettings.PaymentVisibleMethodWxpayEnabled, + OpenAIAdvancedSchedulerEnabled: updatedSettings.OpenAIAdvancedSchedulerEnabled, + OpenAIAdvancedSchedulerStickyWeightedEnabled: updatedSettings.OpenAIAdvancedSchedulerStickyWeightedEnabled, + OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: updatedSettings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled, + OpenAIAdvancedSchedulerLBTopK: updatedSettings.OpenAIAdvancedSchedulerLBTopK, + OpenAIAdvancedSchedulerWeightPriority: updatedSettings.OpenAIAdvancedSchedulerWeightPriority, + OpenAIAdvancedSchedulerWeightLoad: updatedSettings.OpenAIAdvancedSchedulerWeightLoad, + OpenAIAdvancedSchedulerWeightQueue: updatedSettings.OpenAIAdvancedSchedulerWeightQueue, + OpenAIAdvancedSchedulerWeightErrorRate: updatedSettings.OpenAIAdvancedSchedulerWeightErrorRate, + OpenAIAdvancedSchedulerWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerWeightTTFT, + OpenAIAdvancedSchedulerWeightReset: updatedSettings.OpenAIAdvancedSchedulerWeightReset, + OpenAIAdvancedSchedulerWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, + OpenAIAdvancedSchedulerWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerWeightPreviousResponse, + OpenAIAdvancedSchedulerWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerWeightSessionSticky, + OpenAIAdvancedSchedulerEffectiveLBTopK: updatedSettings.OpenAIAdvancedSchedulerEffectiveLBTopK, + OpenAIAdvancedSchedulerEffectiveWeightPriority: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightPriority, + OpenAIAdvancedSchedulerEffectiveWeightLoad: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightLoad, + OpenAIAdvancedSchedulerEffectiveWeightQueue: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightQueue, + OpenAIAdvancedSchedulerEffectiveWeightErrorRate: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightErrorRate, + OpenAIAdvancedSchedulerEffectiveWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightTTFT, + OpenAIAdvancedSchedulerEffectiveWeightReset: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightReset, + OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom, + OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse, + OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky, + BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled, + BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: updatedSettings.BalanceLowNotifyRechargeURL, + SubscriptionExpiryNotifyEnabled: updatedSettings.SubscriptionExpiryNotifyEnabled, + AccountQuotaNotifyEnabled: updatedSettings.AccountQuotaNotifyEnabled, + AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails), + PaymentEnabled: updatedPaymentCfg.Enabled, + PaymentMinAmount: updatedPaymentCfg.MinAmount, + PaymentMaxAmount: updatedPaymentCfg.MaxAmount, + PaymentDailyLimit: updatedPaymentCfg.DailyLimit, + PaymentOrderTimeoutMin: updatedPaymentCfg.OrderTimeoutMin, + PaymentMaxPendingOrders: updatedPaymentCfg.MaxPendingOrders, + PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes, + PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled, + PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier, + PaymentSubscriptionUSDToCNYRate: updatedPaymentCfg.SubscriptionUSDToCNYRate, + PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate, + PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy, + PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix, + PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix, + PaymentHelpImageURL: updatedPaymentCfg.HelpImageURL, + PaymentHelpText: updatedPaymentCfg.HelpText, + PaymentCancelRateLimitEnabled: updatedPaymentCfg.CancelRateLimitEnabled, + PaymentCancelRateLimitMax: updatedPaymentCfg.CancelRateLimitMax, + PaymentCancelRateLimitWindow: updatedPaymentCfg.CancelRateLimitWindow, + PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit, + PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode, + PaymentAlipayForceQRCode: updatedPaymentCfg.AlipayForceQRCode, + + ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled, + ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds, + + AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled, + + AffiliateEnabled: updatedSettings.AffiliateEnabled, + + RiskControlEnabled: updatedSettings.RiskControlEnabled, + CyberSessionBlockEnabled: updatedSettings.CyberSessionBlockEnabled, + CyberSessionBlockTTLSeconds: updatedSettings.CyberSessionBlockTTLSeconds, + AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests, + } + if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil { + slog.Error("openai_fast_policy_settings_get_failed", "error", err) + } else if fastPolicy != nil { + payload.OpenAIFastPolicySettings = openaiFastPolicySettingsToDTO(fastPolicy) + } + + // Default platform quotas(JSON map)—— 与 GetSettings 一致,避免保存后响应缺失该字段 + if platformQuotas, err := h.settingService.GetDefaultPlatformQuotas(c.Request.Context()); err != nil { + slog.Error("default_platform_quotas_get_failed", "error", err) + } else { + payload.DefaultPlatformQuotas = platformQuotas + } + response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults)) +} + +// hasPaymentFields returns true if any payment-related field was explicitly provided. +// mapDingTalkValidateError maps ValidateDingTalkConfig errors to machine-readable reason codes. +func mapDingTalkValidateError(err error) string { + switch { + case errors.Is(err, config.ErrDingTalkV1AppTypeMismatch): + return "dingtalk_apptype_mismatch" + case errors.Is(err, config.ErrDingTalkV4InvalidAppKind): + return "dingtalk_app_kind_invalid" + default: + return "dingtalk_corp_config_invalid" + } +} + +func hasPaymentFields(req UpdateSettingsRequest) bool { + return req.PaymentEnabled != nil || req.PaymentMinAmount != nil || + req.PaymentMaxAmount != nil || req.PaymentDailyLimit != nil || + req.PaymentOrderTimeoutMin != nil || req.PaymentMaxPendingOrders != nil || + req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil || + req.PaymentBalanceRechargeMultiplier != nil || req.PaymentSubscriptionUSDToCNYRate != nil || + req.PaymentRechargeFeeRate != nil || + req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil || + req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil || + req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil || + req.PaymentCancelRateLimitMax != nil || req.PaymentCancelRateLimitWindow != nil || + req.PaymentCancelRateLimitUnit != nil || req.PaymentCancelRateLimitMode != nil || + req.PaymentAlipayForceQRCode != nil +} + +// ensureDingTalkSyncAttributes 在保存 settings 后,按 admin 配置的 (attr key, attr name) +// 兜底 upsert 对应 user attribute definition:不存在则创建;存在但 name 不同则更新 name +// (type/options/required 不变)。仅 internal_only + 对应 sync 开关开启时执行。 +// 失败仅记录日志,不阻塞 settings 保存。 +func (h *SettingHandler) ensureDingTalkSyncAttributes(ctx context.Context, settings *service.SystemSettings) { + if h.userAttributeService == nil || settings == nil { + return + } + if settings.DingTalkConnectCorpRestrictionPolicy != "internal_only" { + return + } + if settings.DingTalkConnectSyncDisplayName { + h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncDisplayNameAttrKey, settings.DingTalkConnectSyncDisplayNameAttrName, "钉钉 internal_only 登录时同步的钉钉姓名", service.AttributeTypeText) + } + if settings.DingTalkConnectSyncCorpEmail { + h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncCorpEmailAttrKey, settings.DingTalkConnectSyncCorpEmailAttrName, "钉钉 internal_only 登录时同步的企业邮箱", service.AttributeTypeEmail) + } + if settings.DingTalkConnectSyncDept { + h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncDeptAttrKey, settings.DingTalkConnectSyncDeptAttrName, "钉钉 internal_only 登录时同步的完整部门路径(如:公司/研发部)", service.AttributeTypeText) + } +} + +func (h *SettingHandler) ensureUserAttributeDefinition(ctx context.Context, key, name, description string, attrType service.UserAttributeType) { + key = strings.TrimSpace(key) + if key == "" { + return + } + existing, err := h.userAttributeService.GetDefinitionByKey(ctx, key) + if err == nil && existing != nil { + if strings.TrimSpace(name) != "" && existing.Name != name { + if _, err := h.userAttributeService.UpdateDefinition(ctx, existing.ID, service.UpdateAttributeDefinitionInput{ + Name: &name, + }); err != nil { + slog.Warn("dingtalk: update user attribute definition name failed", "key", key, "err", err.Error()) + return + } + slog.Info("dingtalk: updated user attribute definition name", "key", key, "name", name) + } + return + } + if _, err := h.userAttributeService.CreateDefinition(ctx, service.CreateAttributeDefinitionInput{ + Key: key, + Name: name, + Description: description, + Type: attrType, + Enabled: true, + }); err != nil { + slog.Warn("dingtalk: ensure user attribute definition failed", "key", key, "err", err.Error()) + return + } + slog.Info("dingtalk: created user attribute definition", "key", key, "name", name, "type", attrType) +} diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 24c648b0a5..cbdc863750 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -1,97 +1,17 @@ package repository import ( - "context" "database/sql" - "encoding/json" - "errors" "fmt" - "os" - "strconv" "strings" "sync" - "sync/atomic" "time" dbent "github.com/Wei-Shaw/sub2api/ent" - dbaccount "github.com/Wei-Shaw/sub2api/ent/account" - dbapikey "github.com/Wei-Shaw/sub2api/ent/apikey" - dbgroup "github.com/Wei-Shaw/sub2api/ent/group" - "github.com/Wei-Shaw/sub2api/ent/schema/mixins" - dbuser "github.com/Wei-Shaw/sub2api/ent/user" - dbusersub "github.com/Wei-Shaw/sub2api/ent/usersubscription" - "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" - "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" - "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/lib/pq" gocache "github.com/patrickmn/go-cache" - "golang.org/x/sync/errgroup" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at" - -// usageLogInsertArgTypes must stay in the same order as: -// 1. prepareUsageLogInsert().args -// 2. every INSERT/CTE VALUES column list in this file -// 3. execUsageLogInsertNoResult placeholder positions -// 4. scanUsageLog selected column order (via usageLogSelectColumns) -// -// When adding a usage_logs column, update all of those call sites together. -var usageLogInsertArgTypes = [...]string{ - "bigint", // user_id - "bigint", // api_key_id - "bigint", // account_id - "text", // request_id - "text", // model - "text", // requested_model - "text", // upstream_model - "bigint", // group_id - "bigint", // subscription_id - "integer", // input_tokens - "integer", // output_tokens - "integer", // cache_creation_tokens - "integer", // cache_read_tokens - "integer", // cache_creation_5m_tokens - "integer", // cache_creation_1h_tokens - "integer", // image_output_tokens - "numeric", // image_output_cost - "numeric", // input_cost - "numeric", // output_cost - "numeric", // cache_creation_cost - "numeric", // cache_read_cost - "numeric", // total_cost - "numeric", // actual_cost - "numeric", // rate_multiplier - "numeric", // account_rate_multiplier - "smallint", // billing_type - "smallint", // request_type - "boolean", // stream - "boolean", // openai_ws_mode - "integer", // duration_ms - "integer", // first_token_ms - "text", // user_agent - "text", // ip_address - "integer", // image_count - "text", // image_size - "text", // image_input_size - "text", // image_output_size - "text", // image_size_source - "jsonb", // image_size_breakdown - "text", // service_tier - "text", // reasoning_effort - "text", // inbound_endpoint - "text", // upstream_endpoint - "boolean", // cache_ttl_overridden - "bigint", // channel_id - "text", // model_mapping_chain - "text", // billing_tier - "text", // billing_mode - "numeric", // account_stats_cost - "timestamptz", // created_at -} - const rawUsageLogModelColumn = "model" // rawUsageLogModelColumn preserves the exact stored usage_logs.model semantics for direct filters. @@ -226,68 +146,6 @@ type usageLogRepository struct { bestEffortRecent *gocache.Cache } -const ( - usageLogCreateBatchMaxSize = 64 - usageLogCreateBatchWindow = 3 * time.Millisecond - usageLogCreateBatchQueueCap = 4096 - usageLogCreateCancelWait = 2 * time.Second - - usageLogBestEffortBatchMaxSize = 256 - usageLogBestEffortBatchWindow = 20 * time.Millisecond - usageLogBestEffortBatchQueueCap = 32768 - usageLogBestEffortRecentTTL = 30 * time.Second -) - -type usageLogCreateRequest struct { - log *service.UsageLog - prepared usageLogInsertPrepared - shared *usageLogCreateShared - resultCh chan usageLogCreateResult -} - -type usageLogCreateResult struct { - inserted bool - err error -} - -type usageLogBestEffortRequest struct { - prepared usageLogInsertPrepared - apiKeyID int64 - resultCh chan error -} - -type usageLogInsertPrepared struct { - createdAt time.Time - requestID string - rateMultiplier float64 - requestType int16 - args []any -} - -type usageLogBatchState struct { - ID int64 - CreatedAt time.Time -} - -type usageLogBatchRow struct { - RequestID string `json:"request_id"` - APIKeyID int64 `json:"api_key_id"` - ID int64 `json:"id"` - CreatedAt time.Time `json:"created_at"` - Inserted bool `json:"inserted"` -} - -type usageLogCreateShared struct { - state atomic.Int32 -} - -const ( - usageLogCreateStateQueued int32 = iota - usageLogCreateStateProcessing - usageLogCreateStateCompleted - usageLogCreateStateCanceled -) - func NewUsageLogRepository(client *dbent.Client, sqlDB *sql.DB) service.UsageLogRepository { return newUsageLogRepositoryWithSQL(client, sqlDB) } @@ -302,4284 +160,6 @@ func newUsageLogRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *usage return repo } -// getPerformanceStats 获取 RPM 和 TPM(近5分钟平均值,可选按用户过滤) -func (r *usageLogRepository) getPerformanceStats(ctx context.Context, userID int64) (rpm, tpm int64, err error) { - fiveMinutesAgo := time.Now().Add(-5 * time.Minute) - query := ` - SELECT - COUNT(*) as request_count, - COALESCE(SUM(input_tokens + output_tokens), 0) as token_count - FROM usage_logs - WHERE created_at >= $1` - args := []any{fiveMinutesAgo} - if userID > 0 { - query += " AND user_id = $2" - args = append(args, userID) - } - - var requestCount int64 - var tokenCount int64 - if err := scanSingleRow(ctx, r.sql, query, args, &requestCount, &tokenCount); err != nil { - return 0, 0, err - } - return requestCount / 5, tokenCount / 5, nil -} - -func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) (bool, error) { - if log == nil { - return false, nil - } - - if tx := dbent.TxFromContext(ctx); tx != nil { - return r.createSingle(ctx, tx.Client(), log) - } - requestID := strings.TrimSpace(log.RequestID) - if requestID == "" { - return r.createSingle(ctx, r.sql, log) - } - log.RequestID = requestID - return r.createBatched(ctx, log) -} - -func (r *usageLogRepository) CreateBestEffort(ctx context.Context, log *service.UsageLog) error { - if log == nil { - return nil - } - - if tx := dbent.TxFromContext(ctx); tx != nil { - _, err := r.createSingle(ctx, tx.Client(), log) - return err - } - if r.db == nil { - _, err := r.createSingle(ctx, r.sql, log) - return err - } - - r.ensureBestEffortBatcher() - if r.bestEffortBatchCh == nil { - _, err := r.createSingle(ctx, r.sql, log) - return err - } - - req := usageLogBestEffortRequest{ - prepared: prepareUsageLogInsert(log), - apiKeyID: log.APIKeyID, - resultCh: make(chan error, 1), - } - if key, ok := r.bestEffortRecentKey(req.prepared.requestID, req.apiKeyID); ok { - if _, exists := r.bestEffortRecent.Get(key); exists { - return nil - } - } - - // 队列满时阻塞等待而非立即丢弃:批处理器持续排空队列,短暂等待即可入队。 - // 立即丢弃会造成“已扣费但无 usage_log”的永久数据缺口(issue #3656); - // 阻塞上限由调用方 ctx 期限约束,超时后由上层同步兜底。 - select { - case r.bestEffortBatchCh <- req: - case <-ctx.Done(): - return service.MarkUsageLogCreateDropped(ctx.Err()) - } - - select { - case err := <-req.resultCh: - return err - case <-ctx.Done(): - return service.MarkUsageLogCreateDropped(ctx.Err()) - } -} - -func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, log *service.UsageLog) (bool, error) { - prepared := prepareUsageLogInsert(log) - if sqlq == nil { - sqlq = r.sql - } - if ctx != nil && ctx.Err() != nil { - return false, service.MarkUsageLogCreateNotPersisted(ctx.Err()) - } - - query := ` - INSERT INTO usage_logs ( - user_id, - api_key_id, - account_id, - request_id, - model, - requested_model, - upstream_model, - group_id, - subscription_id, - input_tokens, - output_tokens, - cache_creation_tokens, - cache_read_tokens, - cache_creation_5m_tokens, - cache_creation_1h_tokens, - image_output_tokens, - image_output_cost, - input_cost, - output_cost, - cache_creation_cost, - cache_read_cost, - total_cost, - actual_cost, - rate_multiplier, - account_rate_multiplier, - billing_type, - request_type, - stream, - openai_ws_mode, - duration_ms, - first_token_ms, - user_agent, - ip_address, - image_count, - image_size, - image_input_size, - image_output_size, - image_size_source, - image_size_breakdown, - service_tier, - reasoning_effort, - inbound_endpoint, - upstream_endpoint, - cache_ttl_overridden, - channel_id, - model_mapping_chain, - billing_tier, - billing_mode, - account_stats_cost, - created_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, - $8, $9, - $10, $11, $12, $13, - $14, $15, $16, $17, - $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50 - ) - ON CONFLICT (request_id, api_key_id) DO NOTHING - RETURNING id, created_at - ` - - if err := scanSingleRow(ctx, sqlq, query, prepared.args, &log.ID, &log.CreatedAt); err != nil { - if errors.Is(err, sql.ErrNoRows) && prepared.requestID != "" { - selectQuery := "SELECT id, created_at FROM usage_logs WHERE request_id = $1 AND api_key_id = $2" - if err := scanSingleRow(ctx, sqlq, selectQuery, []any{prepared.requestID, log.APIKeyID}, &log.ID, &log.CreatedAt); err != nil { - return false, err - } - log.RateMultiplier = prepared.rateMultiplier - return false, nil - } else { - return false, err - } - } - log.RateMultiplier = prepared.rateMultiplier - return true, nil -} - -func (r *usageLogRepository) createBatched(ctx context.Context, log *service.UsageLog) (bool, error) { - if r.db == nil { - return r.createSingle(ctx, r.sql, log) - } - r.ensureCreateBatcher() - if r.createBatchCh == nil { - return r.createSingle(ctx, r.sql, log) - } - - req := usageLogCreateRequest{ - log: log, - prepared: prepareUsageLogInsert(log), - shared: &usageLogCreateShared{}, - resultCh: make(chan usageLogCreateResult, 1), - } - - // 队列满时阻塞等待而非立即报错:本路径是 best-effort 丢弃后的最后兜底, - // 立即失败会让日志永久丢失;阻塞上限由调用方 ctx 期限约束。 - select { - case r.createBatchCh <- req: - case <-ctx.Done(): - return false, service.MarkUsageLogCreateNotPersisted(ctx.Err()) - } - - select { - case res := <-req.resultCh: - return res.inserted, res.err - case <-ctx.Done(): - if req.shared != nil && req.shared.state.CompareAndSwap(usageLogCreateStateQueued, usageLogCreateStateCanceled) { - return false, service.MarkUsageLogCreateNotPersisted(ctx.Err()) - } - timer := time.NewTimer(usageLogCreateCancelWait) - defer timer.Stop() - select { - case res := <-req.resultCh: - return res.inserted, res.err - case <-timer.C: - return false, ctx.Err() - } - } -} - -func (r *usageLogRepository) ensureCreateBatcher() { - if r == nil || r.db == nil { - return - } - // nil 检查必须在 Once 内部:在外层做无同步快路径读会与 Once 内的写构成数据竞争。 - r.createBatchOnce.Do(func() { - if r.createBatchCh == nil { - r.createBatchCh = make(chan usageLogCreateRequest, usageLogCreateBatchQueueCap) - go r.runCreateBatcher(r.db) - } - }) -} - -func (r *usageLogRepository) ensureBestEffortBatcher() { - if r == nil || r.db == nil { - return - } - // 同 ensureCreateBatcher:nil 检查放在 Once 内部以避免数据竞争。 - r.bestEffortBatchOnce.Do(func() { - if r.bestEffortBatchCh == nil { - r.bestEffortBatchCh = make(chan usageLogBestEffortRequest, usageLogBestEffortBatchQueueCap) - go r.runBestEffortBatcher(r.db) - } - }) -} - -func (r *usageLogRepository) runCreateBatcher(db *sql.DB) { - for { - first, ok := <-r.createBatchCh - if !ok { - return - } - - batch := make([]usageLogCreateRequest, 0, usageLogCreateBatchMaxSize) - batch = append(batch, first) - - timer := time.NewTimer(usageLogCreateBatchWindow) - batchLoop: - for len(batch) < usageLogCreateBatchMaxSize { - select { - case req, ok := <-r.createBatchCh: - if !ok { - break batchLoop - } - batch = append(batch, req) - case <-timer.C: - break batchLoop - } - } - if !timer.Stop() { - select { - case <-timer.C: - default: - } - } - - r.flushCreateBatch(db, batch) - } -} - -func (r *usageLogRepository) runBestEffortBatcher(db *sql.DB) { - for { - first, ok := <-r.bestEffortBatchCh - if !ok { - return - } - - batch := make([]usageLogBestEffortRequest, 0, usageLogBestEffortBatchMaxSize) - batch = append(batch, first) - - timer := time.NewTimer(usageLogBestEffortBatchWindow) - bestEffortLoop: - for len(batch) < usageLogBestEffortBatchMaxSize { - select { - case req, ok := <-r.bestEffortBatchCh: - if !ok { - break bestEffortLoop - } - batch = append(batch, req) - case <-timer.C: - break bestEffortLoop - } - } - if !timer.Stop() { - select { - case <-timer.C: - default: - } - } - - r.flushBestEffortBatch(db, batch) - } -} - -func (r *usageLogRepository) flushCreateBatch(db *sql.DB, batch []usageLogCreateRequest) { - if len(batch) == 0 { - return - } - - uniqueOrder := make([]string, 0, len(batch)) - preparedByKey := make(map[string]usageLogInsertPrepared, len(batch)) - requestsByKey := make(map[string][]usageLogCreateRequest, len(batch)) - fallback := make([]usageLogCreateRequest, 0) - - for _, req := range batch { - if req.log == nil { - completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: false, err: nil}) - continue - } - if req.shared != nil && !req.shared.state.CompareAndSwap(usageLogCreateStateQueued, usageLogCreateStateProcessing) { - if req.shared.state.Load() == usageLogCreateStateCanceled { - completeUsageLogCreateRequest(req, usageLogCreateResult{ - inserted: false, - err: service.MarkUsageLogCreateNotPersisted(context.Canceled), - }) - continue - } - } - prepared := req.prepared - if prepared.requestID == "" { - fallback = append(fallback, req) - continue - } - key := usageLogBatchKey(prepared.requestID, req.log.APIKeyID) - if _, exists := requestsByKey[key]; !exists { - uniqueOrder = append(uniqueOrder, key) - preparedByKey[key] = prepared - } - requestsByKey[key] = append(requestsByKey[key], req) - } - - if len(uniqueOrder) > 0 { - insertedMap, stateMap, safeFallback, err := r.batchInsertUsageLogs(db, uniqueOrder, preparedByKey) - if err != nil { - if safeFallback { - for _, key := range uniqueOrder { - fallback = append(fallback, requestsByKey[key]...) - } - } else { - for _, key := range uniqueOrder { - reqs := requestsByKey[key] - state, hasState := stateMap[key] - inserted := insertedMap[key] - for idx, req := range reqs { - req.log.RateMultiplier = preparedByKey[key].rateMultiplier - if hasState { - req.log.ID = state.ID - req.log.CreatedAt = state.CreatedAt - } - switch { - case inserted && idx == 0: - completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: true, err: nil}) - case inserted: - completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: false, err: nil}) - case hasState: - completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: false, err: nil}) - case idx == 0: - completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: false, err: err}) - default: - completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: false, err: nil}) - } - } - } - } - } else { - for _, key := range uniqueOrder { - reqs := requestsByKey[key] - state, ok := stateMap[key] - if !ok { - for _, req := range reqs { - completeUsageLogCreateRequest(req, usageLogCreateResult{ - inserted: false, - err: fmt.Errorf("usage log batch state missing for key=%s", key), - }) - } - continue - } - for idx, req := range reqs { - req.log.ID = state.ID - req.log.CreatedAt = state.CreatedAt - req.log.RateMultiplier = preparedByKey[key].rateMultiplier - completeUsageLogCreateRequest(req, usageLogCreateResult{ - inserted: idx == 0 && insertedMap[key], - err: nil, - }) - } - } - } - } - - if len(fallback) == 0 { - return - } - - for _, req := range fallback { - fallbackCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - inserted, err := r.createSingle(fallbackCtx, db, req.log) - cancel() - completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: inserted, err: err}) - } -} - -func (r *usageLogRepository) flushBestEffortBatch(db *sql.DB, batch []usageLogBestEffortRequest) { - if len(batch) == 0 { - return - } - - type bestEffortGroup struct { - prepared usageLogInsertPrepared - apiKeyID int64 - key string - reqs []usageLogBestEffortRequest - } - - groupsByKey := make(map[string]*bestEffortGroup, len(batch)) - groupOrder := make([]*bestEffortGroup, 0, len(batch)) - preparedList := make([]usageLogInsertPrepared, 0, len(batch)) - - for idx, req := range batch { - prepared := req.prepared - key := fmt.Sprintf("__best_effort_%d", idx) - if prepared.requestID != "" { - key = usageLogBatchKey(prepared.requestID, req.apiKeyID) - } - group, exists := groupsByKey[key] - if !exists { - group = &bestEffortGroup{ - prepared: prepared, - apiKeyID: req.apiKeyID, - key: key, - } - groupsByKey[key] = group - groupOrder = append(groupOrder, group) - preparedList = append(preparedList, prepared) - } - group.reqs = append(group.reqs, req) - } - - if len(preparedList) == 0 { - for _, req := range batch { - sendUsageLogBestEffortResult(req.resultCh, nil) - } - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - query, args := buildUsageLogBestEffortInsertQuery(preparedList) - if _, err := db.ExecContext(ctx, query, args...); err != nil { - logger.LegacyPrintf("repository.usage_log", "best-effort batch insert failed: %v", err) - for _, group := range groupOrder { - singleErr := execUsageLogInsertNoResult(ctx, db, group.prepared) - if singleErr != nil { - logger.LegacyPrintf("repository.usage_log", "best-effort single fallback insert failed: %v", singleErr) - } else if group.prepared.requestID != "" && r != nil && r.bestEffortRecent != nil { - r.bestEffortRecent.SetDefault(group.key, struct{}{}) - } - for _, req := range group.reqs { - sendUsageLogBestEffortResult(req.resultCh, singleErr) - } - } - return - } - for _, group := range groupOrder { - if group.prepared.requestID != "" && r != nil && r.bestEffortRecent != nil { - r.bestEffortRecent.SetDefault(group.key, struct{}{}) - } - for _, req := range group.reqs { - sendUsageLogBestEffortResult(req.resultCh, nil) - } - } -} - -func sendUsageLogBestEffortResult(ch chan error, err error) { - if ch == nil { - return - } - select { - case ch <- err: - default: - } -} - -func completeUsageLogCreateRequest(req usageLogCreateRequest, res usageLogCreateResult) { - if req.shared != nil { - req.shared.state.Store(usageLogCreateStateCompleted) - } - sendUsageLogCreateResult(req.resultCh, res) -} - -func (r *usageLogRepository) batchInsertUsageLogs(db *sql.DB, keys []string, preparedByKey map[string]usageLogInsertPrepared) (map[string]bool, map[string]usageLogBatchState, bool, error) { - if len(keys) == 0 { - return map[string]bool{}, map[string]usageLogBatchState{}, false, nil - } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - query, args := buildUsageLogBatchInsertQuery(keys, preparedByKey) - var payload []byte - if err := db.QueryRowContext(ctx, query, args...).Scan(&payload); err != nil { - return nil, nil, true, err - } - var rows []usageLogBatchRow - if err := json.Unmarshal(payload, &rows); err != nil { - return nil, nil, false, err - } - insertedMap := make(map[string]bool, len(keys)) - stateMap := make(map[string]usageLogBatchState, len(keys)) - for _, row := range rows { - key := usageLogBatchKey(row.RequestID, row.APIKeyID) - insertedMap[key] = row.Inserted - stateMap[key] = usageLogBatchState{ - ID: row.ID, - CreatedAt: row.CreatedAt, - } - } - if len(stateMap) != len(keys) { - return insertedMap, stateMap, false, fmt.Errorf("usage log batch state count mismatch: got=%d want=%d", len(stateMap), len(keys)) - } - return insertedMap, stateMap, false, nil -} - -func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usageLogInsertPrepared) (string, []any) { - var query strings.Builder - _, _ = query.WriteString(` - WITH input ( - input_idx, - user_id, - api_key_id, - account_id, - request_id, - model, - requested_model, - upstream_model, - group_id, - subscription_id, - input_tokens, - output_tokens, - cache_creation_tokens, - cache_read_tokens, - cache_creation_5m_tokens, - cache_creation_1h_tokens, - image_output_tokens, - image_output_cost, - input_cost, - output_cost, - cache_creation_cost, - cache_read_cost, - total_cost, - actual_cost, - rate_multiplier, - account_rate_multiplier, - billing_type, - request_type, - stream, - openai_ws_mode, - duration_ms, - first_token_ms, - user_agent, - ip_address, - image_count, - image_size, - image_input_size, - image_output_size, - image_size_source, - image_size_breakdown, - service_tier, - reasoning_effort, - inbound_endpoint, - upstream_endpoint, - cache_ttl_overridden, - channel_id, - model_mapping_chain, - billing_tier, - billing_mode, - account_stats_cost, - created_at - ) AS (VALUES `) - - args := make([]any, 0, len(keys)*50) - argPos := 1 - for idx, key := range keys { - if idx > 0 { - _, _ = query.WriteString(",") - } - _, _ = query.WriteString("(") - _, _ = query.WriteString("$") - _, _ = query.WriteString(strconv.Itoa(argPos)) - args = append(args, idx) - argPos++ - prepared := preparedByKey[key] - for i := 0; i < len(prepared.args); i++ { - _, _ = query.WriteString(",") - _, _ = query.WriteString("$") - _, _ = query.WriteString(strconv.Itoa(argPos)) - if i < len(usageLogInsertArgTypes) { - _, _ = query.WriteString("::") - _, _ = query.WriteString(usageLogInsertArgTypes[i]) - } - argPos++ - } - _, _ = query.WriteString(")") - args = append(args, prepared.args...) - } - _, _ = query.WriteString(` - ), - inserted AS ( - INSERT INTO usage_logs ( - user_id, - api_key_id, - account_id, - request_id, - model, - requested_model, - upstream_model, - group_id, - subscription_id, - input_tokens, - output_tokens, - cache_creation_tokens, - cache_read_tokens, - cache_creation_5m_tokens, - cache_creation_1h_tokens, - image_output_tokens, - image_output_cost, - input_cost, - output_cost, - cache_creation_cost, - cache_read_cost, - total_cost, - actual_cost, - rate_multiplier, - account_rate_multiplier, - billing_type, - request_type, - stream, - openai_ws_mode, - duration_ms, - first_token_ms, - user_agent, - ip_address, - image_count, - image_size, - image_input_size, - image_output_size, - image_size_source, - image_size_breakdown, - service_tier, - reasoning_effort, - inbound_endpoint, - upstream_endpoint, - cache_ttl_overridden, - channel_id, - model_mapping_chain, - billing_tier, - billing_mode, - account_stats_cost, - created_at - ) - SELECT - user_id, - api_key_id, - account_id, - request_id, - model, - requested_model, - upstream_model, - group_id, - subscription_id, - input_tokens, - output_tokens, - cache_creation_tokens, - cache_read_tokens, - cache_creation_5m_tokens, - cache_creation_1h_tokens, - image_output_tokens, - image_output_cost, - input_cost, - output_cost, - cache_creation_cost, - cache_read_cost, - total_cost, - actual_cost, - rate_multiplier, - account_rate_multiplier, - billing_type, - request_type, - stream, - openai_ws_mode, - duration_ms, - first_token_ms, - user_agent, - ip_address, - image_count, - image_size, - image_input_size, - image_output_size, - image_size_source, - image_size_breakdown, - service_tier, - reasoning_effort, - inbound_endpoint, - upstream_endpoint, - cache_ttl_overridden, - channel_id, - model_mapping_chain, - billing_tier, - billing_mode, - account_stats_cost, - created_at - FROM input - ON CONFLICT (request_id, api_key_id) DO NOTHING - RETURNING request_id, api_key_id, id, created_at - ), - resolved AS ( - SELECT - input.input_idx, - input.request_id, - input.api_key_id, - COALESCE(inserted.id, existing.id) AS id, - COALESCE(inserted.created_at, existing.created_at) AS created_at, - (inserted.id IS NOT NULL) AS inserted - FROM input - LEFT JOIN inserted - ON inserted.request_id = input.request_id - AND inserted.api_key_id = input.api_key_id - LEFT JOIN usage_logs existing - ON existing.request_id = input.request_id - AND existing.api_key_id = input.api_key_id - ) - SELECT COALESCE( - json_agg( - json_build_object( - 'request_id', resolved.request_id, - 'api_key_id', resolved.api_key_id, - 'id', resolved.id, - 'created_at', resolved.created_at, - 'inserted', resolved.inserted - ) - ORDER BY resolved.input_idx - ), - '[]'::json - ) - FROM resolved - `) - return query.String(), args -} - -func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (string, []any) { - var query strings.Builder - _, _ = query.WriteString(` - WITH input ( - user_id, - api_key_id, - account_id, - request_id, - model, - requested_model, - upstream_model, - group_id, - subscription_id, - input_tokens, - output_tokens, - cache_creation_tokens, - cache_read_tokens, - cache_creation_5m_tokens, - cache_creation_1h_tokens, - image_output_tokens, - image_output_cost, - input_cost, - output_cost, - cache_creation_cost, - cache_read_cost, - total_cost, - actual_cost, - rate_multiplier, - account_rate_multiplier, - billing_type, - request_type, - stream, - openai_ws_mode, - duration_ms, - first_token_ms, - user_agent, - ip_address, - image_count, - image_size, - image_input_size, - image_output_size, - image_size_source, - image_size_breakdown, - service_tier, - reasoning_effort, - inbound_endpoint, - upstream_endpoint, - cache_ttl_overridden, - channel_id, - model_mapping_chain, - billing_tier, - billing_mode, - account_stats_cost, - created_at - ) AS (VALUES `) - - args := make([]any, 0, len(preparedList)*50) - argPos := 1 - for idx, prepared := range preparedList { - if idx > 0 { - _, _ = query.WriteString(",") - } - _, _ = query.WriteString("(") - for i := 0; i < len(prepared.args); i++ { - if i > 0 { - _, _ = query.WriteString(",") - } - _, _ = query.WriteString("$") - _, _ = query.WriteString(strconv.Itoa(argPos)) - if i < len(usageLogInsertArgTypes) { - _, _ = query.WriteString("::") - _, _ = query.WriteString(usageLogInsertArgTypes[i]) - } - argPos++ - } - _, _ = query.WriteString(")") - args = append(args, prepared.args...) - } - - _, _ = query.WriteString(` - ) - INSERT INTO usage_logs ( - user_id, - api_key_id, - account_id, - request_id, - model, - requested_model, - upstream_model, - group_id, - subscription_id, - input_tokens, - output_tokens, - cache_creation_tokens, - cache_read_tokens, - cache_creation_5m_tokens, - cache_creation_1h_tokens, - image_output_tokens, - image_output_cost, - input_cost, - output_cost, - cache_creation_cost, - cache_read_cost, - total_cost, - actual_cost, - rate_multiplier, - account_rate_multiplier, - billing_type, - request_type, - stream, - openai_ws_mode, - duration_ms, - first_token_ms, - user_agent, - ip_address, - image_count, - image_size, - image_input_size, - image_output_size, - image_size_source, - image_size_breakdown, - service_tier, - reasoning_effort, - inbound_endpoint, - upstream_endpoint, - cache_ttl_overridden, - channel_id, - model_mapping_chain, - billing_tier, - billing_mode, - account_stats_cost, - created_at - ) - SELECT - user_id, - api_key_id, - account_id, - request_id, - model, - requested_model, - upstream_model, - group_id, - subscription_id, - input_tokens, - output_tokens, - cache_creation_tokens, - cache_read_tokens, - cache_creation_5m_tokens, - cache_creation_1h_tokens, - image_output_tokens, - image_output_cost, - input_cost, - output_cost, - cache_creation_cost, - cache_read_cost, - total_cost, - actual_cost, - rate_multiplier, - account_rate_multiplier, - billing_type, - request_type, - stream, - openai_ws_mode, - duration_ms, - first_token_ms, - user_agent, - ip_address, - image_count, - image_size, - image_input_size, - image_output_size, - image_size_source, - image_size_breakdown, - service_tier, - reasoning_effort, - inbound_endpoint, - upstream_endpoint, - cache_ttl_overridden, - channel_id, - model_mapping_chain, - billing_tier, - billing_mode, - account_stats_cost, - created_at - FROM input - ON CONFLICT (request_id, api_key_id) DO NOTHING - `) - - return query.String(), args -} - -func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared usageLogInsertPrepared) error { - _, err := sqlq.ExecContext(ctx, ` - INSERT INTO usage_logs ( - user_id, - api_key_id, - account_id, - request_id, - model, - requested_model, - upstream_model, - group_id, - subscription_id, - input_tokens, - output_tokens, - cache_creation_tokens, - cache_read_tokens, - cache_creation_5m_tokens, - cache_creation_1h_tokens, - image_output_tokens, - image_output_cost, - input_cost, - output_cost, - cache_creation_cost, - cache_read_cost, - total_cost, - actual_cost, - rate_multiplier, - account_rate_multiplier, - billing_type, - request_type, - stream, - openai_ws_mode, - duration_ms, - first_token_ms, - user_agent, - ip_address, - image_count, - image_size, - image_input_size, - image_output_size, - image_size_source, - image_size_breakdown, - service_tier, - reasoning_effort, - inbound_endpoint, - upstream_endpoint, - cache_ttl_overridden, - channel_id, - model_mapping_chain, - billing_tier, - billing_mode, - account_stats_cost, - created_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, - $8, $9, - $10, $11, $12, $13, - $14, $15, $16, $17, - $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50 - ) - ON CONFLICT (request_id, api_key_id) DO NOTHING - `, prepared.args...) - return err -} - -func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { - createdAt := log.CreatedAt - if createdAt.IsZero() { - createdAt = time.Now() - } - - requestID := strings.TrimSpace(log.RequestID) - log.RequestID = requestID - - rateMultiplier := log.RateMultiplier - log.SyncRequestTypeAndLegacyFields() - requestType := int16(log.RequestType) - - groupID := nullInt64(log.GroupID) - subscriptionID := nullInt64(log.SubscriptionID) - duration := nullInt(log.DurationMs) - firstToken := nullInt(log.FirstTokenMs) - userAgent := nullString(log.UserAgent) - ipAddress := nullString(log.IPAddress) - imageSize := nullString(log.ImageSize) - imageInputSize := nullString(log.ImageInputSize) - imageOutputSize := nullString(log.ImageOutputSize) - imageSizeSource := nullString(log.ImageSizeSource) - imageSizeBreakdown := nullStringIntMapJSON(log.ImageSizeBreakdown) - serviceTier := nullString(log.ServiceTier) - reasoningEffort := nullString(log.ReasoningEffort) - inboundEndpoint := nullString(log.InboundEndpoint) - upstreamEndpoint := nullString(log.UpstreamEndpoint) - channelID := nullInt64(log.ChannelID) - modelMappingChain := nullString(log.ModelMappingChain) - billingTier := nullString(log.BillingTier) - billingMode := nullString(log.BillingMode) - requestedModel := strings.TrimSpace(log.RequestedModel) - if requestedModel == "" { - requestedModel = strings.TrimSpace(log.Model) - } - upstreamModel := nullString(log.UpstreamModel) - - var requestIDArg any - if requestID != "" { - requestIDArg = requestID - } - - return usageLogInsertPrepared{ - createdAt: createdAt, - requestID: requestID, - rateMultiplier: rateMultiplier, - requestType: requestType, - args: []any{ - log.UserID, - log.APIKeyID, - log.AccountID, - requestIDArg, - log.Model, - nullString(&requestedModel), - upstreamModel, - groupID, - subscriptionID, - log.InputTokens, - log.OutputTokens, - log.CacheCreationTokens, - log.CacheReadTokens, - log.CacheCreation5mTokens, - log.CacheCreation1hTokens, - log.ImageOutputTokens, - log.ImageOutputCost, - log.InputCost, - log.OutputCost, - log.CacheCreationCost, - log.CacheReadCost, - log.TotalCost, - log.ActualCost, - rateMultiplier, - log.AccountRateMultiplier, - log.BillingType, - requestType, - log.Stream, - log.OpenAIWSMode, - duration, - firstToken, - userAgent, - ipAddress, - log.ImageCount, - imageSize, - imageInputSize, - imageOutputSize, - imageSizeSource, - imageSizeBreakdown, - serviceTier, - reasoningEffort, - inboundEndpoint, - upstreamEndpoint, - log.CacheTTLOverridden, - channelID, - modelMappingChain, - billingTier, - billingMode, - log.AccountStatsCost, // account_stats_cost - createdAt, - }, - } -} - -func usageLogBatchKey(requestID string, apiKeyID int64) string { - return requestID + "\x1f" + strconv.FormatInt(apiKeyID, 10) -} - -func sendUsageLogCreateResult(ch chan usageLogCreateResult, res usageLogCreateResult) { - if ch == nil { - return - } - select { - case ch <- res: - default: - } -} - -func (r *usageLogRepository) bestEffortRecentKey(requestID string, apiKeyID int64) (string, bool) { - requestID = strings.TrimSpace(requestID) - if requestID == "" || r == nil || r.bestEffortRecent == nil { - return "", false - } - return usageLogBatchKey(requestID, apiKeyID), true -} - -func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { - query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" - rows, err := r.sql.QueryContext(ctx, query, id) - if err != nil { - return nil, err - } - defer func() { - // 保持主错误优先;仅在无错误时回传 Close 失败。 - // 同时清空返回值,避免误用不完整结果。 - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - log = nil - } - }() - if !rows.Next() { - if err = rows.Err(); err != nil { - return nil, err - } - return nil, service.ErrUsageLogNotFound - } - log, err = scanUsageLog(rows) - if err != nil { - return nil, err - } - if err = rows.Err(); err != nil { - return nil, err - } - return log, nil -} - -func (r *usageLogRepository) ListByUser(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { - return r.listUsageLogsWithPagination(ctx, "WHERE user_id = $1", []any{userID}, params) -} - -func (r *usageLogRepository) ListByAPIKey(ctx context.Context, apiKeyID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { - return r.listUsageLogsWithPagination(ctx, "WHERE api_key_id = $1", []any{apiKeyID}, params) -} - -// UserStats 用户使用统计 -type UserStats struct { - TotalRequests int64 `json:"total_requests"` - TotalTokens int64 `json:"total_tokens"` - TotalCost float64 `json:"total_cost"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CacheReadTokens int64 `json:"cache_read_tokens"` -} - -func (r *usageLogRepository) GetUserStats(ctx context.Context, userID int64, startTime, endTime time.Time) (*UserStats, error) { - query := ` - SELECT - COUNT(*) as total_requests, - COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens, - COALESCE(SUM(actual_cost), 0) as total_cost, - COALESCE(SUM(input_tokens), 0) as input_tokens, - COALESCE(SUM(output_tokens), 0) as output_tokens, - COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens - FROM usage_logs - WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 - ` - - stats := &UserStats{} - if err := scanSingleRow( - ctx, - r.sql, - query, - []any{userID, startTime, endTime}, - &stats.TotalRequests, - &stats.TotalTokens, - &stats.TotalCost, - &stats.InputTokens, - &stats.OutputTokens, - &stats.CacheReadTokens, - ); err != nil { - return nil, err - } - return stats, nil -} - -// DashboardStats 仪表盘统计 -type DashboardStats = usagestats.DashboardStats - -func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) { - stats := &DashboardStats{} - now := timezone.Now() - todayStart := timezone.Today() - - if err := r.fillDashboardEntityStats(ctx, stats, todayStart, now); err != nil { - return nil, err - } - if err := r.fillDashboardUsageStatsAggregated(ctx, stats, todayStart, now); err != nil { - return nil, err - } - - rpm, tpm, err := r.getPerformanceStats(ctx, 0) - if err != nil { - return nil, err - } - stats.Rpm = rpm - stats.Tpm = tpm - - return stats, nil -} - -func (r *usageLogRepository) GetDashboardStatsWithRange(ctx context.Context, start, end time.Time) (*DashboardStats, error) { - startUTC := start.UTC() - endUTC := end.UTC() - if !endUTC.After(startUTC) { - return nil, errors.New("统计时间范围无效") - } - - stats := &DashboardStats{} - now := timezone.Now() - todayStart := timezone.Today() - - if err := r.fillDashboardEntityStats(ctx, stats, todayStart, now); err != nil { - return nil, err - } - if err := r.fillDashboardUsageStatsFromUsageLogs(ctx, stats, startUTC, endUTC, todayStart, now); err != nil { - return nil, err - } - - rpm, tpm, err := r.getPerformanceStats(ctx, 0) - if err != nil { - return nil, err - } - stats.Rpm = rpm - stats.Tpm = tpm - - return stats, nil -} - -func (r *usageLogRepository) fillDashboardEntityStats(ctx context.Context, stats *DashboardStats, todayUTC, now time.Time) error { - userStatsQuery := ` - SELECT - COUNT(*) as total_users, - COUNT(CASE WHEN created_at >= $1 THEN 1 END) as today_new_users - FROM users - WHERE deleted_at IS NULL - ` - if err := scanSingleRow( - ctx, - r.sql, - userStatsQuery, - []any{todayUTC}, - &stats.TotalUsers, - &stats.TodayNewUsers, - ); err != nil { - return err - } - - apiKeyStatsQuery := ` - SELECT - COUNT(*) as total_api_keys, - COUNT(CASE WHEN status = $1 THEN 1 END) as active_api_keys - FROM api_keys - WHERE deleted_at IS NULL - ` - if err := scanSingleRow( - ctx, - r.sql, - apiKeyStatsQuery, - []any{service.StatusActive}, - &stats.TotalAPIKeys, - &stats.ActiveAPIKeys, - ); err != nil { - return err - } - - accountStatsQuery := ` - SELECT - COUNT(*) as total_accounts, - COUNT(CASE WHEN status = $1 AND schedulable = true THEN 1 END) as normal_accounts, - COUNT(CASE WHEN status = $2 THEN 1 END) as error_accounts, - COUNT(CASE WHEN rate_limited_at IS NOT NULL AND rate_limit_reset_at > $3 THEN 1 END) as ratelimit_accounts, - COUNT(CASE WHEN overload_until IS NOT NULL AND overload_until > $4 THEN 1 END) as overload_accounts - FROM accounts - WHERE deleted_at IS NULL - ` - if err := scanSingleRow( - ctx, - r.sql, - accountStatsQuery, - []any{service.StatusActive, service.StatusError, now, now}, - &stats.TotalAccounts, - &stats.NormalAccounts, - &stats.ErrorAccounts, - &stats.RateLimitAccounts, - &stats.OverloadAccounts, - ); err != nil { - return err - } - - return nil -} - -func (r *usageLogRepository) fillDashboardUsageStatsAggregated(ctx context.Context, stats *DashboardStats, todayUTC, now time.Time) error { - totalStatsQuery := ` - SELECT - COALESCE(SUM(total_requests), 0) as total_requests, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, - COALESCE(SUM(total_cost), 0) as total_cost, - COALESCE(SUM(actual_cost), 0) as total_actual_cost, - COALESCE(SUM(account_cost), 0) as total_account_cost, - COALESCE(SUM(total_duration_ms), 0) as total_duration_ms - FROM usage_dashboard_daily - ` - var totalDurationMs int64 - if err := scanSingleRow( - ctx, - r.sql, - totalStatsQuery, - nil, - &stats.TotalRequests, - &stats.TotalInputTokens, - &stats.TotalOutputTokens, - &stats.TotalCacheCreationTokens, - &stats.TotalCacheReadTokens, - &stats.TotalCost, - &stats.TotalActualCost, - &stats.TotalAccountCost, - &totalDurationMs, - ); err != nil { - return err - } - stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens - if stats.TotalRequests > 0 { - stats.AverageDurationMs = float64(totalDurationMs) / float64(stats.TotalRequests) - } - - todayStatsQuery := ` - SELECT - total_requests as today_requests, - input_tokens as today_input_tokens, - output_tokens as today_output_tokens, - cache_creation_tokens as today_cache_creation_tokens, - cache_read_tokens as today_cache_read_tokens, - total_cost as today_cost, - actual_cost as today_actual_cost, - account_cost as today_account_cost, - active_users as active_users - FROM usage_dashboard_daily - WHERE bucket_date = $1::date - ` - if err := scanSingleRow( - ctx, - r.sql, - todayStatsQuery, - []any{todayUTC}, - &stats.TodayRequests, - &stats.TodayInputTokens, - &stats.TodayOutputTokens, - &stats.TodayCacheCreationTokens, - &stats.TodayCacheReadTokens, - &stats.TodayCost, - &stats.TodayActualCost, - &stats.TodayAccountCost, - &stats.ActiveUsers, - ); err != nil { - if err != sql.ErrNoRows { - return err - } - } - stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens - - hourlyActiveQuery := ` - SELECT active_users - FROM usage_dashboard_hourly - WHERE bucket_start = $1 - ` - hourStart := now.In(timezone.Location()).Truncate(time.Hour) - if err := scanSingleRow(ctx, r.sql, hourlyActiveQuery, []any{hourStart}, &stats.HourlyActiveUsers); err != nil { - if err != sql.ErrNoRows { - return err - } - } - - return nil -} - -func (r *usageLogRepository) fillDashboardUsageStatsFromUsageLogs(ctx context.Context, stats *DashboardStats, startUTC, endUTC, todayUTC, now time.Time) error { - todayEnd := todayUTC.Add(24 * time.Hour) - combinedStatsQuery := ` - WITH scoped AS ( - SELECT - created_at, - input_tokens, - output_tokens, - cache_creation_tokens, - cache_read_tokens, - total_cost, - actual_cost, - COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1) AS account_cost, - COALESCE(duration_ms, 0) AS duration_ms - FROM usage_logs - WHERE created_at >= LEAST($1::timestamptz, $3::timestamptz) - AND created_at < GREATEST($2::timestamptz, $4::timestamptz) - ) - SELECT - COUNT(*) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz) AS total_requests, - COALESCE(SUM(input_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_input_tokens, - COALESCE(SUM(output_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_output_tokens, - COALESCE(SUM(cache_creation_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_cache_read_tokens, - COALESCE(SUM(total_cost) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_cost, - COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_actual_cost, - COALESCE(SUM(account_cost) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_account_cost, - COALESCE(SUM(duration_ms) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_duration_ms, - COUNT(*) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz) AS today_requests, - COALESCE(SUM(input_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_input_tokens, - COALESCE(SUM(output_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_output_tokens, - COALESCE(SUM(cache_creation_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_cache_read_tokens, - COALESCE(SUM(total_cost) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_cost, - COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_actual_cost, - COALESCE(SUM(account_cost) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_account_cost - FROM scoped - ` - var totalDurationMs int64 - if err := scanSingleRow( - ctx, - r.sql, - combinedStatsQuery, - []any{startUTC, endUTC, todayUTC, todayEnd}, - &stats.TotalRequests, - &stats.TotalInputTokens, - &stats.TotalOutputTokens, - &stats.TotalCacheCreationTokens, - &stats.TotalCacheReadTokens, - &stats.TotalCost, - &stats.TotalActualCost, - &stats.TotalAccountCost, - &totalDurationMs, - &stats.TodayRequests, - &stats.TodayInputTokens, - &stats.TodayOutputTokens, - &stats.TodayCacheCreationTokens, - &stats.TodayCacheReadTokens, - &stats.TodayCost, - &stats.TodayActualCost, - &stats.TodayAccountCost, - ); err != nil { - return err - } - stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens - if stats.TotalRequests > 0 { - stats.AverageDurationMs = float64(totalDurationMs) / float64(stats.TotalRequests) - } - - stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens - - hourStart := now.UTC().Truncate(time.Hour) - hourEnd := hourStart.Add(time.Hour) - activeUsersQuery := ` - WITH scoped AS ( - SELECT user_id, created_at - FROM usage_logs - WHERE created_at >= LEAST($1::timestamptz, $3::timestamptz) - AND created_at < GREATEST($2::timestamptz, $4::timestamptz) - ) - SELECT - COUNT(DISTINCT CASE WHEN created_at >= $1::timestamptz AND created_at < $2::timestamptz THEN user_id END) AS active_users, - COUNT(DISTINCT CASE WHEN created_at >= $3::timestamptz AND created_at < $4::timestamptz THEN user_id END) AS hourly_active_users - FROM scoped - ` - if err := scanSingleRow(ctx, r.sql, activeUsersQuery, []any{todayUTC, todayEnd, hourStart, hourEnd}, &stats.ActiveUsers, &stats.HourlyActiveUsers); err != nil { - return err - } - - return nil -} - -func (r *usageLogRepository) ListByAccount(ctx context.Context, accountID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { - return r.listUsageLogsWithPagination(ctx, "WHERE account_id = $1", []any{accountID}, params) -} - -func (r *usageLogRepository) ListByUserAndTimeRange(ctx context.Context, userID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { - query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000" - logs, err := r.queryUsageLogs(ctx, query, userID, startTime, endTime) - return logs, nil, err -} - -// GetUserStatsAggregated returns aggregated usage statistics for a user using database-level aggregation -func (r *usageLogRepository) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { - query := ` - SELECT - COUNT(*) as total_requests, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, - COALESCE(SUM(total_cost), 0) as total_cost, - COALESCE(SUM(actual_cost), 0) as total_actual_cost, - COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms - FROM usage_logs - WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 - ` - - var stats usagestats.UsageStats - if err := scanSingleRow( - ctx, - r.sql, - query, - []any{userID, startTime, endTime}, - &stats.TotalRequests, - &stats.TotalInputTokens, - &stats.TotalOutputTokens, - &stats.TotalCacheTokens, - &stats.TotalCacheCreationTokens, - &stats.TotalCacheReadTokens, - &stats.TotalCost, - &stats.TotalActualCost, - &stats.AverageDurationMs, - ); err != nil { - return nil, err - } - stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens - return &stats, nil -} - -// GetAPIKeyStatsAggregated returns aggregated usage statistics for an API key using database-level aggregation -func (r *usageLogRepository) GetAPIKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { - query := ` - SELECT - COUNT(*) as total_requests, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, - COALESCE(SUM(total_cost), 0) as total_cost, - COALESCE(SUM(actual_cost), 0) as total_actual_cost, - COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms - FROM usage_logs - WHERE api_key_id = $1 AND created_at >= $2 AND created_at < $3 - ` - - var stats usagestats.UsageStats - if err := scanSingleRow( - ctx, - r.sql, - query, - []any{apiKeyID, startTime, endTime}, - &stats.TotalRequests, - &stats.TotalInputTokens, - &stats.TotalOutputTokens, - &stats.TotalCacheTokens, - &stats.TotalCacheCreationTokens, - &stats.TotalCacheReadTokens, - &stats.TotalCost, - &stats.TotalActualCost, - &stats.AverageDurationMs, - ); err != nil { - return nil, err - } - stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens - return &stats, nil -} - -// GetAccountStatsAggregated 使用 SQL 聚合统计账号使用数据 -// -// 性能优化说明: -// 原实现先查询所有日志记录,再在应用层循环计算统计值: -// 1. 需要传输大量数据到应用层 -// 2. 应用层循环计算增加 CPU 和内存开销 -// -// 新实现使用 SQL 聚合函数: -// 1. 在数据库层完成 COUNT/SUM/AVG 计算 -// 2. 只返回单行聚合结果,大幅减少数据传输量 -// 3. 利用数据库索引优化聚合查询性能 -func (r *usageLogRepository) GetAccountStatsAggregated(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { - query := ` - SELECT - COUNT(*) as total_requests, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, - COALESCE(SUM(total_cost), 0) as total_cost, - COALESCE(SUM(actual_cost), 0) as total_actual_cost, - COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms - FROM usage_logs - WHERE account_id = $1 AND created_at >= $2 AND created_at < $3 - ` - - var stats usagestats.UsageStats - if err := scanSingleRow( - ctx, - r.sql, - query, - []any{accountID, startTime, endTime}, - &stats.TotalRequests, - &stats.TotalInputTokens, - &stats.TotalOutputTokens, - &stats.TotalCacheTokens, - &stats.TotalCacheCreationTokens, - &stats.TotalCacheReadTokens, - &stats.TotalCost, - &stats.TotalActualCost, - &stats.AverageDurationMs, - ); err != nil { - return nil, err - } - stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens - return &stats, nil -} - -// GetModelStatsAggregated 使用 SQL 聚合统计模型使用数据 -// 性能优化:数据库层聚合计算,避免应用层循环统计 -func (r *usageLogRepository) GetModelStatsAggregated(ctx context.Context, modelName string, startTime, endTime time.Time) (*usagestats.UsageStats, error) { - query := fmt.Sprintf(` - SELECT - COUNT(*) as total_requests, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, - COALESCE(SUM(total_cost), 0) as total_cost, - COALESCE(SUM(actual_cost), 0) as total_actual_cost, - COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms - FROM usage_logs - WHERE %s = $1 AND created_at >= $2 AND created_at < $3 - `, rawUsageLogModelColumn) - - var stats usagestats.UsageStats - if err := scanSingleRow( - ctx, - r.sql, - query, - []any{modelName, startTime, endTime}, - &stats.TotalRequests, - &stats.TotalInputTokens, - &stats.TotalOutputTokens, - &stats.TotalCacheTokens, - &stats.TotalCacheCreationTokens, - &stats.TotalCacheReadTokens, - &stats.TotalCost, - &stats.TotalActualCost, - &stats.AverageDurationMs, - ); err != nil { - return nil, err - } - stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens - return &stats, nil -} - -// GetDailyStatsAggregated 使用 SQL 聚合统计用户的每日使用数据 -// 性能优化:使用 GROUP BY 在数据库层按日期分组聚合,避免应用层循环分组统计 -func (r *usageLogRepository) GetDailyStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (result []map[string]any, err error) { - tzName := resolveUsageStatsTimezone() - query := ` - SELECT - -- 使用应用时区分组,避免数据库会话时区导致日边界偏移。 - TO_CHAR(created_at AT TIME ZONE $4, 'YYYY-MM-DD') as date, - COUNT(*) as total_requests, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, - COALESCE(SUM(total_cost), 0) as total_cost, - COALESCE(SUM(actual_cost), 0) as total_actual_cost, - COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms - FROM usage_logs - WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 - GROUP BY 1 - ORDER BY 1 - ` - - rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime, tzName) - if err != nil { - return nil, err - } - defer func() { - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - result = nil - } - }() - - result = make([]map[string]any, 0) - for rows.Next() { - var ( - date string - totalRequests int64 - totalInputTokens int64 - totalOutputTokens int64 - totalCacheTokens int64 - totalCost float64 - totalActualCost float64 - avgDurationMs float64 - ) - if err = rows.Scan( - &date, - &totalRequests, - &totalInputTokens, - &totalOutputTokens, - &totalCacheTokens, - &totalCost, - &totalActualCost, - &avgDurationMs, - ); err != nil { - return nil, err - } - result = append(result, map[string]any{ - "date": date, - "total_requests": totalRequests, - "total_input_tokens": totalInputTokens, - "total_output_tokens": totalOutputTokens, - "total_cache_tokens": totalCacheTokens, - "total_tokens": totalInputTokens + totalOutputTokens + totalCacheTokens, - "total_cost": totalCost, - "total_actual_cost": totalActualCost, - "average_duration_ms": avgDurationMs, - }) - } - - if err = rows.Err(); err != nil { - return nil, err - } - - return result, nil -} - -// resolveUsageStatsTimezone 获取用于 SQL 分组的时区名称。 -// 优先使用应用初始化的时区,其次尝试读取 TZ 环境变量,最后回落为 UTC。 -func resolveUsageStatsTimezone() string { - tzName := timezone.Name() - if tzName != "" && tzName != "Local" { - return tzName - } - if envTZ := strings.TrimSpace(os.Getenv("TZ")); envTZ != "" { - return envTZ - } - return "UTC" -} - -func (r *usageLogRepository) ListByAPIKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { - query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE api_key_id = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000" - logs, err := r.queryUsageLogs(ctx, query, apiKeyID, startTime, endTime) - return logs, nil, err -} - -func (r *usageLogRepository) ListByAccountAndTimeRange(ctx context.Context, accountID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { - query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE account_id = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000" - logs, err := r.queryUsageLogs(ctx, query, accountID, startTime, endTime) - return logs, nil, err -} - -func (r *usageLogRepository) ListByModelAndTimeRange(ctx context.Context, modelName string, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { - query := fmt.Sprintf("SELECT %s FROM usage_logs WHERE %s = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000", usageLogSelectColumns, rawUsageLogModelColumn) - logs, err := r.queryUsageLogs(ctx, query, modelName, startTime, endTime) - return logs, nil, err -} - -func (r *usageLogRepository) Delete(ctx context.Context, id int64) error { - _, err := r.sql.ExecContext(ctx, "DELETE FROM usage_logs WHERE id = $1", id) - return err -} - -// GetAccountTodayStats 获取账号今日统计 -func (r *usageLogRepository) GetAccountTodayStats(ctx context.Context, accountID int64) (*usagestats.AccountStats, error) { - today := timezone.Today() - - query := ` - SELECT - COUNT(*) as requests, - COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens, - COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as cost, - COALESCE(SUM(total_cost), 0) as standard_cost, - COALESCE(SUM(actual_cost), 0) as user_cost - FROM usage_logs - WHERE account_id = $1 AND created_at >= $2 - ` - - stats := &usagestats.AccountStats{} - if err := scanSingleRow( - ctx, - r.sql, - query, - []any{accountID, today}, - &stats.Requests, - &stats.Tokens, - &stats.Cost, - &stats.StandardCost, - &stats.UserCost, - ); err != nil { - return nil, err - } - return stats, nil -} - -// GetAccountWindowStats 获取账号时间窗口内的统计 -func (r *usageLogRepository) GetAccountWindowStats(ctx context.Context, accountID int64, startTime time.Time) (*usagestats.AccountStats, error) { - query := ` - SELECT - COUNT(*) as requests, - COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens, - COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as cost, - COALESCE(SUM(total_cost), 0) as standard_cost, - COALESCE(SUM(actual_cost), 0) as user_cost - FROM usage_logs - WHERE account_id = $1 AND created_at >= $2 - ` - - stats := &usagestats.AccountStats{} - if err := scanSingleRow( - ctx, - r.sql, - query, - []any{accountID, startTime}, - &stats.Requests, - &stats.Tokens, - &stats.Cost, - &stats.StandardCost, - &stats.UserCost, - ); err != nil { - return nil, err - } - return stats, nil -} - -// GetAccountWindowStatsBatch 批量获取同一窗口起点下多个账号的统计数据。 -// 返回 map[accountID]*AccountStats,未命中的账号会返回零值统计,便于上层直接复用。 -func (r *usageLogRepository) GetAccountWindowStatsBatch(ctx context.Context, accountIDs []int64, startTime time.Time) (map[int64]*usagestats.AccountStats, error) { - result := make(map[int64]*usagestats.AccountStats, len(accountIDs)) - if len(accountIDs) == 0 { - return result, nil - } - - query := ` - SELECT - account_id, - COUNT(*) as requests, - COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens, - COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as cost, - COALESCE(SUM(total_cost), 0) as standard_cost, - COALESCE(SUM(actual_cost), 0) as user_cost - FROM usage_logs - WHERE account_id = ANY($1) AND created_at >= $2 - GROUP BY account_id - ` - rows, err := r.sql.QueryContext(ctx, query, pq.Array(accountIDs), startTime) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - for rows.Next() { - var accountID int64 - stats := &usagestats.AccountStats{} - if err := rows.Scan( - &accountID, - &stats.Requests, - &stats.Tokens, - &stats.Cost, - &stats.StandardCost, - &stats.UserCost, - ); err != nil { - return nil, err - } - result[accountID] = stats - } - if err := rows.Err(); err != nil { - return nil, err - } - - for _, accountID := range accountIDs { - if _, ok := result[accountID]; !ok { - result[accountID] = &usagestats.AccountStats{} - } - } - return result, nil -} - -// GetGeminiUsageTotalsBatch 批量聚合 Gemini 账号在窗口内的 Pro/Flash 请求与用量。 -// 模型分类规则与 service.geminiModelClassFromName 一致:model 包含 flash/lite 视为 flash,其余视为 pro。 -func (r *usageLogRepository) GetGeminiUsageTotalsBatch(ctx context.Context, accountIDs []int64, startTime, endTime time.Time) (map[int64]service.GeminiUsageTotals, error) { - result := make(map[int64]service.GeminiUsageTotals, len(accountIDs)) - if len(accountIDs) == 0 { - return result, nil - } - - query := ` - SELECT - account_id, - COALESCE(SUM(CASE WHEN LOWER(COALESCE(model, '')) LIKE '%flash%' OR LOWER(COALESCE(model, '')) LIKE '%lite%' THEN 1 ELSE 0 END), 0) AS flash_requests, - COALESCE(SUM(CASE WHEN LOWER(COALESCE(model, '')) LIKE '%flash%' OR LOWER(COALESCE(model, '')) LIKE '%lite%' THEN 0 ELSE 1 END), 0) AS pro_requests, - COALESCE(SUM(CASE WHEN LOWER(COALESCE(model, '')) LIKE '%flash%' OR LOWER(COALESCE(model, '')) LIKE '%lite%' THEN (input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) ELSE 0 END), 0) AS flash_tokens, - COALESCE(SUM(CASE WHEN LOWER(COALESCE(model, '')) LIKE '%flash%' OR LOWER(COALESCE(model, '')) LIKE '%lite%' THEN 0 ELSE (input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) END), 0) AS pro_tokens, - COALESCE(SUM(CASE WHEN LOWER(COALESCE(model, '')) LIKE '%flash%' OR LOWER(COALESCE(model, '')) LIKE '%lite%' THEN actual_cost ELSE 0 END), 0) AS flash_cost, - COALESCE(SUM(CASE WHEN LOWER(COALESCE(model, '')) LIKE '%flash%' OR LOWER(COALESCE(model, '')) LIKE '%lite%' THEN 0 ELSE actual_cost END), 0) AS pro_cost - FROM usage_logs - WHERE account_id = ANY($1) AND created_at >= $2 AND created_at < $3 - GROUP BY account_id - ` - rows, err := r.sql.QueryContext(ctx, query, pq.Array(accountIDs), startTime, endTime) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - for rows.Next() { - var accountID int64 - var totals service.GeminiUsageTotals - if err := rows.Scan( - &accountID, - &totals.FlashRequests, - &totals.ProRequests, - &totals.FlashTokens, - &totals.ProTokens, - &totals.FlashCost, - &totals.ProCost, - ); err != nil { - return nil, err - } - result[accountID] = totals - } - if err := rows.Err(); err != nil { - return nil, err - } - - for _, accountID := range accountIDs { - if _, ok := result[accountID]; !ok { - result[accountID] = service.GeminiUsageTotals{} - } - } - return result, nil -} - -// TrendDataPoint represents a single point in trend data -type TrendDataPoint = usagestats.TrendDataPoint - -// ModelStat represents usage statistics for a single model -type ModelStat = usagestats.ModelStat - -// UserUsageTrendPoint represents user usage trend data point -type UserUsageTrendPoint = usagestats.UserUsageTrendPoint - -// UserSpendingRankingItem represents a user spending ranking row. -type UserSpendingRankingItem = usagestats.UserSpendingRankingItem -type UserSpendingRankingResponse = usagestats.UserSpendingRankingResponse - -// APIKeyUsageTrendPoint represents API key usage trend data point -type APIKeyUsageTrendPoint = usagestats.APIKeyUsageTrendPoint - -// GetAPIKeyUsageTrend returns usage trend data grouped by API key and date -func (r *usageLogRepository) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) (results []APIKeyUsageTrendPoint, err error) { - dateFormat := safeDateFormat(granularity) - - query := fmt.Sprintf(` - WITH top_keys AS ( - SELECT api_key_id - FROM usage_logs - WHERE created_at >= $1 AND created_at < $2 - GROUP BY api_key_id - ORDER BY SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) DESC - LIMIT $3 - ) - SELECT - TO_CHAR(u.created_at, '%s') as date, - u.api_key_id, - COALESCE(k.name, '') as key_name, - COUNT(*) as requests, - COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens - FROM usage_logs u - LEFT JOIN api_keys k ON u.api_key_id = k.id - WHERE u.api_key_id IN (SELECT api_key_id FROM top_keys) - AND u.created_at >= $4 AND u.created_at < $5 - GROUP BY date, u.api_key_id, k.name - ORDER BY date ASC, tokens DESC - `, dateFormat) - - rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit, startTime, endTime) - if err != nil { - return nil, err - } - defer func() { - // 保持主错误优先;仅在无错误时回传 Close 失败。 - // 同时清空返回值,避免误用不完整结果。 - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - results = nil - } - }() - - results = make([]APIKeyUsageTrendPoint, 0) - for rows.Next() { - var row APIKeyUsageTrendPoint - if err = rows.Scan(&row.Date, &row.APIKeyID, &row.KeyName, &row.Requests, &row.Tokens); err != nil { - return nil, err - } - results = append(results, row) - } - if err = rows.Err(); err != nil { - return nil, err - } - - return results, nil -} - -// GetUserUsageTrend returns usage trend data grouped by user and date -func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) (results []UserUsageTrendPoint, err error) { - dateFormat := safeDateFormat(granularity) - - query := fmt.Sprintf(` - WITH top_users AS ( - SELECT user_id - FROM usage_logs - WHERE created_at >= $1 AND created_at < $2 - GROUP BY user_id - ORDER BY SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) DESC - LIMIT $3 - ) - SELECT - TO_CHAR(u.created_at, '%s') as date, - u.user_id, - COALESCE(us.email, '') as email, - COALESCE(us.username, '') as username, - COUNT(*) as requests, - COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens, - COALESCE(SUM(u.total_cost), 0) as cost, - COALESCE(SUM(u.actual_cost), 0) as actual_cost - FROM usage_logs u - LEFT JOIN users us ON u.user_id = us.id - WHERE u.user_id IN (SELECT user_id FROM top_users) - AND u.created_at >= $4 AND u.created_at < $5 - GROUP BY date, u.user_id, us.email, us.username - ORDER BY date ASC, tokens DESC - `, dateFormat) - - rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit, startTime, endTime) - if err != nil { - return nil, err - } - defer func() { - // 保持主错误优先;仅在无错误时回传 Close 失败。 - // 同时清空返回值,避免误用不完整结果。 - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - results = nil - } - }() - - results = make([]UserUsageTrendPoint, 0) - for rows.Next() { - var row UserUsageTrendPoint - if err = rows.Scan(&row.Date, &row.UserID, &row.Email, &row.Username, &row.Requests, &row.Tokens, &row.Cost, &row.ActualCost); err != nil { - return nil, err - } - results = append(results, row) - } - if err = rows.Err(); err != nil { - return nil, err - } - - return results, nil -} - -// GetUserSpendingRanking returns user spending ranking aggregated within the time range. -func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (result *UserSpendingRankingResponse, err error) { - if limit <= 0 { - limit = 12 - } - - query := ` - WITH user_spend AS ( - SELECT - u.user_id, - COALESCE(us.email, '') as email, - COALESCE(SUM(u.actual_cost), 0) as actual_cost, - COUNT(*) as requests, - COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens - FROM usage_logs u - LEFT JOIN users us ON u.user_id = us.id - WHERE u.created_at >= $1 AND u.created_at < $2 - GROUP BY u.user_id, us.email - ), - ranked AS ( - SELECT - user_id, - email, - actual_cost, - requests, - tokens, - COALESCE(SUM(actual_cost) OVER (), 0) as total_actual_cost, - COALESCE(SUM(requests) OVER (), 0) as total_requests, - COALESCE(SUM(tokens) OVER (), 0) as total_tokens - FROM user_spend - ORDER BY actual_cost DESC, tokens DESC, user_id ASC - LIMIT $3 - ) - SELECT - user_id, - email, - actual_cost, - requests, - tokens, - total_actual_cost, - total_requests, - total_tokens - FROM ranked - ORDER BY actual_cost DESC, tokens DESC, user_id ASC - ` - - rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit) - if err != nil { - return nil, err - } - defer func() { - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - result = nil - } - }() - - ranking := make([]UserSpendingRankingItem, 0) - totalActualCost := 0.0 - totalRequests := int64(0) - totalTokens := int64(0) - for rows.Next() { - var row UserSpendingRankingItem - if err = rows.Scan(&row.UserID, &row.Email, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil { - return nil, err - } - ranking = append(ranking, row) - } - if err = rows.Err(); err != nil { - return nil, err - } - - return &UserSpendingRankingResponse{ - Ranking: ranking, - TotalActualCost: totalActualCost, - TotalRequests: totalRequests, - TotalTokens: totalTokens, - }, nil -} - -// UserDashboardStats 用户仪表盘统计 -type UserDashboardStats = usagestats.UserDashboardStats - -// PlatformDashboardStats 单平台用量明细 -type PlatformDashboardStats = usagestats.PlatformDashboardStats - -// GetUserDashboardStats 获取用户专属的仪表盘统计 -func (r *usageLogRepository) GetUserDashboardStats(ctx context.Context, userID int64) (*UserDashboardStats, error) { - stats := &UserDashboardStats{} - today := timezone.Today() - - // API Key 统计 - if err := scanSingleRow( - ctx, - r.sql, - "SELECT COUNT(*) FROM api_keys WHERE user_id = $1 AND deleted_at IS NULL", - []any{userID}, - &stats.TotalAPIKeys, - ); err != nil { - return nil, err - } - if err := scanSingleRow( - ctx, - r.sql, - "SELECT COUNT(*) FROM api_keys WHERE user_id = $1 AND status = $2 AND deleted_at IS NULL", - []any{userID, service.StatusActive}, - &stats.ActiveAPIKeys, - ); err != nil { - return nil, err - } - - // 累计 Token 统计 - totalStatsQuery := ` - SELECT - COUNT(*) as total_requests, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, - COALESCE(SUM(total_cost), 0) as total_cost, - COALESCE(SUM(actual_cost), 0) as total_actual_cost, - COALESCE(AVG(duration_ms), 0) as avg_duration_ms - FROM usage_logs - WHERE user_id = $1 - ` - if err := scanSingleRow( - ctx, - r.sql, - totalStatsQuery, - []any{userID}, - &stats.TotalRequests, - &stats.TotalInputTokens, - &stats.TotalOutputTokens, - &stats.TotalCacheCreationTokens, - &stats.TotalCacheReadTokens, - &stats.TotalCost, - &stats.TotalActualCost, - &stats.AverageDurationMs, - ); err != nil { - return nil, err - } - stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens - - // 今日 Token 统计 - todayStatsQuery := ` - SELECT - COUNT(*) as today_requests, - COALESCE(SUM(input_tokens), 0) as today_input_tokens, - COALESCE(SUM(output_tokens), 0) as today_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as today_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as today_cache_read_tokens, - COALESCE(SUM(total_cost), 0) as today_cost, - COALESCE(SUM(actual_cost), 0) as today_actual_cost - FROM usage_logs - WHERE user_id = $1 AND created_at >= $2 - ` - if err := scanSingleRow( - ctx, - r.sql, - todayStatsQuery, - []any{userID, today}, - &stats.TodayRequests, - &stats.TodayInputTokens, - &stats.TodayOutputTokens, - &stats.TodayCacheCreationTokens, - &stats.TodayCacheReadTokens, - &stats.TodayCost, - &stats.TodayActualCost, - ); err != nil { - return nil, err - } - stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens - - // 性能指标:RPM 和 TPM(最近1分钟,仅统计该用户的请求) - rpm, tpm, err := r.getPerformanceStats(ctx, userID) - if err != nil { - return nil, err - } - stats.Rpm = rpm - stats.Tpm = tpm - - // 按"有效平台"维度拆分(group.platform 优先,否则 account.platform)。 - // 与 ops 路径口径一致;HAVING 过滤掉无法确定平台的行(避免出现空字符串平台)。 - // 与上面 totalStatsQuery/todayStatsQuery 的总值可能略微差异,原因有二: - // 1) 无平台归属的极少数行(group/account 都没 platform)会被 HAVING 排除; - // 2) usageLogSuccessFilterUL 会把 actual_cost = 0 的失败 placeholder 行排除, - // 而 totalStatsQuery/todayStatsQuery 没有这层过滤、会把这些行的 request 计数算进去。 - platformQuery := ` - SELECT - ` + usageLogEffectivePlatformExpr + ` as platform, - COUNT(*) as total_requests, - COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens, - COALESCE(SUM(ul.actual_cost), 0) as total_actual_cost, - COUNT(*) FILTER (WHERE ul.created_at >= $2) as today_requests, - COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens) FILTER (WHERE ul.created_at >= $2), 0) as today_tokens, - COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $2), 0) as today_actual_cost - FROM usage_logs ul - LEFT JOIN groups g ON g.id = ul.group_id - LEFT JOIN accounts a ON a.id = ul.account_id - WHERE ul.user_id = $1 - AND ` + usageLogSuccessFilterUL + ` - GROUP BY ` + usageLogEffectivePlatformExpr + ` - HAVING ` + usageLogEffectivePlatformExpr + ` IS NOT NULL AND ` + usageLogEffectivePlatformExpr + ` <> '' - ORDER BY total_actual_cost DESC - ` - rows, err := r.sql.QueryContext(ctx, platformQuery, userID, today) - if err != nil { - return nil, err - } - for rows.Next() { - var p PlatformDashboardStats - if err := rows.Scan( - &p.Platform, - &p.TotalRequests, - &p.TotalTokens, - &p.TotalActualCost, - &p.TodayRequests, - &p.TodayTokens, - &p.TodayActualCost, - ); err != nil { - _ = rows.Close() - return nil, err - } - stats.ByPlatform = append(stats.ByPlatform, p) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - - return stats, nil -} - -// getPerformanceStatsByAPIKey 获取指定 API Key 的 RPM 和 TPM(近5分钟平均值) -func (r *usageLogRepository) getPerformanceStatsByAPIKey(ctx context.Context, apiKeyID int64) (rpm, tpm int64, err error) { - fiveMinutesAgo := time.Now().Add(-5 * time.Minute) - query := ` - SELECT - COUNT(*) as request_count, - COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as token_count - FROM usage_logs - WHERE created_at >= $1 AND api_key_id = $2` - args := []any{fiveMinutesAgo, apiKeyID} - - var requestCount int64 - var tokenCount int64 - if err := scanSingleRow(ctx, r.sql, query, args, &requestCount, &tokenCount); err != nil { - return 0, 0, err - } - return requestCount / 5, tokenCount / 5, nil -} - -// GetAPIKeyDashboardStats 获取指定 API Key 的仪表盘统计(按 api_key_id 过滤) -func (r *usageLogRepository) GetAPIKeyDashboardStats(ctx context.Context, apiKeyID int64) (*UserDashboardStats, error) { - stats := &UserDashboardStats{} - today := timezone.Today() - - // API Key 维度不需要统计 key 数量,设为 1 - stats.TotalAPIKeys = 1 - stats.ActiveAPIKeys = 1 - - // 累计 Token 统计 - totalStatsQuery := ` - SELECT - COUNT(*) as total_requests, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, - COALESCE(SUM(total_cost), 0) as total_cost, - COALESCE(SUM(actual_cost), 0) as total_actual_cost, - COALESCE(AVG(duration_ms), 0) as avg_duration_ms - FROM usage_logs - WHERE api_key_id = $1 - ` - if err := scanSingleRow( - ctx, - r.sql, - totalStatsQuery, - []any{apiKeyID}, - &stats.TotalRequests, - &stats.TotalInputTokens, - &stats.TotalOutputTokens, - &stats.TotalCacheCreationTokens, - &stats.TotalCacheReadTokens, - &stats.TotalCost, - &stats.TotalActualCost, - &stats.AverageDurationMs, - ); err != nil { - return nil, err - } - stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens - - // 今日 Token 统计 - todayStatsQuery := ` - SELECT - COUNT(*) as today_requests, - COALESCE(SUM(input_tokens), 0) as today_input_tokens, - COALESCE(SUM(output_tokens), 0) as today_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as today_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as today_cache_read_tokens, - COALESCE(SUM(total_cost), 0) as today_cost, - COALESCE(SUM(actual_cost), 0) as today_actual_cost - FROM usage_logs - WHERE api_key_id = $1 AND created_at >= $2 - ` - if err := scanSingleRow( - ctx, - r.sql, - todayStatsQuery, - []any{apiKeyID, today}, - &stats.TodayRequests, - &stats.TodayInputTokens, - &stats.TodayOutputTokens, - &stats.TodayCacheCreationTokens, - &stats.TodayCacheReadTokens, - &stats.TodayCost, - &stats.TodayActualCost, - ); err != nil { - return nil, err - } - stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens - - // 性能指标:RPM 和 TPM(最近5分钟,按 API Key 过滤) - rpm, tpm, err := r.getPerformanceStatsByAPIKey(ctx, apiKeyID) - if err != nil { - return nil, err - } - stats.Rpm = rpm - stats.Tpm = tpm - - return stats, nil -} - -// GetUserUsageTrendByUserID 获取指定用户的使用趋势 -func (r *usageLogRepository) GetUserUsageTrendByUserID(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) { - dateFormat := safeDateFormat(granularity) - - query := fmt.Sprintf(` - SELECT - TO_CHAR(created_at, '%s') as date, - COUNT(*) as requests, - COALESCE(SUM(input_tokens), 0) as input_tokens, - COALESCE(SUM(output_tokens), 0) as output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens, - COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens, - COALESCE(SUM(total_cost), 0) as cost, - COALESCE(SUM(actual_cost), 0) as actual_cost - FROM usage_logs - WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 - GROUP BY date - ORDER BY date ASC - `, dateFormat) - - rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime) - if err != nil { - return nil, err - } - defer func() { - // 保持主错误优先;仅在无错误时回传 Close 失败。 - // 同时清空返回值,避免误用不完整结果。 - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - results = nil - } - }() - - results, err = scanTrendRows(rows) - if err != nil { - return nil, err - } - return results, nil -} - -// GetUserModelStats 获取指定用户的模型统计 -func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "") -} - -// UsageLogFilters represents filters for usage log queries -type UsageLogFilters = usagestats.UsageLogFilters - -// ListWithFilters lists usage logs with optional filters (for admin) -func (r *usageLogRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) { - conditions := make([]string, 0, 9) - args := make([]any, 0, 9) - - if filters.UserID > 0 { - conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1)) - args = append(args, filters.UserID) - } - if filters.APIKeyID > 0 { - conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1)) - args = append(args, filters.APIKeyID) - } - if filters.AccountID > 0 { - conditions = append(conditions, fmt.Sprintf("account_id = $%d", len(args)+1)) - args = append(args, filters.AccountID) - } - if filters.GroupID > 0 { - conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1)) - args = append(args, filters.GroupID) - } - conditions, args = appendUsageLogModelWhereCondition(conditions, args, filters.Model, filters.ModelFilterSource) - conditions, args = appendRequestTypeOrStreamWhereCondition(conditions, args, filters.RequestType, filters.Stream) - if filters.BillingType != nil { - conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1)) - args = append(args, int16(*filters.BillingType)) - } - conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode) - if filters.StartTime != nil { - conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) - args = append(args, *filters.StartTime) - } - if filters.EndTime != nil { - conditions = append(conditions, fmt.Sprintf("created_at < $%d", len(args)+1)) - args = append(args, *filters.EndTime) - } - - whereClause := buildWhere(conditions) - var ( - logs []service.UsageLog - page *pagination.PaginationResult - err error - ) - if shouldUseFastUsageLogTotal(filters) { - logs, page, err = r.listUsageLogsWithFastPagination(ctx, whereClause, args, params) - } else { - logs, page, err = r.listUsageLogsWithPagination(ctx, whereClause, args, params) - } - if err != nil { - return nil, nil, err - } - - if err := r.hydrateUsageLogAssociations(ctx, logs); err != nil { - return nil, nil, err - } - return logs, page, nil -} - -func shouldUseFastUsageLogTotal(filters UsageLogFilters) bool { - if filters.ExactTotal { - return false - } - // 强选择过滤下记录集通常较小,保留精确总数。 - return filters.UserID == 0 && filters.APIKeyID == 0 && filters.AccountID == 0 -} - -// UsageStats represents usage statistics -type UsageStats = usagestats.UsageStats - -// BatchUserUsageStats represents usage stats for a single user -type BatchUserUsageStats = usagestats.BatchUserUsageStats - -// PlatformUsage represents per-platform usage breakdown -type PlatformUsage = usagestats.PlatformUsage - -func normalizePositiveInt64IDs(ids []int64) []int64 { - if len(ids) == 0 { - return nil - } - seen := make(map[int64]struct{}, len(ids)) - out := make([]int64, 0, len(ids)) - for _, id := range ids { - if id <= 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - return out -} - -// GetBatchUserUsageStats gets today and total actual_cost for multiple users within a time range. -// If startTime is zero, defaults to 30 days ago. -func (r *usageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*BatchUserUsageStats, error) { - result := make(map[int64]*BatchUserUsageStats) - normalizedUserIDs := normalizePositiveInt64IDs(userIDs) - if len(normalizedUserIDs) == 0 { - return result, nil - } - - // 默认最近 30 天 - if startTime.IsZero() { - startTime = time.Now().AddDate(0, 0, -30) - } - if endTime.IsZero() { - endTime = time.Now() - } - - for _, id := range normalizedUserIDs { - result[id] = &BatchUserUsageStats{UserID: id} - } - - // GROUP BY (user_id, effective_platform) 一次查询同时得到总值与按平台拆分。 - // 应用层把同一 user_id 的多行累加为总值,并把非空 platform 行收集到 ByPlatform。 - query := ` - SELECT - ul.user_id, - ` + usageLogEffectivePlatformExpr + ` as platform, - COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $2 AND ul.created_at < $3), 0) as total_cost, - COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $4), 0) as today_cost - FROM usage_logs ul - LEFT JOIN groups g ON g.id = ul.group_id - LEFT JOIN accounts a ON a.id = ul.account_id - WHERE ul.user_id = ANY($1) - AND ul.created_at >= LEAST($2, $4) - AND ` + usageLogSuccessFilterUL + ` - GROUP BY ul.user_id, ` + usageLogEffectivePlatformExpr + ` - ` - today := timezone.Today() - rows, err := r.sql.QueryContext(ctx, query, pq.Array(normalizedUserIDs), startTime, endTime, today) - if err != nil { - return nil, err - } - for rows.Next() { - var userID int64 - var platform sql.NullString - var total float64 - var todayTotal float64 - if err := rows.Scan(&userID, &platform, &total, &todayTotal); err != nil { - _ = rows.Close() - return nil, err - } - stats, ok := result[userID] - if !ok { - continue - } - stats.TotalActualCost += total - stats.TodayActualCost += todayTotal - if platform.Valid && platform.String != "" { - stats.ByPlatform = append(stats.ByPlatform, PlatformUsage{ - Platform: platform.String, - TotalActualCost: total, - TodayActualCost: todayTotal, - }) - } - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - - return result, nil -} - -// BatchAPIKeyUsageStats represents usage stats for a single API key -type BatchAPIKeyUsageStats = usagestats.BatchAPIKeyUsageStats - -// GetBatchAPIKeyUsageStats gets today and total actual_cost for multiple API keys within a time range. -// If startTime is zero, defaults to 30 days ago. -func (r *usageLogRepository) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*BatchAPIKeyUsageStats, error) { - result := make(map[int64]*BatchAPIKeyUsageStats) - normalizedAPIKeyIDs := normalizePositiveInt64IDs(apiKeyIDs) - if len(normalizedAPIKeyIDs) == 0 { - return result, nil - } - - // 默认最近 30 天 - if startTime.IsZero() { - startTime = time.Now().AddDate(0, 0, -30) - } - if endTime.IsZero() { - endTime = time.Now() - } - - for _, id := range normalizedAPIKeyIDs { - result[id] = &BatchAPIKeyUsageStats{APIKeyID: id} - } - - query := ` - SELECT - api_key_id, - COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $2 AND created_at < $3), 0) as total_cost, - COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $4), 0) as today_cost - FROM usage_logs - WHERE api_key_id = ANY($1) - AND created_at >= LEAST($2, $4) - GROUP BY api_key_id - ` - today := timezone.Today() - rows, err := r.sql.QueryContext(ctx, query, pq.Array(normalizedAPIKeyIDs), startTime, endTime, today) - if err != nil { - return nil, err - } - for rows.Next() { - var apiKeyID int64 - var total float64 - var todayTotal float64 - if err := rows.Scan(&apiKeyID, &total, &todayTotal); err != nil { - _ = rows.Close() - return nil, err - } - if stats, ok := result[apiKeyID]; ok { - stats.TotalActualCost = total - stats.TodayActualCost = todayTotal - } - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - - return result, nil -} - -// GetUsageTrendWithFilters returns usage trend data with optional filters -func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []TrendDataPoint, err error) { - return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") -} - -func (r *usageLogRepository) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters UsageLogFilters) (results []TrendDataPoint, err error) { - return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) -} - -func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []TrendDataPoint, err error) { - if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode) { - aggregated, aggregatedErr := r.getUsageTrendFromAggregates(ctx, startTime, endTime, granularity) - if aggregatedErr == nil && len(aggregated) > 0 { - return aggregated, nil - } - } - - dateFormat := safeDateFormat(granularity) - - query := fmt.Sprintf(` - SELECT - TO_CHAR(created_at, '%s') as date, - COUNT(*) as requests, - COALESCE(SUM(input_tokens), 0) as input_tokens, - COALESCE(SUM(output_tokens), 0) as output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens, - COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens, - COALESCE(SUM(total_cost), 0) as cost, - COALESCE(SUM(actual_cost), 0) as actual_cost - FROM usage_logs - WHERE created_at >= $1 AND created_at < $2 - `, dateFormat) - - args := []any{startTime, endTime} - if userID > 0 { - query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) - args = append(args, userID) - } - if apiKeyID > 0 { - query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) - args = append(args, apiKeyID) - } - if accountID > 0 { - query += fmt.Sprintf(" AND account_id = $%d", len(args)+1) - args = append(args, accountID) - } - if groupID > 0 { - query += fmt.Sprintf(" AND group_id = $%d", len(args)+1) - args = append(args, groupID) - } - query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource) - query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) - if billingType != nil { - query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1) - args = append(args, int16(*billingType)) - } - query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") - query += " GROUP BY date ORDER BY date ASC" - - rows, err := r.sql.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer func() { - // 保持主错误优先;仅在无错误时回传 Close 失败。 - // 同时清空返回值,避免误用不完整结果。 - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - results = nil - } - }() - - results, err = scanTrendRows(rows) - if err != nil { - return nil, err - } - return results, nil -} - -func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) bool { - if granularity != "day" && granularity != "hour" { - return false - } - return userID == 0 && - apiKeyID == 0 && - accountID == 0 && - groupID == 0 && - model == "" && - requestType == nil && - stream == nil && - billingType == nil && - billingMode == "" -} - -func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) { - dateFormat := safeDateFormat(granularity) - query := "" - args := []any{startTime, endTime} - - switch granularity { - case "hour": - query = fmt.Sprintf(` - SELECT - TO_CHAR(bucket_start, '%s') as date, - total_requests as requests, - input_tokens, - output_tokens, - cache_creation_tokens, - cache_read_tokens, - (input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) as total_tokens, - total_cost as cost, - actual_cost - FROM usage_dashboard_hourly - WHERE bucket_start >= $1 AND bucket_start < $2 - ORDER BY bucket_start ASC - `, dateFormat) - case "day": - query = fmt.Sprintf(` - SELECT - TO_CHAR(bucket_date::timestamp, '%s') as date, - total_requests as requests, - input_tokens, - output_tokens, - cache_creation_tokens, - cache_read_tokens, - (input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) as total_tokens, - total_cost as cost, - actual_cost - FROM usage_dashboard_daily - WHERE bucket_date >= $1::date AND bucket_date < $2::date - ORDER BY bucket_date ASC - `, dateFormat) - default: - return nil, nil - } - - rows, err := r.sql.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer func() { - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - results = nil - } - }() - - results, err = scanTrendRows(rows) - if err != nil { - return nil, err - } - return results, nil -} - -// GetModelStatsWithFilters returns model statistics with optional filters -func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "") -} - -// GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension. -// source: requested | upstream | mapping. -func (r *usageLogRepository) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "") -} - -func (r *usageLogRepository) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters, source string) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode) -} - -func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string) (results []ModelStat, err error) { - actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" - // 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。 - if accountID > 0 && userID == 0 && apiKeyID == 0 { - actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost" - } - accountCostExpr := "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as account_cost" - modelExpr := resolveModelDimensionExpression(source) - - query := fmt.Sprintf(` - SELECT - %s as model, - COUNT(*) as requests, - COALESCE(SUM(input_tokens), 0) as input_tokens, - COALESCE(SUM(output_tokens), 0) as output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens, - COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens, - COALESCE(SUM(total_cost), 0) as cost, - %s, - %s - FROM usage_logs - WHERE created_at >= $1 AND created_at < $2 - `, modelExpr, actualCostExpr, accountCostExpr) - - args := []any{startTime, endTime} - if userID > 0 { - query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) - args = append(args, userID) - } - if apiKeyID > 0 { - query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) - args = append(args, apiKeyID) - } - if accountID > 0 { - query += fmt.Sprintf(" AND account_id = $%d", len(args)+1) - args = append(args, accountID) - } - if groupID > 0 { - query += fmt.Sprintf(" AND group_id = $%d", len(args)+1) - args = append(args, groupID) - } - if strings.TrimSpace(model) != "" { - query += fmt.Sprintf(" AND %s = $%d", modelExpr, len(args)+1) - args = append(args, model) - } - query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) - if billingType != nil { - query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1) - args = append(args, int16(*billingType)) - } - query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") - query += fmt.Sprintf(" GROUP BY %s ORDER BY total_tokens DESC", modelExpr) - - rows, err := r.sql.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer func() { - // 保持主错误优先;仅在无错误时回传 Close 失败。 - // 同时清空返回值,避免误用不完整结果。 - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - results = nil - } - }() - - results, err = scanModelStatsRows(rows) - if err != nil { - return nil, err - } - return results, nil -} - -// GetGroupStatsWithFilters returns group usage statistics with optional filters -func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []usagestats.GroupStat, err error) { - return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "") -} - -func (r *usageLogRepository) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters) (results []usagestats.GroupStat, err error) { - return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) -} - -func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []usagestats.GroupStat, err error) { - query := ` - SELECT - COALESCE(ul.group_id, 0) as group_id, - COALESCE(g.name, '') as group_name, - COUNT(*) as requests, - COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens, - COALESCE(SUM(ul.total_cost), 0) as cost, - COALESCE(SUM(ul.actual_cost), 0) as actual_cost, - COALESCE(SUM(COALESCE(ul.account_stats_cost, ul.total_cost) * COALESCE(ul.account_rate_multiplier, 1)), 0) as account_cost - FROM usage_logs ul - LEFT JOIN groups g ON g.id = ul.group_id - WHERE ul.created_at >= $1 AND ul.created_at < $2 - ` - - args := []any{startTime, endTime} - if userID > 0 { - query += fmt.Sprintf(" AND ul.user_id = $%d", len(args)+1) - args = append(args, userID) - } - if apiKeyID > 0 { - query += fmt.Sprintf(" AND ul.api_key_id = $%d", len(args)+1) - args = append(args, apiKeyID) - } - if accountID > 0 { - query += fmt.Sprintf(" AND ul.account_id = $%d", len(args)+1) - args = append(args, accountID) - } - if groupID > 0 { - query += fmt.Sprintf(" AND ul.group_id = $%d", len(args)+1) - args = append(args, groupID) - } - if strings.TrimSpace(model) != "" { - modelExpr := resolveModelDimensionExpressionWithAlias(usagestats.ModelSourceRequested, "ul") - query += fmt.Sprintf(" AND %s = $%d", modelExpr, len(args)+1) - args = append(args, model) - } - query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) - if billingType != nil { - query += fmt.Sprintf(" AND ul.billing_type = $%d", len(args)+1) - args = append(args, int16(*billingType)) - } - query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "ul") - query += " GROUP BY ul.group_id, g.name ORDER BY total_tokens DESC" - - rows, err := r.sql.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer func() { - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - results = nil - } - }() - - results = make([]usagestats.GroupStat, 0) - for rows.Next() { - var row usagestats.GroupStat - if err := rows.Scan( - &row.GroupID, - &row.GroupName, - &row.Requests, - &row.TotalTokens, - &row.Cost, - &row.ActualCost, - &row.AccountCost, - ); err != nil { - return nil, err - } - results = append(results, row) - } - if err := rows.Err(); err != nil { - return nil, err - } - return results, nil -} - -// GetUserBreakdownStats returns per-user usage breakdown within a specific dimension. -func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) (results []usagestats.UserBreakdownItem, err error) { - query := ` - SELECT - COALESCE(ul.user_id, 0) as user_id, - COALESCE(u.email, '') as email, - COUNT(*) as requests, - COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens, - COALESCE(SUM(ul.total_cost), 0) as cost, - COALESCE(SUM(ul.actual_cost), 0) as actual_cost, - COALESCE(SUM(COALESCE(ul.account_stats_cost, ul.total_cost) * COALESCE(ul.account_rate_multiplier, 1)), 0) as account_cost - FROM usage_logs ul - LEFT JOIN users u ON u.id = ul.user_id - WHERE ul.created_at >= $1 AND ul.created_at < $2 - ` - args := []any{startTime, endTime} - - if dim.GroupID > 0 { - query += fmt.Sprintf(" AND ul.group_id = $%d", len(args)+1) - args = append(args, dim.GroupID) - } - if dim.Model != "" { - query += fmt.Sprintf(" AND %s = $%d", resolveModelDimensionExpression(dim.ModelType), len(args)+1) - args = append(args, dim.Model) - } - if dim.Endpoint != "" { - col := resolveEndpointColumn(dim.EndpointType) - query += fmt.Sprintf(" AND %s = $%d", col, len(args)+1) - args = append(args, dim.Endpoint) - } - if dim.UserID > 0 { - query += fmt.Sprintf(" AND ul.user_id = $%d", len(args)+1) - args = append(args, dim.UserID) - } - if dim.APIKeyID > 0 { - query += fmt.Sprintf(" AND ul.api_key_id = $%d", len(args)+1) - args = append(args, dim.APIKeyID) - } - if dim.AccountID > 0 { - query += fmt.Sprintf(" AND ul.account_id = $%d", len(args)+1) - args = append(args, dim.AccountID) - } - if dim.RequestType != nil { - query += fmt.Sprintf(" AND ul.request_type = $%d", len(args)+1) - args = append(args, *dim.RequestType) - } - if dim.Stream != nil { - query += fmt.Sprintf(" AND ul.stream = $%d", len(args)+1) - args = append(args, *dim.Stream) - } - if dim.BillingType != nil { - query += fmt.Sprintf(" AND ul.billing_type = $%d", len(args)+1) - args = append(args, *dim.BillingType) - } - - query += " GROUP BY ul.user_id, u.email ORDER BY actual_cost DESC" - if limit > 0 { - query += fmt.Sprintf(" LIMIT %d", limit) - } - - rows, err := r.sql.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer func() { - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - results = nil - } - }() - - results = make([]usagestats.UserBreakdownItem, 0) - for rows.Next() { - var row usagestats.UserBreakdownItem - if err := rows.Scan( - &row.UserID, - &row.Email, - &row.Requests, - &row.TotalTokens, - &row.Cost, - &row.ActualCost, - &row.AccountCost, - ); err != nil { - return nil, err - } - results = append(results, row) - } - if err := rows.Err(); err != nil { - return nil, err - } - return results, nil -} - -// GetAllGroupUsageSummary returns today's and cumulative actual_cost for every group. -// todayStart is the start-of-day in the caller's timezone (UTC-based). -// TODO(perf): This query scans ALL usage_logs rows for total_cost aggregation. -// When usage_logs exceeds ~1M rows, consider adding a short-lived cache (30s) -// or a materialized view / pre-aggregation table for cumulative costs. -func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { - query := ` - SELECT - g.id AS group_id, - COALESCE(SUM(ul.actual_cost), 0) AS total_cost, - COALESCE(SUM(CASE WHEN ul.created_at >= $1 THEN ul.actual_cost ELSE 0 END), 0) AS today_cost - FROM groups g - LEFT JOIN usage_logs ul ON ul.group_id = g.id - GROUP BY g.id - ` - - rows, err := r.sql.QueryContext(ctx, query, todayStart) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - var results []usagestats.GroupUsageSummary - for rows.Next() { - var row usagestats.GroupUsageSummary - if err := rows.Scan(&row.GroupID, &row.TotalCost, &row.TodayCost); err != nil { - return nil, err - } - results = append(results, row) - } - if err := rows.Err(); err != nil { - return nil, err - } - return results, nil -} - -// resolveModelDimensionExpression maps model source type to a safe SQL expression. -func resolveModelDimensionExpression(modelType string) string { - return resolveModelDimensionExpressionWithAlias(modelType, "") -} - -func resolveModelDimensionExpressionWithAlias(modelType, alias string) string { - column := func(name string) string { - if alias == "" { - return name - } - return alias + "." + name - } - requestedExpr := fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("requested_model"), column("model")) - switch usagestats.NormalizeModelSource(modelType) { - case usagestats.ModelSourceUpstream: - return fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("upstream_model"), requestedExpr) - case usagestats.ModelSourceMapping: - return fmt.Sprintf("(%s || ' -> ' || COALESCE(NULLIF(TRIM(%s), ''), %s))", requestedExpr, column("upstream_model"), requestedExpr) - default: - return requestedExpr - } -} - -// resolveEndpointColumn maps endpoint type to the corresponding DB column name. -func resolveEndpointColumn(endpointType string) string { - switch endpointType { - case "upstream": - return "ul.upstream_endpoint" - case "path": - return "ul.inbound_endpoint || ' -> ' || ul.upstream_endpoint" - default: - return "ul.inbound_endpoint" - } -} - -// GetGlobalStats gets usage statistics for all users within a time range -func (r *usageLogRepository) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*UsageStats, error) { - query := ` - SELECT - COUNT(*) as total_requests, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, - COALESCE(SUM(total_cost), 0) as total_cost, - COALESCE(SUM(actual_cost), 0) as total_actual_cost, - COALESCE(AVG(duration_ms), 0) as avg_duration_ms - FROM usage_logs - WHERE created_at >= $1 AND created_at < $2 - ` - - stats := &UsageStats{} - if err := scanSingleRow( - ctx, - r.sql, - query, - []any{startTime, endTime}, - &stats.TotalRequests, - &stats.TotalInputTokens, - &stats.TotalOutputTokens, - &stats.TotalCacheTokens, - &stats.TotalCost, - &stats.TotalActualCost, - &stats.AverageDurationMs, - ); err != nil { - return nil, err - } - stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens - return stats, nil -} - -// GetStatsWithFilters gets usage statistics with optional filters -func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters UsageLogFilters) (*UsageStats, error) { - conditions := make([]string, 0, 9) - args := make([]any, 0, 9) - - if filters.UserID > 0 { - conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1)) - args = append(args, filters.UserID) - } - if filters.APIKeyID > 0 { - conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1)) - args = append(args, filters.APIKeyID) - } - if filters.AccountID > 0 { - conditions = append(conditions, fmt.Sprintf("account_id = $%d", len(args)+1)) - args = append(args, filters.AccountID) - } - if filters.GroupID > 0 { - conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1)) - args = append(args, filters.GroupID) - } - conditions, args = appendUsageLogModelWhereCondition(conditions, args, filters.Model, filters.ModelFilterSource) - conditions, args = appendRequestTypeOrStreamWhereCondition(conditions, args, filters.RequestType, filters.Stream) - if filters.BillingType != nil { - conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1)) - args = append(args, int16(*filters.BillingType)) - } - conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode) - if filters.StartTime != nil { - conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) - args = append(args, *filters.StartTime) - } - if filters.EndTime != nil { - conditions = append(conditions, fmt.Sprintf("created_at < $%d", len(args)+1)) - args = append(args, *filters.EndTime) - } - - query := fmt.Sprintf(` - SELECT - COUNT(*) as total_requests, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, - COALESCE(SUM(total_cost), 0) as total_cost, - COALESCE(SUM(actual_cost), 0) as total_actual_cost, - COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as total_account_cost, - COALESCE(AVG(duration_ms), 0) as avg_duration_ms - FROM usage_logs - %s - `, buildWhere(conditions)) - - stats := &UsageStats{} - var totalAccountCost float64 - - start := time.Unix(0, 0).UTC() - if filters.StartTime != nil { - start = *filters.StartTime - } - end := time.Now().UTC() - if filters.EndTime != nil { - end = *filters.EndTime - } - - var endpoints, upstreamEndpoints, endpointPaths []EndpointStat - - // 汇总查询:失败即致命。 - runSummary := func(c context.Context) error { - return scanSingleRow( - c, r.sql, query, args, - &stats.TotalRequests, - &stats.TotalInputTokens, - &stats.TotalOutputTokens, - &stats.TotalCacheTokens, - &stats.TotalCacheCreationTokens, - &stats.TotalCacheReadTokens, - &stats.TotalCost, - &stats.TotalActualCost, - &totalAccountCost, - &stats.AverageDurationMs, - ) - } - // endpoint 明细:best-effort(失败 log + 返空),不致命。 - runEndpoints := func(c context.Context) { - res, err := r.getEndpointStatsByColumnWithFilters(c, "inbound_endpoint", start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - logger.LegacyPrintf("repository.usage_log", "GetEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err) - } - res = []EndpointStat{} - } - endpoints = res - } - runUpstream := func(c context.Context) { - res, err := r.getEndpointStatsByColumnWithFilters(c, "upstream_endpoint", start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - logger.LegacyPrintf("repository.usage_log", "GetUpstreamEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err) - } - res = []EndpointStat{} - } - upstreamEndpoints = res - } - runPaths := func(c context.Context) { - res, err := r.getEndpointPathStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - logger.LegacyPrintf("repository.usage_log", "getEndpointPathStatsWithFilters failed in GetStatsWithFilters: %v", err) - } - res = []EndpointStat{} - } - endpointPaths = res - } - - if r.db != nil { - // 生产路径:r.sql 是 *sql.DB 连接池,可并发。4 条查询并行,延迟取最大值。 - g, gctx := errgroup.WithContext(ctx) - g.Go(func() error { return runSummary(gctx) }) - g.Go(func() error { runEndpoints(gctx); return nil }) - g.Go(func() error { runUpstream(gctx); return nil }) - g.Go(func() error { runPaths(gctx); return nil }) - if err := g.Wait(); err != nil { - return nil, err - } - } else { - // 事务路径(ent.Tx 不能并发查询):顺序执行,行为与重构前一致。 - if err := runSummary(ctx); err != nil { - return nil, err - } - runEndpoints(ctx) - runUpstream(ctx) - runPaths(ctx) - } - - stats.TotalAccountCost = &totalAccountCost - stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens - stats.Endpoints = endpoints - stats.UpstreamEndpoints = upstreamEndpoints - stats.EndpointPaths = endpointPaths - - return stats, nil -} - -// AccountUsageHistory represents daily usage history for an account -type AccountUsageHistory = usagestats.AccountUsageHistory - -// AccountUsageSummary represents summary statistics for an account -type AccountUsageSummary = usagestats.AccountUsageSummary - -// AccountUsageStatsResponse represents the full usage statistics response for an account -type AccountUsageStatsResponse = usagestats.AccountUsageStatsResponse - -// EndpointStat represents endpoint usage statistics row. -type EndpointStat = usagestats.EndpointStat - -func (r *usageLogRepository) getEndpointStatsByColumnWithFilters(ctx context.Context, endpointColumn string, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []EndpointStat, err error) { - actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" - if accountID > 0 && userID == 0 && apiKeyID == 0 { - actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost" - } - - query := fmt.Sprintf(` - SELECT - COALESCE(NULLIF(TRIM(%s), ''), 'unknown') AS endpoint, - COUNT(*) AS requests, - COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS total_tokens, - COALESCE(SUM(total_cost), 0) as cost, - %s - FROM usage_logs - WHERE created_at >= $1 AND created_at < $2 - `, endpointColumn, actualCostExpr) - - args := []any{startTime, endTime} - if userID > 0 { - query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) - args = append(args, userID) - } - if apiKeyID > 0 { - query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) - args = append(args, apiKeyID) - } - if accountID > 0 { - query += fmt.Sprintf(" AND account_id = $%d", len(args)+1) - args = append(args, accountID) - } - if groupID > 0 { - query += fmt.Sprintf(" AND group_id = $%d", len(args)+1) - args = append(args, groupID) - } - query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource) - query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) - if billingType != nil { - query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1) - args = append(args, int16(*billingType)) - } - query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") - query += " GROUP BY endpoint ORDER BY requests DESC" - - rows, err := r.sql.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer func() { - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - results = nil - } - }() - - results = make([]EndpointStat, 0) - for rows.Next() { - var row EndpointStat - if err := rows.Scan(&row.Endpoint, &row.Requests, &row.TotalTokens, &row.Cost, &row.ActualCost); err != nil { - return nil, err - } - results = append(results, row) - } - if err := rows.Err(); err != nil { - return nil, err - } - return results, nil -} - -func (r *usageLogRepository) getEndpointPathStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []EndpointStat, err error) { - actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" - if accountID > 0 && userID == 0 && apiKeyID == 0 { - actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost" - } - - query := fmt.Sprintf(` - SELECT - CONCAT( - COALESCE(NULLIF(TRIM(inbound_endpoint), ''), 'unknown'), - ' -> ', - COALESCE(NULLIF(TRIM(upstream_endpoint), ''), 'unknown') - ) AS endpoint, - COUNT(*) AS requests, - COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS total_tokens, - COALESCE(SUM(total_cost), 0) as cost, - %s - FROM usage_logs - WHERE created_at >= $1 AND created_at < $2 - `, actualCostExpr) - - args := []any{startTime, endTime} - if userID > 0 { - query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) - args = append(args, userID) - } - if apiKeyID > 0 { - query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) - args = append(args, apiKeyID) - } - if accountID > 0 { - query += fmt.Sprintf(" AND account_id = $%d", len(args)+1) - args = append(args, accountID) - } - if groupID > 0 { - query += fmt.Sprintf(" AND group_id = $%d", len(args)+1) - args = append(args, groupID) - } - query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource) - query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) - if billingType != nil { - query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1) - args = append(args, int16(*billingType)) - } - query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") - query += " GROUP BY endpoint ORDER BY requests DESC" - - rows, err := r.sql.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer func() { - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - results = nil - } - }() - - results = make([]EndpointStat, 0) - for rows.Next() { - var row EndpointStat - if err := rows.Scan(&row.Endpoint, &row.Requests, &row.TotalTokens, &row.Cost, &row.ActualCost); err != nil { - return nil, err - } - results = append(results, row) - } - if err := rows.Err(); err != nil { - return nil, err - } - return results, nil -} - -// GetEndpointStatsWithFilters returns inbound endpoint statistics with optional filters. -func (r *usageLogRepository) GetEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]EndpointStat, error) { - return r.getEndpointStatsByColumnWithFilters(ctx, "inbound_endpoint", startTime, endTime, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") -} - -// GetUpstreamEndpointStatsWithFilters returns upstream endpoint statistics with optional filters. -func (r *usageLogRepository) GetUpstreamEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]EndpointStat, error) { - return r.getEndpointStatsByColumnWithFilters(ctx, "upstream_endpoint", startTime, endTime, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") -} - -// GetAccountUsageStats returns comprehensive usage statistics for an account over a time range -func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (resp *AccountUsageStatsResponse, err error) { - daysCount := int(endTime.Sub(startTime).Hours()/24) + 1 - if daysCount <= 0 { - daysCount = 30 - } - - query := ` - SELECT - TO_CHAR(created_at, 'YYYY-MM-DD') as date, - COUNT(*) as requests, - COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens, - COALESCE(SUM(total_cost), 0) as cost, - COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost, - COALESCE(SUM(actual_cost), 0) as user_cost - FROM usage_logs - WHERE account_id = $1 AND created_at >= $2 AND created_at < $3 - GROUP BY date - ORDER BY date ASC - ` - - rows, err := r.sql.QueryContext(ctx, query, accountID, startTime, endTime) - if err != nil { - return nil, err - } - defer func() { - // 保持主错误优先;仅在无错误时回传 Close 失败。 - // 同时清空返回值,避免误用不完整结果。 - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - resp = nil - } - }() - - history := make([]AccountUsageHistory, 0) - for rows.Next() { - var date string - var requests int64 - var tokens int64 - var cost float64 - var actualCost float64 - var userCost float64 - if err = rows.Scan(&date, &requests, &tokens, &cost, &actualCost, &userCost); err != nil { - return nil, err - } - t, _ := time.Parse("2006-01-02", date) - history = append(history, AccountUsageHistory{ - Date: date, - Label: t.Format("01/02"), - Requests: requests, - Tokens: tokens, - Cost: cost, - ActualCost: actualCost, - UserCost: userCost, - }) - } - if err = rows.Err(); err != nil { - return nil, err - } - - var totalAccountCost, totalUserCost, totalStandardCost float64 - var totalRequests, totalTokens int64 - var highestCostDay, highestRequestDay *AccountUsageHistory - - for i := range history { - h := &history[i] - totalAccountCost += h.ActualCost - totalUserCost += h.UserCost - totalStandardCost += h.Cost - totalRequests += h.Requests - totalTokens += h.Tokens - - if highestCostDay == nil || h.ActualCost > highestCostDay.ActualCost { - highestCostDay = h - } - if highestRequestDay == nil || h.Requests > highestRequestDay.Requests { - highestRequestDay = h - } - } - - actualDaysUsed := len(history) - if actualDaysUsed == 0 { - actualDaysUsed = 1 - } - - avgQuery := "SELECT COALESCE(AVG(duration_ms), 0) as avg_duration_ms FROM usage_logs WHERE account_id = $1 AND created_at >= $2 AND created_at < $3" - var avgDuration float64 - if err := scanSingleRow(ctx, r.sql, avgQuery, []any{accountID, startTime, endTime}, &avgDuration); err != nil { - return nil, err - } - - summary := AccountUsageSummary{ - Days: daysCount, - ActualDaysUsed: actualDaysUsed, - TotalCost: totalAccountCost, - TotalUserCost: totalUserCost, - TotalStandardCost: totalStandardCost, - TotalRequests: totalRequests, - TotalTokens: totalTokens, - AvgDailyCost: totalAccountCost / float64(actualDaysUsed), - AvgDailyUserCost: totalUserCost / float64(actualDaysUsed), - AvgDailyRequests: float64(totalRequests) / float64(actualDaysUsed), - AvgDailyTokens: float64(totalTokens) / float64(actualDaysUsed), - AvgDurationMs: avgDuration, - } - - todayStr := timezone.Now().Format("2006-01-02") - for i := range history { - if history[i].Date == todayStr { - summary.Today = &struct { - Date string `json:"date"` - Cost float64 `json:"cost"` - UserCost float64 `json:"user_cost"` - Requests int64 `json:"requests"` - Tokens int64 `json:"tokens"` - }{ - Date: history[i].Date, - Cost: history[i].ActualCost, - UserCost: history[i].UserCost, - Requests: history[i].Requests, - Tokens: history[i].Tokens, - } - break - } - } - - if highestCostDay != nil { - summary.HighestCostDay = &struct { - Date string `json:"date"` - Label string `json:"label"` - Cost float64 `json:"cost"` - UserCost float64 `json:"user_cost"` - Requests int64 `json:"requests"` - }{ - Date: highestCostDay.Date, - Label: highestCostDay.Label, - Cost: highestCostDay.ActualCost, - UserCost: highestCostDay.UserCost, - Requests: highestCostDay.Requests, - } - } - - if highestRequestDay != nil { - summary.HighestRequestDay = &struct { - Date string `json:"date"` - Label string `json:"label"` - Requests int64 `json:"requests"` - Cost float64 `json:"cost"` - UserCost float64 `json:"user_cost"` - }{ - Date: highestRequestDay.Date, - Label: highestRequestDay.Label, - Requests: highestRequestDay.Requests, - Cost: highestRequestDay.ActualCost, - UserCost: highestRequestDay.UserCost, - } - } - - models, err := r.GetModelStatsWithFilters(ctx, startTime, endTime, 0, 0, accountID, 0, nil, nil, nil) - if err != nil { - models = []ModelStat{} - } - endpoints, endpointErr := r.GetEndpointStatsWithFilters(ctx, startTime, endTime, 0, 0, accountID, 0, "", nil, nil, nil) - if endpointErr != nil { - logger.LegacyPrintf("repository.usage_log", "GetEndpointStatsWithFilters failed in GetAccountUsageStats: %v", endpointErr) - endpoints = []EndpointStat{} - } - upstreamEndpoints, upstreamEndpointErr := r.GetUpstreamEndpointStatsWithFilters(ctx, startTime, endTime, 0, 0, accountID, 0, "", nil, nil, nil) - if upstreamEndpointErr != nil { - logger.LegacyPrintf("repository.usage_log", "GetUpstreamEndpointStatsWithFilters failed in GetAccountUsageStats: %v", upstreamEndpointErr) - upstreamEndpoints = []EndpointStat{} - } - - resp = &AccountUsageStatsResponse{ - History: history, - Summary: summary, - Models: models, - Endpoints: endpoints, - UpstreamEndpoints: upstreamEndpoints, - } - return resp, nil -} - -func (r *usageLogRepository) listUsageLogsWithPagination(ctx context.Context, whereClause string, args []any, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { - countQuery := "SELECT COUNT(*) FROM usage_logs " + whereClause - var total int64 - if err := scanSingleRow(ctx, r.sql, countQuery, args, &total); err != nil { - return nil, nil, err - } - - limitPos := len(args) + 1 - offsetPos := len(args) + 2 - listArgs := append(append([]any{}, args...), params.Limit(), params.Offset()) - query := fmt.Sprintf("SELECT %s FROM usage_logs %s ORDER BY %s LIMIT $%d OFFSET $%d", usageLogSelectColumns, whereClause, usageLogOrderBy(params), limitPos, offsetPos) - logs, err := r.queryUsageLogs(ctx, query, listArgs...) - if err != nil { - return nil, nil, err - } - return logs, paginationResultFromTotal(total, params), nil -} - -func (r *usageLogRepository) listUsageLogsWithFastPagination(ctx context.Context, whereClause string, args []any, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { - limit := params.Limit() - offset := params.Offset() - - limitPos := len(args) + 1 - offsetPos := len(args) + 2 - listArgs := append(append([]any{}, args...), limit+1, offset) - query := fmt.Sprintf("SELECT %s FROM usage_logs %s ORDER BY %s LIMIT $%d OFFSET $%d", usageLogSelectColumns, whereClause, usageLogOrderBy(params), limitPos, offsetPos) - - logs, err := r.queryUsageLogs(ctx, query, listArgs...) - if err != nil { - return nil, nil, err - } - - hasMore := false - if len(logs) > limit { - hasMore = true - logs = logs[:limit] - } - - total := int64(offset) + int64(len(logs)) - if hasMore { - // 只保证“还有下一页”,避免对超大表做全量 COUNT(*)。 - total = int64(offset) + int64(limit) + 1 - } - - return logs, paginationResultFromTotal(total, params), nil -} - -func usageLogOrderBy(params pagination.PaginationParams) string { - sortBy := strings.ToLower(strings.TrimSpace(params.SortBy)) - sortOrder := strings.ToUpper(params.NormalizedSortOrder(pagination.SortOrderDesc)) - - var column string - switch sortBy { - case "model": - column = "COALESCE(NULLIF(TRIM(requested_model), ''), model)" - case "created_at": - column = "created_at" - default: - column = "id" - } - - if column == "id" { - return fmt.Sprintf("id %s", sortOrder) - } - return fmt.Sprintf("%s %s, id %s", column, sortOrder, sortOrder) -} - -func (r *usageLogRepository) queryUsageLogs(ctx context.Context, query string, args ...any) (logs []service.UsageLog, err error) { - rows, err := r.sql.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer func() { - // 保持主错误优先;仅在无错误时回传 Close 失败。 - // 同时清空返回值,避免误用不完整结果。 - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - logs = nil - } - }() - - logs = make([]service.UsageLog, 0) - for rows.Next() { - var log *service.UsageLog - log, err = scanUsageLog(rows) - if err != nil { - return nil, err - } - logs = append(logs, *log) - } - if err = rows.Err(); err != nil { - return nil, err - } - return logs, nil -} - -func (r *usageLogRepository) hydrateUsageLogAssociations(ctx context.Context, logs []service.UsageLog) error { - // 关联数据使用 Ent 批量加载,避免把复杂 SQL 继续膨胀。 - if len(logs) == 0 { - return nil - } - - ids := collectUsageLogIDs(logs) - users, err := r.loadUsers(ctx, ids.userIDs) - if err != nil { - return err - } - apiKeys, err := r.loadAPIKeys(ctx, ids.apiKeyIDs) - if err != nil { - return err - } - accounts, err := r.loadAccounts(ctx, ids.accountIDs) - if err != nil { - return err - } - groups, err := r.loadGroups(ctx, ids.groupIDs) - if err != nil { - return err - } - subs, err := r.loadSubscriptions(ctx, ids.subscriptionIDs) - if err != nil { - return err - } - - for i := range logs { - if user, ok := users[logs[i].UserID]; ok { - logs[i].User = user - } - if key, ok := apiKeys[logs[i].APIKeyID]; ok { - logs[i].APIKey = key - } - if acc, ok := accounts[logs[i].AccountID]; ok { - logs[i].Account = acc - } - if logs[i].GroupID != nil { - if group, ok := groups[*logs[i].GroupID]; ok { - logs[i].Group = group - } - } - if logs[i].SubscriptionID != nil { - if sub, ok := subs[*logs[i].SubscriptionID]; ok { - logs[i].Subscription = sub - } - } - } - return nil -} - -type usageLogIDs struct { - userIDs []int64 - apiKeyIDs []int64 - accountIDs []int64 - groupIDs []int64 - subscriptionIDs []int64 -} - -func collectUsageLogIDs(logs []service.UsageLog) usageLogIDs { - idSet := func() map[int64]struct{} { return make(map[int64]struct{}) } - - userIDs := idSet() - apiKeyIDs := idSet() - accountIDs := idSet() - groupIDs := idSet() - subscriptionIDs := idSet() - - for i := range logs { - userIDs[logs[i].UserID] = struct{}{} - apiKeyIDs[logs[i].APIKeyID] = struct{}{} - accountIDs[logs[i].AccountID] = struct{}{} - if logs[i].GroupID != nil { - groupIDs[*logs[i].GroupID] = struct{}{} - } - if logs[i].SubscriptionID != nil { - subscriptionIDs[*logs[i].SubscriptionID] = struct{}{} - } - } - - return usageLogIDs{ - userIDs: setToSlice(userIDs), - apiKeyIDs: setToSlice(apiKeyIDs), - accountIDs: setToSlice(accountIDs), - groupIDs: setToSlice(groupIDs), - subscriptionIDs: setToSlice(subscriptionIDs), - } -} - -func (r *usageLogRepository) loadUsers(ctx context.Context, ids []int64) (map[int64]*service.User, error) { - out := make(map[int64]*service.User) - if len(ids) == 0 { - return out, nil - } - // 无条件穿透软删除:ids 来自调用方已按 user_id 筛选的日志行;普通用户路径强制 UserID=本人(本人必为活跃用户),不会借此解析他人已删身份;仅 admin 路径可借此显示已删用户。 - models, err := r.client.User.Query().Where(dbuser.IDIn(ids...)).All(mixins.SkipSoftDelete(ctx)) - if err != nil { - return nil, err - } - for _, m := range models { - out[m.ID] = userEntityToService(m) - } - return out, nil -} - -func (r *usageLogRepository) loadAPIKeys(ctx context.Context, ids []int64) (map[int64]*service.APIKey, error) { - out := make(map[int64]*service.APIKey) - if len(ids) == 0 { - return out, nil - } - models, err := r.client.APIKey.Query().Where(dbapikey.IDIn(ids...)).All(ctx) - if err != nil { - return nil, err - } - for _, m := range models { - out[m.ID] = apiKeyEntityToService(m) - } - return out, nil -} - -func (r *usageLogRepository) loadAccounts(ctx context.Context, ids []int64) (map[int64]*service.Account, error) { - out := make(map[int64]*service.Account) - if len(ids) == 0 { - return out, nil - } - models, err := r.client.Account.Query().Where(dbaccount.IDIn(ids...)).All(ctx) - if err != nil { - return nil, err - } - for _, m := range models { - out[m.ID] = accountEntityToService(m) - } - return out, nil -} - -func (r *usageLogRepository) loadGroups(ctx context.Context, ids []int64) (map[int64]*service.Group, error) { - out := make(map[int64]*service.Group) - if len(ids) == 0 { - return out, nil - } - models, err := r.client.Group.Query().Where(dbgroup.IDIn(ids...)).All(ctx) - if err != nil { - return nil, err - } - for _, m := range models { - out[m.ID] = groupEntityToService(m) - } - return out, nil -} - -func (r *usageLogRepository) loadSubscriptions(ctx context.Context, ids []int64) (map[int64]*service.UserSubscription, error) { - out := make(map[int64]*service.UserSubscription) - if len(ids) == 0 { - return out, nil - } - models, err := r.client.UserSubscription.Query().Where(dbusersub.IDIn(ids...)).All(ctx) - if err != nil { - return nil, err - } - for _, m := range models { - out[m.ID] = userSubscriptionEntityToService(m) - } - return out, nil -} - -func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, error) { - var ( - id int64 - userID int64 - apiKeyID int64 - accountID int64 - requestID sql.NullString - model string - requestedModel sql.NullString - upstreamModel sql.NullString - groupID sql.NullInt64 - subscriptionID sql.NullInt64 - inputTokens int - outputTokens int - cacheCreationTokens int - cacheReadTokens int - cacheCreation5m int - cacheCreation1h int - imageOutputTokens int - imageOutputCost float64 - inputCost float64 - outputCost float64 - cacheCreationCost float64 - cacheReadCost float64 - totalCost float64 - actualCost float64 - rateMultiplier float64 - accountRateMultiplier sql.NullFloat64 - billingType int16 - requestTypeRaw int16 - stream bool - openaiWSMode bool - durationMs sql.NullInt64 - firstTokenMs sql.NullInt64 - userAgent sql.NullString - ipAddress sql.NullString - imageCount int - imageSize sql.NullString - imageInputSize sql.NullString - imageOutputSize sql.NullString - imageSizeSource sql.NullString - imageSizeBreakdown sql.NullString - serviceTier sql.NullString - reasoningEffort sql.NullString - inboundEndpoint sql.NullString - upstreamEndpoint sql.NullString - cacheTTLOverridden bool - channelID sql.NullInt64 - modelMappingChain sql.NullString - billingTier sql.NullString - billingMode sql.NullString - accountStatsCost sql.NullFloat64 - createdAt time.Time - ) - - if err := scanner.Scan( - &id, - &userID, - &apiKeyID, - &accountID, - &requestID, - &model, - &requestedModel, - &upstreamModel, - &groupID, - &subscriptionID, - &inputTokens, - &outputTokens, - &cacheCreationTokens, - &cacheReadTokens, - &cacheCreation5m, - &cacheCreation1h, - &imageOutputTokens, - &imageOutputCost, - &inputCost, - &outputCost, - &cacheCreationCost, - &cacheReadCost, - &totalCost, - &actualCost, - &rateMultiplier, - &accountRateMultiplier, - &billingType, - &requestTypeRaw, - &stream, - &openaiWSMode, - &durationMs, - &firstTokenMs, - &userAgent, - &ipAddress, - &imageCount, - &imageSize, - &imageInputSize, - &imageOutputSize, - &imageSizeSource, - &imageSizeBreakdown, - &serviceTier, - &reasoningEffort, - &inboundEndpoint, - &upstreamEndpoint, - &cacheTTLOverridden, - &channelID, - &modelMappingChain, - &billingTier, - &billingMode, - &accountStatsCost, - &createdAt, - ); err != nil { - return nil, err - } - - log := &service.UsageLog{ - ID: id, - UserID: userID, - APIKeyID: apiKeyID, - AccountID: accountID, - Model: model, - RequestedModel: coalesceTrimmedString(requestedModel, model), - InputTokens: inputTokens, - OutputTokens: outputTokens, - CacheCreationTokens: cacheCreationTokens, - CacheReadTokens: cacheReadTokens, - CacheCreation5mTokens: cacheCreation5m, - CacheCreation1hTokens: cacheCreation1h, - ImageOutputTokens: imageOutputTokens, - ImageOutputCost: imageOutputCost, - InputCost: inputCost, - OutputCost: outputCost, - CacheCreationCost: cacheCreationCost, - CacheReadCost: cacheReadCost, - TotalCost: totalCost, - ActualCost: actualCost, - RateMultiplier: rateMultiplier, - AccountRateMultiplier: nullFloat64Ptr(accountRateMultiplier), - BillingType: int8(billingType), - RequestType: service.RequestTypeFromInt16(requestTypeRaw), - ImageCount: imageCount, - CacheTTLOverridden: cacheTTLOverridden, - CreatedAt: createdAt, - } - // 先回填 legacy 字段,再基于 legacy + request_type 计算最终请求类型,保证历史数据兼容。 - log.Stream = stream - log.OpenAIWSMode = openaiWSMode - log.RequestType = log.EffectiveRequestType() - log.Stream, log.OpenAIWSMode = service.ApplyLegacyRequestFields(log.RequestType, stream, openaiWSMode) - - if requestID.Valid { - log.RequestID = requestID.String - } - if groupID.Valid { - value := groupID.Int64 - log.GroupID = &value - } - if subscriptionID.Valid { - value := subscriptionID.Int64 - log.SubscriptionID = &value - } - if durationMs.Valid { - value := int(durationMs.Int64) - log.DurationMs = &value - } - if firstTokenMs.Valid { - value := int(firstTokenMs.Int64) - log.FirstTokenMs = &value - } - if userAgent.Valid { - log.UserAgent = &userAgent.String - } - if ipAddress.Valid { - log.IPAddress = &ipAddress.String - } - if imageSize.Valid { - log.ImageSize = &imageSize.String - } - if imageInputSize.Valid { - log.ImageInputSize = &imageInputSize.String - } - if imageOutputSize.Valid { - log.ImageOutputSize = &imageOutputSize.String - } - if imageSizeSource.Valid { - log.ImageSizeSource = &imageSizeSource.String - } - log.ImageSizeBreakdown = stringIntMapFromNullJSON(imageSizeBreakdown) - if serviceTier.Valid { - log.ServiceTier = &serviceTier.String - } - if reasoningEffort.Valid { - log.ReasoningEffort = &reasoningEffort.String - } - if inboundEndpoint.Valid { - log.InboundEndpoint = &inboundEndpoint.String - } - if upstreamEndpoint.Valid { - log.UpstreamEndpoint = &upstreamEndpoint.String - } - if upstreamModel.Valid { - log.UpstreamModel = &upstreamModel.String - } - if channelID.Valid { - value := channelID.Int64 - log.ChannelID = &value - } - if modelMappingChain.Valid { - log.ModelMappingChain = &modelMappingChain.String - } - if billingTier.Valid { - log.BillingTier = &billingTier.String - } - if billingMode.Valid { - log.BillingMode = &billingMode.String - } - if accountStatsCost.Valid { - log.AccountStatsCost = &accountStatsCost.Float64 - } - - return log, nil -} - -func scanTrendRows(rows *sql.Rows) ([]TrendDataPoint, error) { - results := make([]TrendDataPoint, 0) - for rows.Next() { - var row TrendDataPoint - if err := rows.Scan( - &row.Date, - &row.Requests, - &row.InputTokens, - &row.OutputTokens, - &row.CacheCreationTokens, - &row.CacheReadTokens, - &row.TotalTokens, - &row.Cost, - &row.ActualCost, - ); err != nil { - return nil, err - } - results = append(results, row) - } - if err := rows.Err(); err != nil { - return nil, err - } - return results, nil -} - -func scanModelStatsRows(rows *sql.Rows) ([]ModelStat, error) { - results := make([]ModelStat, 0) - for rows.Next() { - var row ModelStat - if err := rows.Scan( - &row.Model, - &row.Requests, - &row.InputTokens, - &row.OutputTokens, - &row.CacheCreationTokens, - &row.CacheReadTokens, - &row.TotalTokens, - &row.Cost, - &row.ActualCost, - &row.AccountCost, - ); err != nil { - return nil, err - } - results = append(results, row) - } - if err := rows.Err(); err != nil { - return nil, err - } - return results, nil -} - func buildWhere(conditions []string) string { if len(conditions) == 0 { return "" @@ -4630,72 +210,3 @@ func buildRequestTypeFilterCondition(startArgIndex int, requestType int16) (stri return fmt.Sprintf("request_type = $%d", startArgIndex), []any{requestTypeArg} } } - -func nullInt64(v *int64) sql.NullInt64 { - if v == nil { - return sql.NullInt64{} - } - return sql.NullInt64{Int64: *v, Valid: true} -} - -func nullInt(v *int) sql.NullInt64 { - if v == nil { - return sql.NullInt64{} - } - return sql.NullInt64{Int64: int64(*v), Valid: true} -} - -func nullFloat64Ptr(v sql.NullFloat64) *float64 { - if !v.Valid { - return nil - } - out := v.Float64 - return &out -} - -func nullString(v *string) sql.NullString { - if v == nil || *v == "" { - return sql.NullString{} - } - return sql.NullString{String: *v, Valid: true} -} - -func nullStringIntMapJSON(v map[string]int) any { - if len(v) == 0 { - return nil - } - payload, err := json.Marshal(v) - if err != nil { - return nil - } - return string(payload) -} - -func stringIntMapFromNullJSON(v sql.NullString) map[string]int { - if !v.Valid || strings.TrimSpace(v.String) == "" { - return nil - } - var out map[string]int - if err := json.Unmarshal([]byte(v.String), &out); err != nil { - return nil - } - if len(out) == 0 { - return nil - } - return out -} - -func coalesceTrimmedString(v sql.NullString, fallback string) string { - if v.Valid && strings.TrimSpace(v.String) != "" { - return v.String - } - return fallback -} - -func setToSlice(set map[int64]struct{}) []int64 { - out := make([]int64, 0, len(set)) - for id := range set { - out = append(out, id) - } - return out -} diff --git a/backend/internal/repository/usage_log_repo_dashboard.go b/backend/internal/repository/usage_log_repo_dashboard.go new file mode 100644 index 0000000000..a56213357e --- /dev/null +++ b/backend/internal/repository/usage_log_repo_dashboard.go @@ -0,0 +1,628 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// getPerformanceStats 获取 RPM 和 TPM(近5分钟平均值,可选按用户过滤) +func (r *usageLogRepository) getPerformanceStats(ctx context.Context, userID int64) (rpm, tpm int64, err error) { + fiveMinutesAgo := time.Now().Add(-5 * time.Minute) + query := ` + SELECT + COUNT(*) as request_count, + COALESCE(SUM(input_tokens + output_tokens), 0) as token_count + FROM usage_logs + WHERE created_at >= $1` + args := []any{fiveMinutesAgo} + if userID > 0 { + query += " AND user_id = $2" + args = append(args, userID) + } + + var requestCount int64 + var tokenCount int64 + if err := scanSingleRow(ctx, r.sql, query, args, &requestCount, &tokenCount); err != nil { + return 0, 0, err + } + return requestCount / 5, tokenCount / 5, nil +} + +// UserStats 用户使用统计 +type UserStats struct { + TotalRequests int64 `json:"total_requests"` + TotalTokens int64 `json:"total_tokens"` + TotalCost float64 `json:"total_cost"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` +} + +func (r *usageLogRepository) GetUserStats(ctx context.Context, userID int64, startTime, endTime time.Time) (*UserStats, error) { + query := ` + SELECT + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens, + COALESCE(SUM(actual_cost), 0) as total_cost, + COALESCE(SUM(input_tokens), 0) as input_tokens, + COALESCE(SUM(output_tokens), 0) as output_tokens, + COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens + FROM usage_logs + WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 + ` + + stats := &UserStats{} + if err := scanSingleRow( + ctx, + r.sql, + query, + []any{userID, startTime, endTime}, + &stats.TotalRequests, + &stats.TotalTokens, + &stats.TotalCost, + &stats.InputTokens, + &stats.OutputTokens, + &stats.CacheReadTokens, + ); err != nil { + return nil, err + } + return stats, nil +} + +// DashboardStats 仪表盘统计 +type DashboardStats = usagestats.DashboardStats + +func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) { + stats := &DashboardStats{} + now := timezone.Now() + todayStart := timezone.Today() + + if err := r.fillDashboardEntityStats(ctx, stats, todayStart, now); err != nil { + return nil, err + } + if err := r.fillDashboardUsageStatsAggregated(ctx, stats, todayStart, now); err != nil { + return nil, err + } + + rpm, tpm, err := r.getPerformanceStats(ctx, 0) + if err != nil { + return nil, err + } + stats.Rpm = rpm + stats.Tpm = tpm + + return stats, nil +} + +func (r *usageLogRepository) GetDashboardStatsWithRange(ctx context.Context, start, end time.Time) (*DashboardStats, error) { + startUTC := start.UTC() + endUTC := end.UTC() + if !endUTC.After(startUTC) { + return nil, errors.New("统计时间范围无效") + } + + stats := &DashboardStats{} + now := timezone.Now() + todayStart := timezone.Today() + + if err := r.fillDashboardEntityStats(ctx, stats, todayStart, now); err != nil { + return nil, err + } + if err := r.fillDashboardUsageStatsFromUsageLogs(ctx, stats, startUTC, endUTC, todayStart, now); err != nil { + return nil, err + } + + rpm, tpm, err := r.getPerformanceStats(ctx, 0) + if err != nil { + return nil, err + } + stats.Rpm = rpm + stats.Tpm = tpm + + return stats, nil +} + +func (r *usageLogRepository) fillDashboardEntityStats(ctx context.Context, stats *DashboardStats, todayUTC, now time.Time) error { + userStatsQuery := ` + SELECT + COUNT(*) as total_users, + COUNT(CASE WHEN created_at >= $1 THEN 1 END) as today_new_users + FROM users + WHERE deleted_at IS NULL + ` + if err := scanSingleRow( + ctx, + r.sql, + userStatsQuery, + []any{todayUTC}, + &stats.TotalUsers, + &stats.TodayNewUsers, + ); err != nil { + return err + } + + apiKeyStatsQuery := ` + SELECT + COUNT(*) as total_api_keys, + COUNT(CASE WHEN status = $1 THEN 1 END) as active_api_keys + FROM api_keys + WHERE deleted_at IS NULL + ` + if err := scanSingleRow( + ctx, + r.sql, + apiKeyStatsQuery, + []any{service.StatusActive}, + &stats.TotalAPIKeys, + &stats.ActiveAPIKeys, + ); err != nil { + return err + } + + accountStatsQuery := ` + SELECT + COUNT(*) as total_accounts, + COUNT(CASE WHEN status = $1 AND schedulable = true THEN 1 END) as normal_accounts, + COUNT(CASE WHEN status = $2 THEN 1 END) as error_accounts, + COUNT(CASE WHEN rate_limited_at IS NOT NULL AND rate_limit_reset_at > $3 THEN 1 END) as ratelimit_accounts, + COUNT(CASE WHEN overload_until IS NOT NULL AND overload_until > $4 THEN 1 END) as overload_accounts + FROM accounts + WHERE deleted_at IS NULL + ` + if err := scanSingleRow( + ctx, + r.sql, + accountStatsQuery, + []any{service.StatusActive, service.StatusError, now, now}, + &stats.TotalAccounts, + &stats.NormalAccounts, + &stats.ErrorAccounts, + &stats.RateLimitAccounts, + &stats.OverloadAccounts, + ); err != nil { + return err + } + + return nil +} + +func (r *usageLogRepository) fillDashboardUsageStatsAggregated(ctx context.Context, stats *DashboardStats, todayUTC, now time.Time) error { + totalStatsQuery := ` + SELECT + COALESCE(SUM(total_requests), 0) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(SUM(account_cost), 0) as total_account_cost, + COALESCE(SUM(total_duration_ms), 0) as total_duration_ms + FROM usage_dashboard_daily + ` + var totalDurationMs int64 + if err := scanSingleRow( + ctx, + r.sql, + totalStatsQuery, + nil, + &stats.TotalRequests, + &stats.TotalInputTokens, + &stats.TotalOutputTokens, + &stats.TotalCacheCreationTokens, + &stats.TotalCacheReadTokens, + &stats.TotalCost, + &stats.TotalActualCost, + &stats.TotalAccountCost, + &totalDurationMs, + ); err != nil { + return err + } + stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens + if stats.TotalRequests > 0 { + stats.AverageDurationMs = float64(totalDurationMs) / float64(stats.TotalRequests) + } + + todayStatsQuery := ` + SELECT + total_requests as today_requests, + input_tokens as today_input_tokens, + output_tokens as today_output_tokens, + cache_creation_tokens as today_cache_creation_tokens, + cache_read_tokens as today_cache_read_tokens, + total_cost as today_cost, + actual_cost as today_actual_cost, + account_cost as today_account_cost, + active_users as active_users + FROM usage_dashboard_daily + WHERE bucket_date = $1::date + ` + if err := scanSingleRow( + ctx, + r.sql, + todayStatsQuery, + []any{todayUTC}, + &stats.TodayRequests, + &stats.TodayInputTokens, + &stats.TodayOutputTokens, + &stats.TodayCacheCreationTokens, + &stats.TodayCacheReadTokens, + &stats.TodayCost, + &stats.TodayActualCost, + &stats.TodayAccountCost, + &stats.ActiveUsers, + ); err != nil { + if err != sql.ErrNoRows { + return err + } + } + stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens + + hourlyActiveQuery := ` + SELECT active_users + FROM usage_dashboard_hourly + WHERE bucket_start = $1 + ` + hourStart := now.In(timezone.Location()).Truncate(time.Hour) + if err := scanSingleRow(ctx, r.sql, hourlyActiveQuery, []any{hourStart}, &stats.HourlyActiveUsers); err != nil { + if err != sql.ErrNoRows { + return err + } + } + + return nil +} + +func (r *usageLogRepository) fillDashboardUsageStatsFromUsageLogs(ctx context.Context, stats *DashboardStats, startUTC, endUTC, todayUTC, now time.Time) error { + todayEnd := todayUTC.Add(24 * time.Hour) + combinedStatsQuery := ` + WITH scoped AS ( + SELECT + created_at, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + total_cost, + actual_cost, + COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1) AS account_cost, + COALESCE(duration_ms, 0) AS duration_ms + FROM usage_logs + WHERE created_at >= LEAST($1::timestamptz, $3::timestamptz) + AND created_at < GREATEST($2::timestamptz, $4::timestamptz) + ) + SELECT + COUNT(*) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz) AS total_requests, + COALESCE(SUM(input_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_input_tokens, + COALESCE(SUM(output_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_output_tokens, + COALESCE(SUM(cache_creation_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_cache_read_tokens, + COALESCE(SUM(total_cost) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_cost, + COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_actual_cost, + COALESCE(SUM(account_cost) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_account_cost, + COALESCE(SUM(duration_ms) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_duration_ms, + COUNT(*) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz) AS today_requests, + COALESCE(SUM(input_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_input_tokens, + COALESCE(SUM(output_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_output_tokens, + COALESCE(SUM(cache_creation_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_cache_read_tokens, + COALESCE(SUM(total_cost) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_cost, + COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_actual_cost, + COALESCE(SUM(account_cost) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_account_cost + FROM scoped + ` + var totalDurationMs int64 + if err := scanSingleRow( + ctx, + r.sql, + combinedStatsQuery, + []any{startUTC, endUTC, todayUTC, todayEnd}, + &stats.TotalRequests, + &stats.TotalInputTokens, + &stats.TotalOutputTokens, + &stats.TotalCacheCreationTokens, + &stats.TotalCacheReadTokens, + &stats.TotalCost, + &stats.TotalActualCost, + &stats.TotalAccountCost, + &totalDurationMs, + &stats.TodayRequests, + &stats.TodayInputTokens, + &stats.TodayOutputTokens, + &stats.TodayCacheCreationTokens, + &stats.TodayCacheReadTokens, + &stats.TodayCost, + &stats.TodayActualCost, + &stats.TodayAccountCost, + ); err != nil { + return err + } + stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens + if stats.TotalRequests > 0 { + stats.AverageDurationMs = float64(totalDurationMs) / float64(stats.TotalRequests) + } + + stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens + + hourStart := now.UTC().Truncate(time.Hour) + hourEnd := hourStart.Add(time.Hour) + activeUsersQuery := ` + WITH scoped AS ( + SELECT user_id, created_at + FROM usage_logs + WHERE created_at >= LEAST($1::timestamptz, $3::timestamptz) + AND created_at < GREATEST($2::timestamptz, $4::timestamptz) + ) + SELECT + COUNT(DISTINCT CASE WHEN created_at >= $1::timestamptz AND created_at < $2::timestamptz THEN user_id END) AS active_users, + COUNT(DISTINCT CASE WHEN created_at >= $3::timestamptz AND created_at < $4::timestamptz THEN user_id END) AS hourly_active_users + FROM scoped + ` + if err := scanSingleRow(ctx, r.sql, activeUsersQuery, []any{todayUTC, todayEnd, hourStart, hourEnd}, &stats.ActiveUsers, &stats.HourlyActiveUsers); err != nil { + return err + } + + return nil +} + +// UserDashboardStats 用户仪表盘统计 +type UserDashboardStats = usagestats.UserDashboardStats + +// PlatformDashboardStats 单平台用量明细 +type PlatformDashboardStats = usagestats.PlatformDashboardStats + +// GetUserDashboardStats 获取用户专属的仪表盘统计 +func (r *usageLogRepository) GetUserDashboardStats(ctx context.Context, userID int64) (*UserDashboardStats, error) { + stats := &UserDashboardStats{} + today := timezone.Today() + + // API Key 统计 + if err := scanSingleRow( + ctx, + r.sql, + "SELECT COUNT(*) FROM api_keys WHERE user_id = $1 AND deleted_at IS NULL", + []any{userID}, + &stats.TotalAPIKeys, + ); err != nil { + return nil, err + } + if err := scanSingleRow( + ctx, + r.sql, + "SELECT COUNT(*) FROM api_keys WHERE user_id = $1 AND status = $2 AND deleted_at IS NULL", + []any{userID, service.StatusActive}, + &stats.ActiveAPIKeys, + ); err != nil { + return nil, err + } + + // 累计 Token 统计 + totalStatsQuery := ` + SELECT + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(AVG(duration_ms), 0) as avg_duration_ms + FROM usage_logs + WHERE user_id = $1 + ` + if err := scanSingleRow( + ctx, + r.sql, + totalStatsQuery, + []any{userID}, + &stats.TotalRequests, + &stats.TotalInputTokens, + &stats.TotalOutputTokens, + &stats.TotalCacheCreationTokens, + &stats.TotalCacheReadTokens, + &stats.TotalCost, + &stats.TotalActualCost, + &stats.AverageDurationMs, + ); err != nil { + return nil, err + } + stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens + + // 今日 Token 统计 + todayStatsQuery := ` + SELECT + COUNT(*) as today_requests, + COALESCE(SUM(input_tokens), 0) as today_input_tokens, + COALESCE(SUM(output_tokens), 0) as today_output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as today_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as today_cache_read_tokens, + COALESCE(SUM(total_cost), 0) as today_cost, + COALESCE(SUM(actual_cost), 0) as today_actual_cost + FROM usage_logs + WHERE user_id = $1 AND created_at >= $2 + ` + if err := scanSingleRow( + ctx, + r.sql, + todayStatsQuery, + []any{userID, today}, + &stats.TodayRequests, + &stats.TodayInputTokens, + &stats.TodayOutputTokens, + &stats.TodayCacheCreationTokens, + &stats.TodayCacheReadTokens, + &stats.TodayCost, + &stats.TodayActualCost, + ); err != nil { + return nil, err + } + stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens + + // 性能指标:RPM 和 TPM(最近1分钟,仅统计该用户的请求) + rpm, tpm, err := r.getPerformanceStats(ctx, userID) + if err != nil { + return nil, err + } + stats.Rpm = rpm + stats.Tpm = tpm + + // 按"有效平台"维度拆分(group.platform 优先,否则 account.platform)。 + // 与 ops 路径口径一致;HAVING 过滤掉无法确定平台的行(避免出现空字符串平台)。 + // 与上面 totalStatsQuery/todayStatsQuery 的总值可能略微差异,原因有二: + // 1) 无平台归属的极少数行(group/account 都没 platform)会被 HAVING 排除; + // 2) usageLogSuccessFilterUL 会把 actual_cost = 0 的失败 placeholder 行排除, + // 而 totalStatsQuery/todayStatsQuery 没有这层过滤、会把这些行的 request 计数算进去。 + platformQuery := ` + SELECT + ` + usageLogEffectivePlatformExpr + ` as platform, + COUNT(*) as total_requests, + COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens, + COALESCE(SUM(ul.actual_cost), 0) as total_actual_cost, + COUNT(*) FILTER (WHERE ul.created_at >= $2) as today_requests, + COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens) FILTER (WHERE ul.created_at >= $2), 0) as today_tokens, + COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $2), 0) as today_actual_cost + FROM usage_logs ul + LEFT JOIN groups g ON g.id = ul.group_id + LEFT JOIN accounts a ON a.id = ul.account_id + WHERE ul.user_id = $1 + AND ` + usageLogSuccessFilterUL + ` + GROUP BY ` + usageLogEffectivePlatformExpr + ` + HAVING ` + usageLogEffectivePlatformExpr + ` IS NOT NULL AND ` + usageLogEffectivePlatformExpr + ` <> '' + ORDER BY total_actual_cost DESC + ` + rows, err := r.sql.QueryContext(ctx, platformQuery, userID, today) + if err != nil { + return nil, err + } + for rows.Next() { + var p PlatformDashboardStats + if err := rows.Scan( + &p.Platform, + &p.TotalRequests, + &p.TotalTokens, + &p.TotalActualCost, + &p.TodayRequests, + &p.TodayTokens, + &p.TodayActualCost, + ); err != nil { + _ = rows.Close() + return nil, err + } + stats.ByPlatform = append(stats.ByPlatform, p) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + + return stats, nil +} + +// getPerformanceStatsByAPIKey 获取指定 API Key 的 RPM 和 TPM(近5分钟平均值) +func (r *usageLogRepository) getPerformanceStatsByAPIKey(ctx context.Context, apiKeyID int64) (rpm, tpm int64, err error) { + fiveMinutesAgo := time.Now().Add(-5 * time.Minute) + query := ` + SELECT + COUNT(*) as request_count, + COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as token_count + FROM usage_logs + WHERE created_at >= $1 AND api_key_id = $2` + args := []any{fiveMinutesAgo, apiKeyID} + + var requestCount int64 + var tokenCount int64 + if err := scanSingleRow(ctx, r.sql, query, args, &requestCount, &tokenCount); err != nil { + return 0, 0, err + } + return requestCount / 5, tokenCount / 5, nil +} + +// GetAPIKeyDashboardStats 获取指定 API Key 的仪表盘统计(按 api_key_id 过滤) +func (r *usageLogRepository) GetAPIKeyDashboardStats(ctx context.Context, apiKeyID int64) (*UserDashboardStats, error) { + stats := &UserDashboardStats{} + today := timezone.Today() + + // API Key 维度不需要统计 key 数量,设为 1 + stats.TotalAPIKeys = 1 + stats.ActiveAPIKeys = 1 + + // 累计 Token 统计 + totalStatsQuery := ` + SELECT + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(AVG(duration_ms), 0) as avg_duration_ms + FROM usage_logs + WHERE api_key_id = $1 + ` + if err := scanSingleRow( + ctx, + r.sql, + totalStatsQuery, + []any{apiKeyID}, + &stats.TotalRequests, + &stats.TotalInputTokens, + &stats.TotalOutputTokens, + &stats.TotalCacheCreationTokens, + &stats.TotalCacheReadTokens, + &stats.TotalCost, + &stats.TotalActualCost, + &stats.AverageDurationMs, + ); err != nil { + return nil, err + } + stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens + + // 今日 Token 统计 + todayStatsQuery := ` + SELECT + COUNT(*) as today_requests, + COALESCE(SUM(input_tokens), 0) as today_input_tokens, + COALESCE(SUM(output_tokens), 0) as today_output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as today_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as today_cache_read_tokens, + COALESCE(SUM(total_cost), 0) as today_cost, + COALESCE(SUM(actual_cost), 0) as today_actual_cost + FROM usage_logs + WHERE api_key_id = $1 AND created_at >= $2 + ` + if err := scanSingleRow( + ctx, + r.sql, + todayStatsQuery, + []any{apiKeyID, today}, + &stats.TodayRequests, + &stats.TodayInputTokens, + &stats.TodayOutputTokens, + &stats.TodayCacheCreationTokens, + &stats.TodayCacheReadTokens, + &stats.TodayCost, + &stats.TodayActualCost, + ); err != nil { + return nil, err + } + stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens + + // 性能指标:RPM 和 TPM(最近5分钟,按 API Key 过滤) + rpm, tpm, err := r.getPerformanceStatsByAPIKey(ctx, apiKeyID) + if err != nil { + return nil, err + } + stats.Rpm = rpm + stats.Tpm = tpm + + return stats, nil +} diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go new file mode 100644 index 0000000000..bb978ddc1a --- /dev/null +++ b/backend/internal/repository/usage_log_repo_insert.go @@ -0,0 +1,1265 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "sync/atomic" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// usageLogInsertArgTypes must stay in the same order as: +// 1. prepareUsageLogInsert().args +// 2. every INSERT/CTE VALUES column list in this file +// 3. execUsageLogInsertNoResult placeholder positions +// 4. scanUsageLog selected column order (via usageLogSelectColumns) +// +// When adding a usage_logs column, update all of those call sites together. +var usageLogInsertArgTypes = [...]string{ + "bigint", // user_id + "bigint", // api_key_id + "bigint", // account_id + "text", // request_id + "text", // model + "text", // requested_model + "text", // upstream_model + "bigint", // group_id + "bigint", // subscription_id + "integer", // input_tokens + "integer", // output_tokens + "integer", // cache_creation_tokens + "integer", // cache_read_tokens + "integer", // cache_creation_5m_tokens + "integer", // cache_creation_1h_tokens + "integer", // image_output_tokens + "numeric", // image_output_cost + "numeric", // input_cost + "numeric", // output_cost + "numeric", // cache_creation_cost + "numeric", // cache_read_cost + "numeric", // total_cost + "numeric", // actual_cost + "numeric", // rate_multiplier + "numeric", // account_rate_multiplier + "smallint", // billing_type + "smallint", // request_type + "boolean", // stream + "boolean", // openai_ws_mode + "integer", // duration_ms + "integer", // first_token_ms + "text", // user_agent + "text", // ip_address + "integer", // image_count + "text", // image_size + "text", // image_input_size + "text", // image_output_size + "text", // image_size_source + "jsonb", // image_size_breakdown + "text", // service_tier + "text", // reasoning_effort + "text", // inbound_endpoint + "text", // upstream_endpoint + "boolean", // cache_ttl_overridden + "bigint", // channel_id + "text", // model_mapping_chain + "text", // billing_tier + "text", // billing_mode + "numeric", // account_stats_cost + "timestamptz", // created_at +} + +const ( + usageLogCreateBatchMaxSize = 64 + usageLogCreateBatchWindow = 3 * time.Millisecond + usageLogCreateBatchQueueCap = 4096 + usageLogCreateCancelWait = 2 * time.Second + + usageLogBestEffortBatchMaxSize = 256 + usageLogBestEffortBatchWindow = 20 * time.Millisecond + usageLogBestEffortBatchQueueCap = 32768 + usageLogBestEffortRecentTTL = 30 * time.Second +) + +type usageLogCreateRequest struct { + log *service.UsageLog + prepared usageLogInsertPrepared + shared *usageLogCreateShared + resultCh chan usageLogCreateResult +} + +type usageLogCreateResult struct { + inserted bool + err error +} + +type usageLogBestEffortRequest struct { + prepared usageLogInsertPrepared + apiKeyID int64 + resultCh chan error +} + +type usageLogInsertPrepared struct { + createdAt time.Time + requestID string + rateMultiplier float64 + requestType int16 + args []any +} + +type usageLogBatchState struct { + ID int64 + CreatedAt time.Time +} + +type usageLogBatchRow struct { + RequestID string `json:"request_id"` + APIKeyID int64 `json:"api_key_id"` + ID int64 `json:"id"` + CreatedAt time.Time `json:"created_at"` + Inserted bool `json:"inserted"` +} + +type usageLogCreateShared struct { + state atomic.Int32 +} + +const ( + usageLogCreateStateQueued int32 = iota + usageLogCreateStateProcessing + usageLogCreateStateCompleted + usageLogCreateStateCanceled +) + +func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) (bool, error) { + if log == nil { + return false, nil + } + + if tx := dbent.TxFromContext(ctx); tx != nil { + return r.createSingle(ctx, tx.Client(), log) + } + requestID := strings.TrimSpace(log.RequestID) + if requestID == "" { + return r.createSingle(ctx, r.sql, log) + } + log.RequestID = requestID + return r.createBatched(ctx, log) +} + +func (r *usageLogRepository) CreateBestEffort(ctx context.Context, log *service.UsageLog) error { + if log == nil { + return nil + } + + if tx := dbent.TxFromContext(ctx); tx != nil { + _, err := r.createSingle(ctx, tx.Client(), log) + return err + } + if r.db == nil { + _, err := r.createSingle(ctx, r.sql, log) + return err + } + + r.ensureBestEffortBatcher() + if r.bestEffortBatchCh == nil { + _, err := r.createSingle(ctx, r.sql, log) + return err + } + + req := usageLogBestEffortRequest{ + prepared: prepareUsageLogInsert(log), + apiKeyID: log.APIKeyID, + resultCh: make(chan error, 1), + } + if key, ok := r.bestEffortRecentKey(req.prepared.requestID, req.apiKeyID); ok { + if _, exists := r.bestEffortRecent.Get(key); exists { + return nil + } + } + + // 队列满时阻塞等待而非立即丢弃:批处理器持续排空队列,短暂等待即可入队。 + // 立即丢弃会造成“已扣费但无 usage_log”的永久数据缺口(issue #3656); + // 阻塞上限由调用方 ctx 期限约束,超时后由上层同步兜底。 + select { + case r.bestEffortBatchCh <- req: + case <-ctx.Done(): + return service.MarkUsageLogCreateDropped(ctx.Err()) + } + + select { + case err := <-req.resultCh: + return err + case <-ctx.Done(): + return service.MarkUsageLogCreateDropped(ctx.Err()) + } +} + +func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, log *service.UsageLog) (bool, error) { + prepared := prepareUsageLogInsert(log) + if sqlq == nil { + sqlq = r.sql + } + if ctx != nil && ctx.Err() != nil { + return false, service.MarkUsageLogCreateNotPersisted(ctx.Err()) + } + + query := ` + INSERT INTO usage_logs ( + user_id, + api_key_id, + account_id, + request_id, + model, + requested_model, + upstream_model, + group_id, + subscription_id, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + cache_creation_5m_tokens, + cache_creation_1h_tokens, + image_output_tokens, + image_output_cost, + input_cost, + output_cost, + cache_creation_cost, + cache_read_cost, + total_cost, + actual_cost, + rate_multiplier, + account_rate_multiplier, + billing_type, + request_type, + stream, + openai_ws_mode, + duration_ms, + first_token_ms, + user_agent, + ip_address, + image_count, + image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, + service_tier, + reasoning_effort, + inbound_endpoint, + upstream_endpoint, + cache_ttl_overridden, + channel_id, + model_mapping_chain, + billing_tier, + billing_mode, + account_stats_cost, + created_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, + $10, $11, $12, $13, + $14, $15, $16, $17, + $18, $19, $20, $21, $22, $23, + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50 + ) + ON CONFLICT (request_id, api_key_id) DO NOTHING + RETURNING id, created_at + ` + + if err := scanSingleRow(ctx, sqlq, query, prepared.args, &log.ID, &log.CreatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) && prepared.requestID != "" { + selectQuery := "SELECT id, created_at FROM usage_logs WHERE request_id = $1 AND api_key_id = $2" + if err := scanSingleRow(ctx, sqlq, selectQuery, []any{prepared.requestID, log.APIKeyID}, &log.ID, &log.CreatedAt); err != nil { + return false, err + } + log.RateMultiplier = prepared.rateMultiplier + return false, nil + } else { + return false, err + } + } + log.RateMultiplier = prepared.rateMultiplier + return true, nil +} + +func (r *usageLogRepository) createBatched(ctx context.Context, log *service.UsageLog) (bool, error) { + if r.db == nil { + return r.createSingle(ctx, r.sql, log) + } + r.ensureCreateBatcher() + if r.createBatchCh == nil { + return r.createSingle(ctx, r.sql, log) + } + + req := usageLogCreateRequest{ + log: log, + prepared: prepareUsageLogInsert(log), + shared: &usageLogCreateShared{}, + resultCh: make(chan usageLogCreateResult, 1), + } + + // 队列满时阻塞等待而非立即报错:本路径是 best-effort 丢弃后的最后兜底, + // 立即失败会让日志永久丢失;阻塞上限由调用方 ctx 期限约束。 + select { + case r.createBatchCh <- req: + case <-ctx.Done(): + return false, service.MarkUsageLogCreateNotPersisted(ctx.Err()) + } + + select { + case res := <-req.resultCh: + return res.inserted, res.err + case <-ctx.Done(): + if req.shared != nil && req.shared.state.CompareAndSwap(usageLogCreateStateQueued, usageLogCreateStateCanceled) { + return false, service.MarkUsageLogCreateNotPersisted(ctx.Err()) + } + timer := time.NewTimer(usageLogCreateCancelWait) + defer timer.Stop() + select { + case res := <-req.resultCh: + return res.inserted, res.err + case <-timer.C: + return false, ctx.Err() + } + } +} + +func (r *usageLogRepository) ensureCreateBatcher() { + if r == nil || r.db == nil { + return + } + // nil 检查必须在 Once 内部:在外层做无同步快路径读会与 Once 内的写构成数据竞争。 + r.createBatchOnce.Do(func() { + if r.createBatchCh == nil { + r.createBatchCh = make(chan usageLogCreateRequest, usageLogCreateBatchQueueCap) + go r.runCreateBatcher(r.db) + } + }) +} + +func (r *usageLogRepository) ensureBestEffortBatcher() { + if r == nil || r.db == nil { + return + } + // 同 ensureCreateBatcher:nil 检查放在 Once 内部以避免数据竞争。 + r.bestEffortBatchOnce.Do(func() { + if r.bestEffortBatchCh == nil { + r.bestEffortBatchCh = make(chan usageLogBestEffortRequest, usageLogBestEffortBatchQueueCap) + go r.runBestEffortBatcher(r.db) + } + }) +} + +func (r *usageLogRepository) runCreateBatcher(db *sql.DB) { + for { + first, ok := <-r.createBatchCh + if !ok { + return + } + + batch := make([]usageLogCreateRequest, 0, usageLogCreateBatchMaxSize) + batch = append(batch, first) + + timer := time.NewTimer(usageLogCreateBatchWindow) + batchLoop: + for len(batch) < usageLogCreateBatchMaxSize { + select { + case req, ok := <-r.createBatchCh: + if !ok { + break batchLoop + } + batch = append(batch, req) + case <-timer.C: + break batchLoop + } + } + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + + r.flushCreateBatch(db, batch) + } +} + +func (r *usageLogRepository) runBestEffortBatcher(db *sql.DB) { + for { + first, ok := <-r.bestEffortBatchCh + if !ok { + return + } + + batch := make([]usageLogBestEffortRequest, 0, usageLogBestEffortBatchMaxSize) + batch = append(batch, first) + + timer := time.NewTimer(usageLogBestEffortBatchWindow) + bestEffortLoop: + for len(batch) < usageLogBestEffortBatchMaxSize { + select { + case req, ok := <-r.bestEffortBatchCh: + if !ok { + break bestEffortLoop + } + batch = append(batch, req) + case <-timer.C: + break bestEffortLoop + } + } + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + + r.flushBestEffortBatch(db, batch) + } +} + +func (r *usageLogRepository) flushCreateBatch(db *sql.DB, batch []usageLogCreateRequest) { + if len(batch) == 0 { + return + } + + uniqueOrder := make([]string, 0, len(batch)) + preparedByKey := make(map[string]usageLogInsertPrepared, len(batch)) + requestsByKey := make(map[string][]usageLogCreateRequest, len(batch)) + fallback := make([]usageLogCreateRequest, 0) + + for _, req := range batch { + if req.log == nil { + completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: false, err: nil}) + continue + } + if req.shared != nil && !req.shared.state.CompareAndSwap(usageLogCreateStateQueued, usageLogCreateStateProcessing) { + if req.shared.state.Load() == usageLogCreateStateCanceled { + completeUsageLogCreateRequest(req, usageLogCreateResult{ + inserted: false, + err: service.MarkUsageLogCreateNotPersisted(context.Canceled), + }) + continue + } + } + prepared := req.prepared + if prepared.requestID == "" { + fallback = append(fallback, req) + continue + } + key := usageLogBatchKey(prepared.requestID, req.log.APIKeyID) + if _, exists := requestsByKey[key]; !exists { + uniqueOrder = append(uniqueOrder, key) + preparedByKey[key] = prepared + } + requestsByKey[key] = append(requestsByKey[key], req) + } + + if len(uniqueOrder) > 0 { + insertedMap, stateMap, safeFallback, err := r.batchInsertUsageLogs(db, uniqueOrder, preparedByKey) + if err != nil { + if safeFallback { + for _, key := range uniqueOrder { + fallback = append(fallback, requestsByKey[key]...) + } + } else { + for _, key := range uniqueOrder { + reqs := requestsByKey[key] + state, hasState := stateMap[key] + inserted := insertedMap[key] + for idx, req := range reqs { + req.log.RateMultiplier = preparedByKey[key].rateMultiplier + if hasState { + req.log.ID = state.ID + req.log.CreatedAt = state.CreatedAt + } + switch { + case inserted && idx == 0: + completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: true, err: nil}) + case inserted: + completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: false, err: nil}) + case hasState: + completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: false, err: nil}) + case idx == 0: + completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: false, err: err}) + default: + completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: false, err: nil}) + } + } + } + } + } else { + for _, key := range uniqueOrder { + reqs := requestsByKey[key] + state, ok := stateMap[key] + if !ok { + for _, req := range reqs { + completeUsageLogCreateRequest(req, usageLogCreateResult{ + inserted: false, + err: fmt.Errorf("usage log batch state missing for key=%s", key), + }) + } + continue + } + for idx, req := range reqs { + req.log.ID = state.ID + req.log.CreatedAt = state.CreatedAt + req.log.RateMultiplier = preparedByKey[key].rateMultiplier + completeUsageLogCreateRequest(req, usageLogCreateResult{ + inserted: idx == 0 && insertedMap[key], + err: nil, + }) + } + } + } + } + + if len(fallback) == 0 { + return + } + + for _, req := range fallback { + fallbackCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + inserted, err := r.createSingle(fallbackCtx, db, req.log) + cancel() + completeUsageLogCreateRequest(req, usageLogCreateResult{inserted: inserted, err: err}) + } +} + +func (r *usageLogRepository) flushBestEffortBatch(db *sql.DB, batch []usageLogBestEffortRequest) { + if len(batch) == 0 { + return + } + + type bestEffortGroup struct { + prepared usageLogInsertPrepared + apiKeyID int64 + key string + reqs []usageLogBestEffortRequest + } + + groupsByKey := make(map[string]*bestEffortGroup, len(batch)) + groupOrder := make([]*bestEffortGroup, 0, len(batch)) + preparedList := make([]usageLogInsertPrepared, 0, len(batch)) + + for idx, req := range batch { + prepared := req.prepared + key := fmt.Sprintf("__best_effort_%d", idx) + if prepared.requestID != "" { + key = usageLogBatchKey(prepared.requestID, req.apiKeyID) + } + group, exists := groupsByKey[key] + if !exists { + group = &bestEffortGroup{ + prepared: prepared, + apiKeyID: req.apiKeyID, + key: key, + } + groupsByKey[key] = group + groupOrder = append(groupOrder, group) + preparedList = append(preparedList, prepared) + } + group.reqs = append(group.reqs, req) + } + + if len(preparedList) == 0 { + for _, req := range batch { + sendUsageLogBestEffortResult(req.resultCh, nil) + } + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + query, args := buildUsageLogBestEffortInsertQuery(preparedList) + if _, err := db.ExecContext(ctx, query, args...); err != nil { + logger.LegacyPrintf("repository.usage_log", "best-effort batch insert failed: %v", err) + for _, group := range groupOrder { + singleErr := execUsageLogInsertNoResult(ctx, db, group.prepared) + if singleErr != nil { + logger.LegacyPrintf("repository.usage_log", "best-effort single fallback insert failed: %v", singleErr) + } else if group.prepared.requestID != "" && r != nil && r.bestEffortRecent != nil { + r.bestEffortRecent.SetDefault(group.key, struct{}{}) + } + for _, req := range group.reqs { + sendUsageLogBestEffortResult(req.resultCh, singleErr) + } + } + return + } + for _, group := range groupOrder { + if group.prepared.requestID != "" && r != nil && r.bestEffortRecent != nil { + r.bestEffortRecent.SetDefault(group.key, struct{}{}) + } + for _, req := range group.reqs { + sendUsageLogBestEffortResult(req.resultCh, nil) + } + } +} + +func sendUsageLogBestEffortResult(ch chan error, err error) { + if ch == nil { + return + } + select { + case ch <- err: + default: + } +} + +func completeUsageLogCreateRequest(req usageLogCreateRequest, res usageLogCreateResult) { + if req.shared != nil { + req.shared.state.Store(usageLogCreateStateCompleted) + } + sendUsageLogCreateResult(req.resultCh, res) +} + +func (r *usageLogRepository) batchInsertUsageLogs(db *sql.DB, keys []string, preparedByKey map[string]usageLogInsertPrepared) (map[string]bool, map[string]usageLogBatchState, bool, error) { + if len(keys) == 0 { + return map[string]bool{}, map[string]usageLogBatchState{}, false, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + query, args := buildUsageLogBatchInsertQuery(keys, preparedByKey) + var payload []byte + if err := db.QueryRowContext(ctx, query, args...).Scan(&payload); err != nil { + return nil, nil, true, err + } + var rows []usageLogBatchRow + if err := json.Unmarshal(payload, &rows); err != nil { + return nil, nil, false, err + } + insertedMap := make(map[string]bool, len(keys)) + stateMap := make(map[string]usageLogBatchState, len(keys)) + for _, row := range rows { + key := usageLogBatchKey(row.RequestID, row.APIKeyID) + insertedMap[key] = row.Inserted + stateMap[key] = usageLogBatchState{ + ID: row.ID, + CreatedAt: row.CreatedAt, + } + } + if len(stateMap) != len(keys) { + return insertedMap, stateMap, false, fmt.Errorf("usage log batch state count mismatch: got=%d want=%d", len(stateMap), len(keys)) + } + return insertedMap, stateMap, false, nil +} + +func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usageLogInsertPrepared) (string, []any) { + var query strings.Builder + _, _ = query.WriteString(` + WITH input ( + input_idx, + user_id, + api_key_id, + account_id, + request_id, + model, + requested_model, + upstream_model, + group_id, + subscription_id, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + cache_creation_5m_tokens, + cache_creation_1h_tokens, + image_output_tokens, + image_output_cost, + input_cost, + output_cost, + cache_creation_cost, + cache_read_cost, + total_cost, + actual_cost, + rate_multiplier, + account_rate_multiplier, + billing_type, + request_type, + stream, + openai_ws_mode, + duration_ms, + first_token_ms, + user_agent, + ip_address, + image_count, + image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, + service_tier, + reasoning_effort, + inbound_endpoint, + upstream_endpoint, + cache_ttl_overridden, + channel_id, + model_mapping_chain, + billing_tier, + billing_mode, + account_stats_cost, + created_at + ) AS (VALUES `) + + args := make([]any, 0, len(keys)*50) + argPos := 1 + for idx, key := range keys { + if idx > 0 { + _, _ = query.WriteString(",") + } + _, _ = query.WriteString("(") + _, _ = query.WriteString("$") + _, _ = query.WriteString(strconv.Itoa(argPos)) + args = append(args, idx) + argPos++ + prepared := preparedByKey[key] + for i := 0; i < len(prepared.args); i++ { + _, _ = query.WriteString(",") + _, _ = query.WriteString("$") + _, _ = query.WriteString(strconv.Itoa(argPos)) + if i < len(usageLogInsertArgTypes) { + _, _ = query.WriteString("::") + _, _ = query.WriteString(usageLogInsertArgTypes[i]) + } + argPos++ + } + _, _ = query.WriteString(")") + args = append(args, prepared.args...) + } + _, _ = query.WriteString(` + ), + inserted AS ( + INSERT INTO usage_logs ( + user_id, + api_key_id, + account_id, + request_id, + model, + requested_model, + upstream_model, + group_id, + subscription_id, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + cache_creation_5m_tokens, + cache_creation_1h_tokens, + image_output_tokens, + image_output_cost, + input_cost, + output_cost, + cache_creation_cost, + cache_read_cost, + total_cost, + actual_cost, + rate_multiplier, + account_rate_multiplier, + billing_type, + request_type, + stream, + openai_ws_mode, + duration_ms, + first_token_ms, + user_agent, + ip_address, + image_count, + image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, + service_tier, + reasoning_effort, + inbound_endpoint, + upstream_endpoint, + cache_ttl_overridden, + channel_id, + model_mapping_chain, + billing_tier, + billing_mode, + account_stats_cost, + created_at + ) + SELECT + user_id, + api_key_id, + account_id, + request_id, + model, + requested_model, + upstream_model, + group_id, + subscription_id, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + cache_creation_5m_tokens, + cache_creation_1h_tokens, + image_output_tokens, + image_output_cost, + input_cost, + output_cost, + cache_creation_cost, + cache_read_cost, + total_cost, + actual_cost, + rate_multiplier, + account_rate_multiplier, + billing_type, + request_type, + stream, + openai_ws_mode, + duration_ms, + first_token_ms, + user_agent, + ip_address, + image_count, + image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, + service_tier, + reasoning_effort, + inbound_endpoint, + upstream_endpoint, + cache_ttl_overridden, + channel_id, + model_mapping_chain, + billing_tier, + billing_mode, + account_stats_cost, + created_at + FROM input + ON CONFLICT (request_id, api_key_id) DO NOTHING + RETURNING request_id, api_key_id, id, created_at + ), + resolved AS ( + SELECT + input.input_idx, + input.request_id, + input.api_key_id, + COALESCE(inserted.id, existing.id) AS id, + COALESCE(inserted.created_at, existing.created_at) AS created_at, + (inserted.id IS NOT NULL) AS inserted + FROM input + LEFT JOIN inserted + ON inserted.request_id = input.request_id + AND inserted.api_key_id = input.api_key_id + LEFT JOIN usage_logs existing + ON existing.request_id = input.request_id + AND existing.api_key_id = input.api_key_id + ) + SELECT COALESCE( + json_agg( + json_build_object( + 'request_id', resolved.request_id, + 'api_key_id', resolved.api_key_id, + 'id', resolved.id, + 'created_at', resolved.created_at, + 'inserted', resolved.inserted + ) + ORDER BY resolved.input_idx + ), + '[]'::json + ) + FROM resolved + `) + return query.String(), args +} + +func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (string, []any) { + var query strings.Builder + _, _ = query.WriteString(` + WITH input ( + user_id, + api_key_id, + account_id, + request_id, + model, + requested_model, + upstream_model, + group_id, + subscription_id, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + cache_creation_5m_tokens, + cache_creation_1h_tokens, + image_output_tokens, + image_output_cost, + input_cost, + output_cost, + cache_creation_cost, + cache_read_cost, + total_cost, + actual_cost, + rate_multiplier, + account_rate_multiplier, + billing_type, + request_type, + stream, + openai_ws_mode, + duration_ms, + first_token_ms, + user_agent, + ip_address, + image_count, + image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, + service_tier, + reasoning_effort, + inbound_endpoint, + upstream_endpoint, + cache_ttl_overridden, + channel_id, + model_mapping_chain, + billing_tier, + billing_mode, + account_stats_cost, + created_at + ) AS (VALUES `) + + args := make([]any, 0, len(preparedList)*50) + argPos := 1 + for idx, prepared := range preparedList { + if idx > 0 { + _, _ = query.WriteString(",") + } + _, _ = query.WriteString("(") + for i := 0; i < len(prepared.args); i++ { + if i > 0 { + _, _ = query.WriteString(",") + } + _, _ = query.WriteString("$") + _, _ = query.WriteString(strconv.Itoa(argPos)) + if i < len(usageLogInsertArgTypes) { + _, _ = query.WriteString("::") + _, _ = query.WriteString(usageLogInsertArgTypes[i]) + } + argPos++ + } + _, _ = query.WriteString(")") + args = append(args, prepared.args...) + } + + _, _ = query.WriteString(` + ) + INSERT INTO usage_logs ( + user_id, + api_key_id, + account_id, + request_id, + model, + requested_model, + upstream_model, + group_id, + subscription_id, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + cache_creation_5m_tokens, + cache_creation_1h_tokens, + image_output_tokens, + image_output_cost, + input_cost, + output_cost, + cache_creation_cost, + cache_read_cost, + total_cost, + actual_cost, + rate_multiplier, + account_rate_multiplier, + billing_type, + request_type, + stream, + openai_ws_mode, + duration_ms, + first_token_ms, + user_agent, + ip_address, + image_count, + image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, + service_tier, + reasoning_effort, + inbound_endpoint, + upstream_endpoint, + cache_ttl_overridden, + channel_id, + model_mapping_chain, + billing_tier, + billing_mode, + account_stats_cost, + created_at + ) + SELECT + user_id, + api_key_id, + account_id, + request_id, + model, + requested_model, + upstream_model, + group_id, + subscription_id, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + cache_creation_5m_tokens, + cache_creation_1h_tokens, + image_output_tokens, + image_output_cost, + input_cost, + output_cost, + cache_creation_cost, + cache_read_cost, + total_cost, + actual_cost, + rate_multiplier, + account_rate_multiplier, + billing_type, + request_type, + stream, + openai_ws_mode, + duration_ms, + first_token_ms, + user_agent, + ip_address, + image_count, + image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, + service_tier, + reasoning_effort, + inbound_endpoint, + upstream_endpoint, + cache_ttl_overridden, + channel_id, + model_mapping_chain, + billing_tier, + billing_mode, + account_stats_cost, + created_at + FROM input + ON CONFLICT (request_id, api_key_id) DO NOTHING + `) + + return query.String(), args +} + +func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared usageLogInsertPrepared) error { + _, err := sqlq.ExecContext(ctx, ` + INSERT INTO usage_logs ( + user_id, + api_key_id, + account_id, + request_id, + model, + requested_model, + upstream_model, + group_id, + subscription_id, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + cache_creation_5m_tokens, + cache_creation_1h_tokens, + image_output_tokens, + image_output_cost, + input_cost, + output_cost, + cache_creation_cost, + cache_read_cost, + total_cost, + actual_cost, + rate_multiplier, + account_rate_multiplier, + billing_type, + request_type, + stream, + openai_ws_mode, + duration_ms, + first_token_ms, + user_agent, + ip_address, + image_count, + image_size, + image_input_size, + image_output_size, + image_size_source, + image_size_breakdown, + service_tier, + reasoning_effort, + inbound_endpoint, + upstream_endpoint, + cache_ttl_overridden, + channel_id, + model_mapping_chain, + billing_tier, + billing_mode, + account_stats_cost, + created_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, + $10, $11, $12, $13, + $14, $15, $16, $17, + $18, $19, $20, $21, $22, $23, + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50 + ) + ON CONFLICT (request_id, api_key_id) DO NOTHING + `, prepared.args...) + return err +} + +func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { + createdAt := log.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now() + } + + requestID := strings.TrimSpace(log.RequestID) + log.RequestID = requestID + + rateMultiplier := log.RateMultiplier + log.SyncRequestTypeAndLegacyFields() + requestType := int16(log.RequestType) + + groupID := nullInt64(log.GroupID) + subscriptionID := nullInt64(log.SubscriptionID) + duration := nullInt(log.DurationMs) + firstToken := nullInt(log.FirstTokenMs) + userAgent := nullString(log.UserAgent) + ipAddress := nullString(log.IPAddress) + imageSize := nullString(log.ImageSize) + imageInputSize := nullString(log.ImageInputSize) + imageOutputSize := nullString(log.ImageOutputSize) + imageSizeSource := nullString(log.ImageSizeSource) + imageSizeBreakdown := nullStringIntMapJSON(log.ImageSizeBreakdown) + serviceTier := nullString(log.ServiceTier) + reasoningEffort := nullString(log.ReasoningEffort) + inboundEndpoint := nullString(log.InboundEndpoint) + upstreamEndpoint := nullString(log.UpstreamEndpoint) + channelID := nullInt64(log.ChannelID) + modelMappingChain := nullString(log.ModelMappingChain) + billingTier := nullString(log.BillingTier) + billingMode := nullString(log.BillingMode) + requestedModel := strings.TrimSpace(log.RequestedModel) + if requestedModel == "" { + requestedModel = strings.TrimSpace(log.Model) + } + upstreamModel := nullString(log.UpstreamModel) + + var requestIDArg any + if requestID != "" { + requestIDArg = requestID + } + + return usageLogInsertPrepared{ + createdAt: createdAt, + requestID: requestID, + rateMultiplier: rateMultiplier, + requestType: requestType, + args: []any{ + log.UserID, + log.APIKeyID, + log.AccountID, + requestIDArg, + log.Model, + nullString(&requestedModel), + upstreamModel, + groupID, + subscriptionID, + log.InputTokens, + log.OutputTokens, + log.CacheCreationTokens, + log.CacheReadTokens, + log.CacheCreation5mTokens, + log.CacheCreation1hTokens, + log.ImageOutputTokens, + log.ImageOutputCost, + log.InputCost, + log.OutputCost, + log.CacheCreationCost, + log.CacheReadCost, + log.TotalCost, + log.ActualCost, + rateMultiplier, + log.AccountRateMultiplier, + log.BillingType, + requestType, + log.Stream, + log.OpenAIWSMode, + duration, + firstToken, + userAgent, + ipAddress, + log.ImageCount, + imageSize, + imageInputSize, + imageOutputSize, + imageSizeSource, + imageSizeBreakdown, + serviceTier, + reasoningEffort, + inboundEndpoint, + upstreamEndpoint, + log.CacheTTLOverridden, + channelID, + modelMappingChain, + billingTier, + billingMode, + log.AccountStatsCost, // account_stats_cost + createdAt, + }, + } +} + +func usageLogBatchKey(requestID string, apiKeyID int64) string { + return requestID + "\x1f" + strconv.FormatInt(apiKeyID, 10) +} + +func sendUsageLogCreateResult(ch chan usageLogCreateResult, res usageLogCreateResult) { + if ch == nil { + return + } + select { + case ch <- res: + default: + } +} + +func (r *usageLogRepository) bestEffortRecentKey(requestID string, apiKeyID int64) (string, bool) { + requestID = strings.TrimSpace(requestID) + if requestID == "" || r == nil || r.bestEffortRecent == nil { + return "", false + } + return usageLogBatchKey(requestID, apiKeyID), true +} diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go new file mode 100644 index 0000000000..0ff4aeb936 --- /dev/null +++ b/backend/internal/repository/usage_log_repo_query.go @@ -0,0 +1,712 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + dbaccount "github.com/Wei-Shaw/sub2api/ent/account" + dbapikey "github.com/Wei-Shaw/sub2api/ent/apikey" + dbgroup "github.com/Wei-Shaw/sub2api/ent/group" + "github.com/Wei-Shaw/sub2api/ent/schema/mixins" + dbuser "github.com/Wei-Shaw/sub2api/ent/user" + dbusersub "github.com/Wei-Shaw/sub2api/ent/usersubscription" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at" + +func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { + query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" + rows, err := r.sql.QueryContext(ctx, query, id) + if err != nil { + return nil, err + } + defer func() { + // 保持主错误优先;仅在无错误时回传 Close 失败。 + // 同时清空返回值,避免误用不完整结果。 + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + log = nil + } + }() + if !rows.Next() { + if err = rows.Err(); err != nil { + return nil, err + } + return nil, service.ErrUsageLogNotFound + } + log, err = scanUsageLog(rows) + if err != nil { + return nil, err + } + if err = rows.Err(); err != nil { + return nil, err + } + return log, nil +} + +func (r *usageLogRepository) ListByUser(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { + return r.listUsageLogsWithPagination(ctx, "WHERE user_id = $1", []any{userID}, params) +} + +func (r *usageLogRepository) ListByAPIKey(ctx context.Context, apiKeyID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { + return r.listUsageLogsWithPagination(ctx, "WHERE api_key_id = $1", []any{apiKeyID}, params) +} + +func (r *usageLogRepository) ListByAccount(ctx context.Context, accountID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { + return r.listUsageLogsWithPagination(ctx, "WHERE account_id = $1", []any{accountID}, params) +} + +func (r *usageLogRepository) ListByUserAndTimeRange(ctx context.Context, userID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { + query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000" + logs, err := r.queryUsageLogs(ctx, query, userID, startTime, endTime) + return logs, nil, err +} + +func (r *usageLogRepository) ListByAPIKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { + query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE api_key_id = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000" + logs, err := r.queryUsageLogs(ctx, query, apiKeyID, startTime, endTime) + return logs, nil, err +} + +func (r *usageLogRepository) ListByAccountAndTimeRange(ctx context.Context, accountID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { + query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE account_id = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000" + logs, err := r.queryUsageLogs(ctx, query, accountID, startTime, endTime) + return logs, nil, err +} + +func (r *usageLogRepository) ListByModelAndTimeRange(ctx context.Context, modelName string, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { + query := fmt.Sprintf("SELECT %s FROM usage_logs WHERE %s = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000", usageLogSelectColumns, rawUsageLogModelColumn) + logs, err := r.queryUsageLogs(ctx, query, modelName, startTime, endTime) + return logs, nil, err +} + +func (r *usageLogRepository) Delete(ctx context.Context, id int64) error { + _, err := r.sql.ExecContext(ctx, "DELETE FROM usage_logs WHERE id = $1", id) + return err +} + +// UsageLogFilters represents filters for usage log queries +type UsageLogFilters = usagestats.UsageLogFilters + +// ListWithFilters lists usage logs with optional filters (for admin) +func (r *usageLogRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) { + conditions := make([]string, 0, 9) + args := make([]any, 0, 9) + + if filters.UserID > 0 { + conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1)) + args = append(args, filters.UserID) + } + if filters.APIKeyID > 0 { + conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1)) + args = append(args, filters.APIKeyID) + } + if filters.AccountID > 0 { + conditions = append(conditions, fmt.Sprintf("account_id = $%d", len(args)+1)) + args = append(args, filters.AccountID) + } + if filters.GroupID > 0 { + conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1)) + args = append(args, filters.GroupID) + } + conditions, args = appendUsageLogModelWhereCondition(conditions, args, filters.Model, filters.ModelFilterSource) + conditions, args = appendRequestTypeOrStreamWhereCondition(conditions, args, filters.RequestType, filters.Stream) + if filters.BillingType != nil { + conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1)) + args = append(args, int16(*filters.BillingType)) + } + conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode) + if filters.StartTime != nil { + conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) + args = append(args, *filters.StartTime) + } + if filters.EndTime != nil { + conditions = append(conditions, fmt.Sprintf("created_at < $%d", len(args)+1)) + args = append(args, *filters.EndTime) + } + + whereClause := buildWhere(conditions) + var ( + logs []service.UsageLog + page *pagination.PaginationResult + err error + ) + if shouldUseFastUsageLogTotal(filters) { + logs, page, err = r.listUsageLogsWithFastPagination(ctx, whereClause, args, params) + } else { + logs, page, err = r.listUsageLogsWithPagination(ctx, whereClause, args, params) + } + if err != nil { + return nil, nil, err + } + + if err := r.hydrateUsageLogAssociations(ctx, logs); err != nil { + return nil, nil, err + } + return logs, page, nil +} + +func shouldUseFastUsageLogTotal(filters UsageLogFilters) bool { + if filters.ExactTotal { + return false + } + // 强选择过滤下记录集通常较小,保留精确总数。 + return filters.UserID == 0 && filters.APIKeyID == 0 && filters.AccountID == 0 +} + +func (r *usageLogRepository) listUsageLogsWithPagination(ctx context.Context, whereClause string, args []any, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { + countQuery := "SELECT COUNT(*) FROM usage_logs " + whereClause + var total int64 + if err := scanSingleRow(ctx, r.sql, countQuery, args, &total); err != nil { + return nil, nil, err + } + + limitPos := len(args) + 1 + offsetPos := len(args) + 2 + listArgs := append(append([]any{}, args...), params.Limit(), params.Offset()) + query := fmt.Sprintf("SELECT %s FROM usage_logs %s ORDER BY %s LIMIT $%d OFFSET $%d", usageLogSelectColumns, whereClause, usageLogOrderBy(params), limitPos, offsetPos) + logs, err := r.queryUsageLogs(ctx, query, listArgs...) + if err != nil { + return nil, nil, err + } + return logs, paginationResultFromTotal(total, params), nil +} + +func (r *usageLogRepository) listUsageLogsWithFastPagination(ctx context.Context, whereClause string, args []any, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { + limit := params.Limit() + offset := params.Offset() + + limitPos := len(args) + 1 + offsetPos := len(args) + 2 + listArgs := append(append([]any{}, args...), limit+1, offset) + query := fmt.Sprintf("SELECT %s FROM usage_logs %s ORDER BY %s LIMIT $%d OFFSET $%d", usageLogSelectColumns, whereClause, usageLogOrderBy(params), limitPos, offsetPos) + + logs, err := r.queryUsageLogs(ctx, query, listArgs...) + if err != nil { + return nil, nil, err + } + + hasMore := false + if len(logs) > limit { + hasMore = true + logs = logs[:limit] + } + + total := int64(offset) + int64(len(logs)) + if hasMore { + // 只保证“还有下一页”,避免对超大表做全量 COUNT(*)。 + total = int64(offset) + int64(limit) + 1 + } + + return logs, paginationResultFromTotal(total, params), nil +} + +func usageLogOrderBy(params pagination.PaginationParams) string { + sortBy := strings.ToLower(strings.TrimSpace(params.SortBy)) + sortOrder := strings.ToUpper(params.NormalizedSortOrder(pagination.SortOrderDesc)) + + var column string + switch sortBy { + case "model": + column = "COALESCE(NULLIF(TRIM(requested_model), ''), model)" + case "created_at": + column = "created_at" + default: + column = "id" + } + + if column == "id" { + return fmt.Sprintf("id %s", sortOrder) + } + return fmt.Sprintf("%s %s, id %s", column, sortOrder, sortOrder) +} + +func (r *usageLogRepository) queryUsageLogs(ctx context.Context, query string, args ...any) (logs []service.UsageLog, err error) { + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { + // 保持主错误优先;仅在无错误时回传 Close 失败。 + // 同时清空返回值,避免误用不完整结果。 + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + logs = nil + } + }() + + logs = make([]service.UsageLog, 0) + for rows.Next() { + var log *service.UsageLog + log, err = scanUsageLog(rows) + if err != nil { + return nil, err + } + logs = append(logs, *log) + } + if err = rows.Err(); err != nil { + return nil, err + } + return logs, nil +} + +func (r *usageLogRepository) hydrateUsageLogAssociations(ctx context.Context, logs []service.UsageLog) error { + // 关联数据使用 Ent 批量加载,避免把复杂 SQL 继续膨胀。 + if len(logs) == 0 { + return nil + } + + ids := collectUsageLogIDs(logs) + users, err := r.loadUsers(ctx, ids.userIDs) + if err != nil { + return err + } + apiKeys, err := r.loadAPIKeys(ctx, ids.apiKeyIDs) + if err != nil { + return err + } + accounts, err := r.loadAccounts(ctx, ids.accountIDs) + if err != nil { + return err + } + groups, err := r.loadGroups(ctx, ids.groupIDs) + if err != nil { + return err + } + subs, err := r.loadSubscriptions(ctx, ids.subscriptionIDs) + if err != nil { + return err + } + + for i := range logs { + if user, ok := users[logs[i].UserID]; ok { + logs[i].User = user + } + if key, ok := apiKeys[logs[i].APIKeyID]; ok { + logs[i].APIKey = key + } + if acc, ok := accounts[logs[i].AccountID]; ok { + logs[i].Account = acc + } + if logs[i].GroupID != nil { + if group, ok := groups[*logs[i].GroupID]; ok { + logs[i].Group = group + } + } + if logs[i].SubscriptionID != nil { + if sub, ok := subs[*logs[i].SubscriptionID]; ok { + logs[i].Subscription = sub + } + } + } + return nil +} + +type usageLogIDs struct { + userIDs []int64 + apiKeyIDs []int64 + accountIDs []int64 + groupIDs []int64 + subscriptionIDs []int64 +} + +func collectUsageLogIDs(logs []service.UsageLog) usageLogIDs { + idSet := func() map[int64]struct{} { return make(map[int64]struct{}) } + + userIDs := idSet() + apiKeyIDs := idSet() + accountIDs := idSet() + groupIDs := idSet() + subscriptionIDs := idSet() + + for i := range logs { + userIDs[logs[i].UserID] = struct{}{} + apiKeyIDs[logs[i].APIKeyID] = struct{}{} + accountIDs[logs[i].AccountID] = struct{}{} + if logs[i].GroupID != nil { + groupIDs[*logs[i].GroupID] = struct{}{} + } + if logs[i].SubscriptionID != nil { + subscriptionIDs[*logs[i].SubscriptionID] = struct{}{} + } + } + + return usageLogIDs{ + userIDs: setToSlice(userIDs), + apiKeyIDs: setToSlice(apiKeyIDs), + accountIDs: setToSlice(accountIDs), + groupIDs: setToSlice(groupIDs), + subscriptionIDs: setToSlice(subscriptionIDs), + } +} + +func (r *usageLogRepository) loadUsers(ctx context.Context, ids []int64) (map[int64]*service.User, error) { + out := make(map[int64]*service.User) + if len(ids) == 0 { + return out, nil + } + // 无条件穿透软删除:ids 来自调用方已按 user_id 筛选的日志行;普通用户路径强制 UserID=本人(本人必为活跃用户),不会借此解析他人已删身份;仅 admin 路径可借此显示已删用户。 + models, err := r.client.User.Query().Where(dbuser.IDIn(ids...)).All(mixins.SkipSoftDelete(ctx)) + if err != nil { + return nil, err + } + for _, m := range models { + out[m.ID] = userEntityToService(m) + } + return out, nil +} + +func (r *usageLogRepository) loadAPIKeys(ctx context.Context, ids []int64) (map[int64]*service.APIKey, error) { + out := make(map[int64]*service.APIKey) + if len(ids) == 0 { + return out, nil + } + models, err := r.client.APIKey.Query().Where(dbapikey.IDIn(ids...)).All(ctx) + if err != nil { + return nil, err + } + for _, m := range models { + out[m.ID] = apiKeyEntityToService(m) + } + return out, nil +} + +func (r *usageLogRepository) loadAccounts(ctx context.Context, ids []int64) (map[int64]*service.Account, error) { + out := make(map[int64]*service.Account) + if len(ids) == 0 { + return out, nil + } + models, err := r.client.Account.Query().Where(dbaccount.IDIn(ids...)).All(ctx) + if err != nil { + return nil, err + } + for _, m := range models { + out[m.ID] = accountEntityToService(m) + } + return out, nil +} + +func (r *usageLogRepository) loadGroups(ctx context.Context, ids []int64) (map[int64]*service.Group, error) { + out := make(map[int64]*service.Group) + if len(ids) == 0 { + return out, nil + } + models, err := r.client.Group.Query().Where(dbgroup.IDIn(ids...)).All(ctx) + if err != nil { + return nil, err + } + for _, m := range models { + out[m.ID] = groupEntityToService(m) + } + return out, nil +} + +func (r *usageLogRepository) loadSubscriptions(ctx context.Context, ids []int64) (map[int64]*service.UserSubscription, error) { + out := make(map[int64]*service.UserSubscription) + if len(ids) == 0 { + return out, nil + } + models, err := r.client.UserSubscription.Query().Where(dbusersub.IDIn(ids...)).All(ctx) + if err != nil { + return nil, err + } + for _, m := range models { + out[m.ID] = userSubscriptionEntityToService(m) + } + return out, nil +} + +func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, error) { + var ( + id int64 + userID int64 + apiKeyID int64 + accountID int64 + requestID sql.NullString + model string + requestedModel sql.NullString + upstreamModel sql.NullString + groupID sql.NullInt64 + subscriptionID sql.NullInt64 + inputTokens int + outputTokens int + cacheCreationTokens int + cacheReadTokens int + cacheCreation5m int + cacheCreation1h int + imageOutputTokens int + imageOutputCost float64 + inputCost float64 + outputCost float64 + cacheCreationCost float64 + cacheReadCost float64 + totalCost float64 + actualCost float64 + rateMultiplier float64 + accountRateMultiplier sql.NullFloat64 + billingType int16 + requestTypeRaw int16 + stream bool + openaiWSMode bool + durationMs sql.NullInt64 + firstTokenMs sql.NullInt64 + userAgent sql.NullString + ipAddress sql.NullString + imageCount int + imageSize sql.NullString + imageInputSize sql.NullString + imageOutputSize sql.NullString + imageSizeSource sql.NullString + imageSizeBreakdown sql.NullString + serviceTier sql.NullString + reasoningEffort sql.NullString + inboundEndpoint sql.NullString + upstreamEndpoint sql.NullString + cacheTTLOverridden bool + channelID sql.NullInt64 + modelMappingChain sql.NullString + billingTier sql.NullString + billingMode sql.NullString + accountStatsCost sql.NullFloat64 + createdAt time.Time + ) + + if err := scanner.Scan( + &id, + &userID, + &apiKeyID, + &accountID, + &requestID, + &model, + &requestedModel, + &upstreamModel, + &groupID, + &subscriptionID, + &inputTokens, + &outputTokens, + &cacheCreationTokens, + &cacheReadTokens, + &cacheCreation5m, + &cacheCreation1h, + &imageOutputTokens, + &imageOutputCost, + &inputCost, + &outputCost, + &cacheCreationCost, + &cacheReadCost, + &totalCost, + &actualCost, + &rateMultiplier, + &accountRateMultiplier, + &billingType, + &requestTypeRaw, + &stream, + &openaiWSMode, + &durationMs, + &firstTokenMs, + &userAgent, + &ipAddress, + &imageCount, + &imageSize, + &imageInputSize, + &imageOutputSize, + &imageSizeSource, + &imageSizeBreakdown, + &serviceTier, + &reasoningEffort, + &inboundEndpoint, + &upstreamEndpoint, + &cacheTTLOverridden, + &channelID, + &modelMappingChain, + &billingTier, + &billingMode, + &accountStatsCost, + &createdAt, + ); err != nil { + return nil, err + } + + log := &service.UsageLog{ + ID: id, + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + Model: model, + RequestedModel: coalesceTrimmedString(requestedModel, model), + InputTokens: inputTokens, + OutputTokens: outputTokens, + CacheCreationTokens: cacheCreationTokens, + CacheReadTokens: cacheReadTokens, + CacheCreation5mTokens: cacheCreation5m, + CacheCreation1hTokens: cacheCreation1h, + ImageOutputTokens: imageOutputTokens, + ImageOutputCost: imageOutputCost, + InputCost: inputCost, + OutputCost: outputCost, + CacheCreationCost: cacheCreationCost, + CacheReadCost: cacheReadCost, + TotalCost: totalCost, + ActualCost: actualCost, + RateMultiplier: rateMultiplier, + AccountRateMultiplier: nullFloat64Ptr(accountRateMultiplier), + BillingType: int8(billingType), + RequestType: service.RequestTypeFromInt16(requestTypeRaw), + ImageCount: imageCount, + CacheTTLOverridden: cacheTTLOverridden, + CreatedAt: createdAt, + } + // 先回填 legacy 字段,再基于 legacy + request_type 计算最终请求类型,保证历史数据兼容。 + log.Stream = stream + log.OpenAIWSMode = openaiWSMode + log.RequestType = log.EffectiveRequestType() + log.Stream, log.OpenAIWSMode = service.ApplyLegacyRequestFields(log.RequestType, stream, openaiWSMode) + + if requestID.Valid { + log.RequestID = requestID.String + } + if groupID.Valid { + value := groupID.Int64 + log.GroupID = &value + } + if subscriptionID.Valid { + value := subscriptionID.Int64 + log.SubscriptionID = &value + } + if durationMs.Valid { + value := int(durationMs.Int64) + log.DurationMs = &value + } + if firstTokenMs.Valid { + value := int(firstTokenMs.Int64) + log.FirstTokenMs = &value + } + if userAgent.Valid { + log.UserAgent = &userAgent.String + } + if ipAddress.Valid { + log.IPAddress = &ipAddress.String + } + if imageSize.Valid { + log.ImageSize = &imageSize.String + } + if imageInputSize.Valid { + log.ImageInputSize = &imageInputSize.String + } + if imageOutputSize.Valid { + log.ImageOutputSize = &imageOutputSize.String + } + if imageSizeSource.Valid { + log.ImageSizeSource = &imageSizeSource.String + } + log.ImageSizeBreakdown = stringIntMapFromNullJSON(imageSizeBreakdown) + if serviceTier.Valid { + log.ServiceTier = &serviceTier.String + } + if reasoningEffort.Valid { + log.ReasoningEffort = &reasoningEffort.String + } + if inboundEndpoint.Valid { + log.InboundEndpoint = &inboundEndpoint.String + } + if upstreamEndpoint.Valid { + log.UpstreamEndpoint = &upstreamEndpoint.String + } + if upstreamModel.Valid { + log.UpstreamModel = &upstreamModel.String + } + if channelID.Valid { + value := channelID.Int64 + log.ChannelID = &value + } + if modelMappingChain.Valid { + log.ModelMappingChain = &modelMappingChain.String + } + if billingTier.Valid { + log.BillingTier = &billingTier.String + } + if billingMode.Valid { + log.BillingMode = &billingMode.String + } + if accountStatsCost.Valid { + log.AccountStatsCost = &accountStatsCost.Float64 + } + + return log, nil +} + +func nullInt64(v *int64) sql.NullInt64 { + if v == nil { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: *v, Valid: true} +} + +func nullInt(v *int) sql.NullInt64 { + if v == nil { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: int64(*v), Valid: true} +} + +func nullFloat64Ptr(v sql.NullFloat64) *float64 { + if !v.Valid { + return nil + } + out := v.Float64 + return &out +} + +func nullString(v *string) sql.NullString { + if v == nil || *v == "" { + return sql.NullString{} + } + return sql.NullString{String: *v, Valid: true} +} + +func nullStringIntMapJSON(v map[string]int) any { + if len(v) == 0 { + return nil + } + payload, err := json.Marshal(v) + if err != nil { + return nil + } + return string(payload) +} + +func stringIntMapFromNullJSON(v sql.NullString) map[string]int { + if !v.Valid || strings.TrimSpace(v.String) == "" { + return nil + } + var out map[string]int + if err := json.Unmarshal([]byte(v.String), &out); err != nil { + return nil + } + if len(out) == 0 { + return nil + } + return out +} + +func coalesceTrimmedString(v sql.NullString, fallback string) string { + if v.Valid && strings.TrimSpace(v.String) != "" { + return v.String + } + return fallback +} + +func setToSlice(set map[int64]struct{}) []int64 { + out := make([]int64, 0, len(set)) + for id := range set { + out = append(out, id) + } + return out +} diff --git a/backend/internal/repository/usage_log_repo_stats.go b/backend/internal/repository/usage_log_repo_stats.go new file mode 100644 index 0000000000..aeccd49b30 --- /dev/null +++ b/backend/internal/repository/usage_log_repo_stats.go @@ -0,0 +1,1145 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" + "golang.org/x/sync/errgroup" +) + +// GetUserStatsAggregated returns aggregated usage statistics for a user using database-level aggregation +func (r *usageLogRepository) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { + query := ` + SELECT + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms + FROM usage_logs + WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 + ` + + var stats usagestats.UsageStats + if err := scanSingleRow( + ctx, + r.sql, + query, + []any{userID, startTime, endTime}, + &stats.TotalRequests, + &stats.TotalInputTokens, + &stats.TotalOutputTokens, + &stats.TotalCacheTokens, + &stats.TotalCacheCreationTokens, + &stats.TotalCacheReadTokens, + &stats.TotalCost, + &stats.TotalActualCost, + &stats.AverageDurationMs, + ); err != nil { + return nil, err + } + stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens + return &stats, nil +} + +// GetAPIKeyStatsAggregated returns aggregated usage statistics for an API key using database-level aggregation +func (r *usageLogRepository) GetAPIKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { + query := ` + SELECT + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms + FROM usage_logs + WHERE api_key_id = $1 AND created_at >= $2 AND created_at < $3 + ` + + var stats usagestats.UsageStats + if err := scanSingleRow( + ctx, + r.sql, + query, + []any{apiKeyID, startTime, endTime}, + &stats.TotalRequests, + &stats.TotalInputTokens, + &stats.TotalOutputTokens, + &stats.TotalCacheTokens, + &stats.TotalCacheCreationTokens, + &stats.TotalCacheReadTokens, + &stats.TotalCost, + &stats.TotalActualCost, + &stats.AverageDurationMs, + ); err != nil { + return nil, err + } + stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens + return &stats, nil +} + +// GetAccountStatsAggregated 使用 SQL 聚合统计账号使用数据 +// +// 性能优化说明: +// 原实现先查询所有日志记录,再在应用层循环计算统计值: +// 1. 需要传输大量数据到应用层 +// 2. 应用层循环计算增加 CPU 和内存开销 +// +// 新实现使用 SQL 聚合函数: +// 1. 在数据库层完成 COUNT/SUM/AVG 计算 +// 2. 只返回单行聚合结果,大幅减少数据传输量 +// 3. 利用数据库索引优化聚合查询性能 +func (r *usageLogRepository) GetAccountStatsAggregated(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { + query := ` + SELECT + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms + FROM usage_logs + WHERE account_id = $1 AND created_at >= $2 AND created_at < $3 + ` + + var stats usagestats.UsageStats + if err := scanSingleRow( + ctx, + r.sql, + query, + []any{accountID, startTime, endTime}, + &stats.TotalRequests, + &stats.TotalInputTokens, + &stats.TotalOutputTokens, + &stats.TotalCacheTokens, + &stats.TotalCacheCreationTokens, + &stats.TotalCacheReadTokens, + &stats.TotalCost, + &stats.TotalActualCost, + &stats.AverageDurationMs, + ); err != nil { + return nil, err + } + stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens + return &stats, nil +} + +// GetModelStatsAggregated 使用 SQL 聚合统计模型使用数据 +// 性能优化:数据库层聚合计算,避免应用层循环统计 +func (r *usageLogRepository) GetModelStatsAggregated(ctx context.Context, modelName string, startTime, endTime time.Time) (*usagestats.UsageStats, error) { + query := fmt.Sprintf(` + SELECT + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms + FROM usage_logs + WHERE %s = $1 AND created_at >= $2 AND created_at < $3 + `, rawUsageLogModelColumn) + + var stats usagestats.UsageStats + if err := scanSingleRow( + ctx, + r.sql, + query, + []any{modelName, startTime, endTime}, + &stats.TotalRequests, + &stats.TotalInputTokens, + &stats.TotalOutputTokens, + &stats.TotalCacheTokens, + &stats.TotalCacheCreationTokens, + &stats.TotalCacheReadTokens, + &stats.TotalCost, + &stats.TotalActualCost, + &stats.AverageDurationMs, + ); err != nil { + return nil, err + } + stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens + return &stats, nil +} + +// GetDailyStatsAggregated 使用 SQL 聚合统计用户的每日使用数据 +// 性能优化:使用 GROUP BY 在数据库层按日期分组聚合,避免应用层循环分组统计 +func (r *usageLogRepository) GetDailyStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (result []map[string]any, err error) { + tzName := resolveUsageStatsTimezone() + query := ` + SELECT + -- 使用应用时区分组,避免数据库会话时区导致日边界偏移。 + TO_CHAR(created_at AT TIME ZONE $4, 'YYYY-MM-DD') as date, + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms + FROM usage_logs + WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 + GROUP BY 1 + ORDER BY 1 + ` + + rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime, tzName) + if err != nil { + return nil, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + result = nil + } + }() + + result = make([]map[string]any, 0) + for rows.Next() { + var ( + date string + totalRequests int64 + totalInputTokens int64 + totalOutputTokens int64 + totalCacheTokens int64 + totalCost float64 + totalActualCost float64 + avgDurationMs float64 + ) + if err = rows.Scan( + &date, + &totalRequests, + &totalInputTokens, + &totalOutputTokens, + &totalCacheTokens, + &totalCost, + &totalActualCost, + &avgDurationMs, + ); err != nil { + return nil, err + } + result = append(result, map[string]any{ + "date": date, + "total_requests": totalRequests, + "total_input_tokens": totalInputTokens, + "total_output_tokens": totalOutputTokens, + "total_cache_tokens": totalCacheTokens, + "total_tokens": totalInputTokens + totalOutputTokens + totalCacheTokens, + "total_cost": totalCost, + "total_actual_cost": totalActualCost, + "average_duration_ms": avgDurationMs, + }) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + return result, nil +} + +// resolveUsageStatsTimezone 获取用于 SQL 分组的时区名称。 +// 优先使用应用初始化的时区,其次尝试读取 TZ 环境变量,最后回落为 UTC。 +func resolveUsageStatsTimezone() string { + tzName := timezone.Name() + if tzName != "" && tzName != "Local" { + return tzName + } + if envTZ := strings.TrimSpace(os.Getenv("TZ")); envTZ != "" { + return envTZ + } + return "UTC" +} + +// GetAccountTodayStats 获取账号今日统计 +func (r *usageLogRepository) GetAccountTodayStats(ctx context.Context, accountID int64) (*usagestats.AccountStats, error) { + today := timezone.Today() + + query := ` + SELECT + COUNT(*) as requests, + COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens, + COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as cost, + COALESCE(SUM(total_cost), 0) as standard_cost, + COALESCE(SUM(actual_cost), 0) as user_cost + FROM usage_logs + WHERE account_id = $1 AND created_at >= $2 + ` + + stats := &usagestats.AccountStats{} + if err := scanSingleRow( + ctx, + r.sql, + query, + []any{accountID, today}, + &stats.Requests, + &stats.Tokens, + &stats.Cost, + &stats.StandardCost, + &stats.UserCost, + ); err != nil { + return nil, err + } + return stats, nil +} + +// GetAccountWindowStats 获取账号时间窗口内的统计 +func (r *usageLogRepository) GetAccountWindowStats(ctx context.Context, accountID int64, startTime time.Time) (*usagestats.AccountStats, error) { + query := ` + SELECT + COUNT(*) as requests, + COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens, + COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as cost, + COALESCE(SUM(total_cost), 0) as standard_cost, + COALESCE(SUM(actual_cost), 0) as user_cost + FROM usage_logs + WHERE account_id = $1 AND created_at >= $2 + ` + + stats := &usagestats.AccountStats{} + if err := scanSingleRow( + ctx, + r.sql, + query, + []any{accountID, startTime}, + &stats.Requests, + &stats.Tokens, + &stats.Cost, + &stats.StandardCost, + &stats.UserCost, + ); err != nil { + return nil, err + } + return stats, nil +} + +// GetAccountWindowStatsBatch 批量获取同一窗口起点下多个账号的统计数据。 +// 返回 map[accountID]*AccountStats,未命中的账号会返回零值统计,便于上层直接复用。 +func (r *usageLogRepository) GetAccountWindowStatsBatch(ctx context.Context, accountIDs []int64, startTime time.Time) (map[int64]*usagestats.AccountStats, error) { + result := make(map[int64]*usagestats.AccountStats, len(accountIDs)) + if len(accountIDs) == 0 { + return result, nil + } + + query := ` + SELECT + account_id, + COUNT(*) as requests, + COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens, + COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as cost, + COALESCE(SUM(total_cost), 0) as standard_cost, + COALESCE(SUM(actual_cost), 0) as user_cost + FROM usage_logs + WHERE account_id = ANY($1) AND created_at >= $2 + GROUP BY account_id + ` + rows, err := r.sql.QueryContext(ctx, query, pq.Array(accountIDs), startTime) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var accountID int64 + stats := &usagestats.AccountStats{} + if err := rows.Scan( + &accountID, + &stats.Requests, + &stats.Tokens, + &stats.Cost, + &stats.StandardCost, + &stats.UserCost, + ); err != nil { + return nil, err + } + result[accountID] = stats + } + if err := rows.Err(); err != nil { + return nil, err + } + + for _, accountID := range accountIDs { + if _, ok := result[accountID]; !ok { + result[accountID] = &usagestats.AccountStats{} + } + } + return result, nil +} + +// GetGeminiUsageTotalsBatch 批量聚合 Gemini 账号在窗口内的 Pro/Flash 请求与用量。 +// 模型分类规则与 service.geminiModelClassFromName 一致:model 包含 flash/lite 视为 flash,其余视为 pro。 +func (r *usageLogRepository) GetGeminiUsageTotalsBatch(ctx context.Context, accountIDs []int64, startTime, endTime time.Time) (map[int64]service.GeminiUsageTotals, error) { + result := make(map[int64]service.GeminiUsageTotals, len(accountIDs)) + if len(accountIDs) == 0 { + return result, nil + } + + query := ` + SELECT + account_id, + COALESCE(SUM(CASE WHEN LOWER(COALESCE(model, '')) LIKE '%flash%' OR LOWER(COALESCE(model, '')) LIKE '%lite%' THEN 1 ELSE 0 END), 0) AS flash_requests, + COALESCE(SUM(CASE WHEN LOWER(COALESCE(model, '')) LIKE '%flash%' OR LOWER(COALESCE(model, '')) LIKE '%lite%' THEN 0 ELSE 1 END), 0) AS pro_requests, + COALESCE(SUM(CASE WHEN LOWER(COALESCE(model, '')) LIKE '%flash%' OR LOWER(COALESCE(model, '')) LIKE '%lite%' THEN (input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) ELSE 0 END), 0) AS flash_tokens, + COALESCE(SUM(CASE WHEN LOWER(COALESCE(model, '')) LIKE '%flash%' OR LOWER(COALESCE(model, '')) LIKE '%lite%' THEN 0 ELSE (input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) END), 0) AS pro_tokens, + COALESCE(SUM(CASE WHEN LOWER(COALESCE(model, '')) LIKE '%flash%' OR LOWER(COALESCE(model, '')) LIKE '%lite%' THEN actual_cost ELSE 0 END), 0) AS flash_cost, + COALESCE(SUM(CASE WHEN LOWER(COALESCE(model, '')) LIKE '%flash%' OR LOWER(COALESCE(model, '')) LIKE '%lite%' THEN 0 ELSE actual_cost END), 0) AS pro_cost + FROM usage_logs + WHERE account_id = ANY($1) AND created_at >= $2 AND created_at < $3 + GROUP BY account_id + ` + rows, err := r.sql.QueryContext(ctx, query, pq.Array(accountIDs), startTime, endTime) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var accountID int64 + var totals service.GeminiUsageTotals + if err := rows.Scan( + &accountID, + &totals.FlashRequests, + &totals.ProRequests, + &totals.FlashTokens, + &totals.ProTokens, + &totals.FlashCost, + &totals.ProCost, + ); err != nil { + return nil, err + } + result[accountID] = totals + } + if err := rows.Err(); err != nil { + return nil, err + } + + for _, accountID := range accountIDs { + if _, ok := result[accountID]; !ok { + result[accountID] = service.GeminiUsageTotals{} + } + } + return result, nil +} + +// UsageStats represents usage statistics +type UsageStats = usagestats.UsageStats + +// BatchUserUsageStats represents usage stats for a single user +type BatchUserUsageStats = usagestats.BatchUserUsageStats + +// PlatformUsage represents per-platform usage breakdown +type PlatformUsage = usagestats.PlatformUsage + +func normalizePositiveInt64IDs(ids []int64) []int64 { + if len(ids) == 0 { + return nil + } + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +// GetBatchUserUsageStats gets today and total actual_cost for multiple users within a time range. +// If startTime is zero, defaults to 30 days ago. +func (r *usageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*BatchUserUsageStats, error) { + result := make(map[int64]*BatchUserUsageStats) + normalizedUserIDs := normalizePositiveInt64IDs(userIDs) + if len(normalizedUserIDs) == 0 { + return result, nil + } + + // 默认最近 30 天 + if startTime.IsZero() { + startTime = time.Now().AddDate(0, 0, -30) + } + if endTime.IsZero() { + endTime = time.Now() + } + + for _, id := range normalizedUserIDs { + result[id] = &BatchUserUsageStats{UserID: id} + } + + // GROUP BY (user_id, effective_platform) 一次查询同时得到总值与按平台拆分。 + // 应用层把同一 user_id 的多行累加为总值,并把非空 platform 行收集到 ByPlatform。 + query := ` + SELECT + ul.user_id, + ` + usageLogEffectivePlatformExpr + ` as platform, + COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $2 AND ul.created_at < $3), 0) as total_cost, + COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $4), 0) as today_cost + FROM usage_logs ul + LEFT JOIN groups g ON g.id = ul.group_id + LEFT JOIN accounts a ON a.id = ul.account_id + WHERE ul.user_id = ANY($1) + AND ul.created_at >= LEAST($2, $4) + AND ` + usageLogSuccessFilterUL + ` + GROUP BY ul.user_id, ` + usageLogEffectivePlatformExpr + ` + ` + today := timezone.Today() + rows, err := r.sql.QueryContext(ctx, query, pq.Array(normalizedUserIDs), startTime, endTime, today) + if err != nil { + return nil, err + } + for rows.Next() { + var userID int64 + var platform sql.NullString + var total float64 + var todayTotal float64 + if err := rows.Scan(&userID, &platform, &total, &todayTotal); err != nil { + _ = rows.Close() + return nil, err + } + stats, ok := result[userID] + if !ok { + continue + } + stats.TotalActualCost += total + stats.TodayActualCost += todayTotal + if platform.Valid && platform.String != "" { + stats.ByPlatform = append(stats.ByPlatform, PlatformUsage{ + Platform: platform.String, + TotalActualCost: total, + TodayActualCost: todayTotal, + }) + } + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + + return result, nil +} + +// BatchAPIKeyUsageStats represents usage stats for a single API key +type BatchAPIKeyUsageStats = usagestats.BatchAPIKeyUsageStats + +// GetBatchAPIKeyUsageStats gets today and total actual_cost for multiple API keys within a time range. +// If startTime is zero, defaults to 30 days ago. +func (r *usageLogRepository) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*BatchAPIKeyUsageStats, error) { + result := make(map[int64]*BatchAPIKeyUsageStats) + normalizedAPIKeyIDs := normalizePositiveInt64IDs(apiKeyIDs) + if len(normalizedAPIKeyIDs) == 0 { + return result, nil + } + + // 默认最近 30 天 + if startTime.IsZero() { + startTime = time.Now().AddDate(0, 0, -30) + } + if endTime.IsZero() { + endTime = time.Now() + } + + for _, id := range normalizedAPIKeyIDs { + result[id] = &BatchAPIKeyUsageStats{APIKeyID: id} + } + + query := ` + SELECT + api_key_id, + COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $2 AND created_at < $3), 0) as total_cost, + COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $4), 0) as today_cost + FROM usage_logs + WHERE api_key_id = ANY($1) + AND created_at >= LEAST($2, $4) + GROUP BY api_key_id + ` + today := timezone.Today() + rows, err := r.sql.QueryContext(ctx, query, pq.Array(normalizedAPIKeyIDs), startTime, endTime, today) + if err != nil { + return nil, err + } + for rows.Next() { + var apiKeyID int64 + var total float64 + var todayTotal float64 + if err := rows.Scan(&apiKeyID, &total, &todayTotal); err != nil { + _ = rows.Close() + return nil, err + } + if stats, ok := result[apiKeyID]; ok { + stats.TotalActualCost = total + stats.TodayActualCost = todayTotal + } + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + + return result, nil +} + +// resolveEndpointColumn maps endpoint type to the corresponding DB column name. +func resolveEndpointColumn(endpointType string) string { + switch endpointType { + case "upstream": + return "ul.upstream_endpoint" + case "path": + return "ul.inbound_endpoint || ' -> ' || ul.upstream_endpoint" + default: + return "ul.inbound_endpoint" + } +} + +// GetGlobalStats gets usage statistics for all users within a time range +func (r *usageLogRepository) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*UsageStats, error) { + query := ` + SELECT + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(AVG(duration_ms), 0) as avg_duration_ms + FROM usage_logs + WHERE created_at >= $1 AND created_at < $2 + ` + + stats := &UsageStats{} + if err := scanSingleRow( + ctx, + r.sql, + query, + []any{startTime, endTime}, + &stats.TotalRequests, + &stats.TotalInputTokens, + &stats.TotalOutputTokens, + &stats.TotalCacheTokens, + &stats.TotalCost, + &stats.TotalActualCost, + &stats.AverageDurationMs, + ); err != nil { + return nil, err + } + stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens + return stats, nil +} + +// GetStatsWithFilters gets usage statistics with optional filters +func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters UsageLogFilters) (*UsageStats, error) { + conditions := make([]string, 0, 9) + args := make([]any, 0, 9) + + if filters.UserID > 0 { + conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1)) + args = append(args, filters.UserID) + } + if filters.APIKeyID > 0 { + conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1)) + args = append(args, filters.APIKeyID) + } + if filters.AccountID > 0 { + conditions = append(conditions, fmt.Sprintf("account_id = $%d", len(args)+1)) + args = append(args, filters.AccountID) + } + if filters.GroupID > 0 { + conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1)) + args = append(args, filters.GroupID) + } + conditions, args = appendUsageLogModelWhereCondition(conditions, args, filters.Model, filters.ModelFilterSource) + conditions, args = appendRequestTypeOrStreamWhereCondition(conditions, args, filters.RequestType, filters.Stream) + if filters.BillingType != nil { + conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1)) + args = append(args, int16(*filters.BillingType)) + } + conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode) + if filters.StartTime != nil { + conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) + args = append(args, *filters.StartTime) + } + if filters.EndTime != nil { + conditions = append(conditions, fmt.Sprintf("created_at < $%d", len(args)+1)) + args = append(args, *filters.EndTime) + } + + query := fmt.Sprintf(` + SELECT + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as total_account_cost, + COALESCE(AVG(duration_ms), 0) as avg_duration_ms + FROM usage_logs + %s + `, buildWhere(conditions)) + + stats := &UsageStats{} + var totalAccountCost float64 + + start := time.Unix(0, 0).UTC() + if filters.StartTime != nil { + start = *filters.StartTime + } + end := time.Now().UTC() + if filters.EndTime != nil { + end = *filters.EndTime + } + + var endpoints, upstreamEndpoints, endpointPaths []EndpointStat + + // 汇总查询:失败即致命。 + runSummary := func(c context.Context) error { + return scanSingleRow( + c, r.sql, query, args, + &stats.TotalRequests, + &stats.TotalInputTokens, + &stats.TotalOutputTokens, + &stats.TotalCacheTokens, + &stats.TotalCacheCreationTokens, + &stats.TotalCacheReadTokens, + &stats.TotalCost, + &stats.TotalActualCost, + &totalAccountCost, + &stats.AverageDurationMs, + ) + } + // endpoint 明细:best-effort(失败 log + 返空),不致命。 + runEndpoints := func(c context.Context) { + res, err := r.getEndpointStatsByColumnWithFilters(c, "inbound_endpoint", start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + logger.LegacyPrintf("repository.usage_log", "GetEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err) + } + res = []EndpointStat{} + } + endpoints = res + } + runUpstream := func(c context.Context) { + res, err := r.getEndpointStatsByColumnWithFilters(c, "upstream_endpoint", start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + logger.LegacyPrintf("repository.usage_log", "GetUpstreamEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err) + } + res = []EndpointStat{} + } + upstreamEndpoints = res + } + runPaths := func(c context.Context) { + res, err := r.getEndpointPathStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + logger.LegacyPrintf("repository.usage_log", "getEndpointPathStatsWithFilters failed in GetStatsWithFilters: %v", err) + } + res = []EndpointStat{} + } + endpointPaths = res + } + + if r.db != nil { + // 生产路径:r.sql 是 *sql.DB 连接池,可并发。4 条查询并行,延迟取最大值。 + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { return runSummary(gctx) }) + g.Go(func() error { runEndpoints(gctx); return nil }) + g.Go(func() error { runUpstream(gctx); return nil }) + g.Go(func() error { runPaths(gctx); return nil }) + if err := g.Wait(); err != nil { + return nil, err + } + } else { + // 事务路径(ent.Tx 不能并发查询):顺序执行,行为与重构前一致。 + if err := runSummary(ctx); err != nil { + return nil, err + } + runEndpoints(ctx) + runUpstream(ctx) + runPaths(ctx) + } + + stats.TotalAccountCost = &totalAccountCost + stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens + stats.Endpoints = endpoints + stats.UpstreamEndpoints = upstreamEndpoints + stats.EndpointPaths = endpointPaths + + return stats, nil +} + +// AccountUsageHistory represents daily usage history for an account +type AccountUsageHistory = usagestats.AccountUsageHistory + +// AccountUsageSummary represents summary statistics for an account +type AccountUsageSummary = usagestats.AccountUsageSummary + +// AccountUsageStatsResponse represents the full usage statistics response for an account +type AccountUsageStatsResponse = usagestats.AccountUsageStatsResponse + +// EndpointStat represents endpoint usage statistics row. +type EndpointStat = usagestats.EndpointStat + +func (r *usageLogRepository) getEndpointStatsByColumnWithFilters(ctx context.Context, endpointColumn string, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []EndpointStat, err error) { + actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" + if accountID > 0 && userID == 0 && apiKeyID == 0 { + actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost" + } + + query := fmt.Sprintf(` + SELECT + COALESCE(NULLIF(TRIM(%s), ''), 'unknown') AS endpoint, + COUNT(*) AS requests, + COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS total_tokens, + COALESCE(SUM(total_cost), 0) as cost, + %s + FROM usage_logs + WHERE created_at >= $1 AND created_at < $2 + `, endpointColumn, actualCostExpr) + + args := []any{startTime, endTime} + if userID > 0 { + query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) + args = append(args, userID) + } + if apiKeyID > 0 { + query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) + args = append(args, apiKeyID) + } + if accountID > 0 { + query += fmt.Sprintf(" AND account_id = $%d", len(args)+1) + args = append(args, accountID) + } + if groupID > 0 { + query += fmt.Sprintf(" AND group_id = $%d", len(args)+1) + args = append(args, groupID) + } + query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource) + query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) + if billingType != nil { + query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1) + args = append(args, int16(*billingType)) + } + query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") + query += " GROUP BY endpoint ORDER BY requests DESC" + + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + results = nil + } + }() + + results = make([]EndpointStat, 0) + for rows.Next() { + var row EndpointStat + if err := rows.Scan(&row.Endpoint, &row.Requests, &row.TotalTokens, &row.Cost, &row.ActualCost); err != nil { + return nil, err + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +func (r *usageLogRepository) getEndpointPathStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []EndpointStat, err error) { + actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" + if accountID > 0 && userID == 0 && apiKeyID == 0 { + actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost" + } + + query := fmt.Sprintf(` + SELECT + CONCAT( + COALESCE(NULLIF(TRIM(inbound_endpoint), ''), 'unknown'), + ' -> ', + COALESCE(NULLIF(TRIM(upstream_endpoint), ''), 'unknown') + ) AS endpoint, + COUNT(*) AS requests, + COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS total_tokens, + COALESCE(SUM(total_cost), 0) as cost, + %s + FROM usage_logs + WHERE created_at >= $1 AND created_at < $2 + `, actualCostExpr) + + args := []any{startTime, endTime} + if userID > 0 { + query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) + args = append(args, userID) + } + if apiKeyID > 0 { + query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) + args = append(args, apiKeyID) + } + if accountID > 0 { + query += fmt.Sprintf(" AND account_id = $%d", len(args)+1) + args = append(args, accountID) + } + if groupID > 0 { + query += fmt.Sprintf(" AND group_id = $%d", len(args)+1) + args = append(args, groupID) + } + query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource) + query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) + if billingType != nil { + query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1) + args = append(args, int16(*billingType)) + } + query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") + query += " GROUP BY endpoint ORDER BY requests DESC" + + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + results = nil + } + }() + + results = make([]EndpointStat, 0) + for rows.Next() { + var row EndpointStat + if err := rows.Scan(&row.Endpoint, &row.Requests, &row.TotalTokens, &row.Cost, &row.ActualCost); err != nil { + return nil, err + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +// GetEndpointStatsWithFilters returns inbound endpoint statistics with optional filters. +func (r *usageLogRepository) GetEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]EndpointStat, error) { + return r.getEndpointStatsByColumnWithFilters(ctx, "inbound_endpoint", startTime, endTime, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") +} + +// GetUpstreamEndpointStatsWithFilters returns upstream endpoint statistics with optional filters. +func (r *usageLogRepository) GetUpstreamEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]EndpointStat, error) { + return r.getEndpointStatsByColumnWithFilters(ctx, "upstream_endpoint", startTime, endTime, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") +} + +// GetAccountUsageStats returns comprehensive usage statistics for an account over a time range +func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (resp *AccountUsageStatsResponse, err error) { + daysCount := int(endTime.Sub(startTime).Hours()/24) + 1 + if daysCount <= 0 { + daysCount = 30 + } + + query := ` + SELECT + TO_CHAR(created_at, 'YYYY-MM-DD') as date, + COUNT(*) as requests, + COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens, + COALESCE(SUM(total_cost), 0) as cost, + COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost, + COALESCE(SUM(actual_cost), 0) as user_cost + FROM usage_logs + WHERE account_id = $1 AND created_at >= $2 AND created_at < $3 + GROUP BY date + ORDER BY date ASC + ` + + rows, err := r.sql.QueryContext(ctx, query, accountID, startTime, endTime) + if err != nil { + return nil, err + } + defer func() { + // 保持主错误优先;仅在无错误时回传 Close 失败。 + // 同时清空返回值,避免误用不完整结果。 + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + resp = nil + } + }() + + history := make([]AccountUsageHistory, 0) + for rows.Next() { + var date string + var requests int64 + var tokens int64 + var cost float64 + var actualCost float64 + var userCost float64 + if err = rows.Scan(&date, &requests, &tokens, &cost, &actualCost, &userCost); err != nil { + return nil, err + } + t, _ := time.Parse("2006-01-02", date) + history = append(history, AccountUsageHistory{ + Date: date, + Label: t.Format("01/02"), + Requests: requests, + Tokens: tokens, + Cost: cost, + ActualCost: actualCost, + UserCost: userCost, + }) + } + if err = rows.Err(); err != nil { + return nil, err + } + + var totalAccountCost, totalUserCost, totalStandardCost float64 + var totalRequests, totalTokens int64 + var highestCostDay, highestRequestDay *AccountUsageHistory + + for i := range history { + h := &history[i] + totalAccountCost += h.ActualCost + totalUserCost += h.UserCost + totalStandardCost += h.Cost + totalRequests += h.Requests + totalTokens += h.Tokens + + if highestCostDay == nil || h.ActualCost > highestCostDay.ActualCost { + highestCostDay = h + } + if highestRequestDay == nil || h.Requests > highestRequestDay.Requests { + highestRequestDay = h + } + } + + actualDaysUsed := len(history) + if actualDaysUsed == 0 { + actualDaysUsed = 1 + } + + avgQuery := "SELECT COALESCE(AVG(duration_ms), 0) as avg_duration_ms FROM usage_logs WHERE account_id = $1 AND created_at >= $2 AND created_at < $3" + var avgDuration float64 + if err := scanSingleRow(ctx, r.sql, avgQuery, []any{accountID, startTime, endTime}, &avgDuration); err != nil { + return nil, err + } + + summary := AccountUsageSummary{ + Days: daysCount, + ActualDaysUsed: actualDaysUsed, + TotalCost: totalAccountCost, + TotalUserCost: totalUserCost, + TotalStandardCost: totalStandardCost, + TotalRequests: totalRequests, + TotalTokens: totalTokens, + AvgDailyCost: totalAccountCost / float64(actualDaysUsed), + AvgDailyUserCost: totalUserCost / float64(actualDaysUsed), + AvgDailyRequests: float64(totalRequests) / float64(actualDaysUsed), + AvgDailyTokens: float64(totalTokens) / float64(actualDaysUsed), + AvgDurationMs: avgDuration, + } + + todayStr := timezone.Now().Format("2006-01-02") + for i := range history { + if history[i].Date == todayStr { + summary.Today = &struct { + Date string `json:"date"` + Cost float64 `json:"cost"` + UserCost float64 `json:"user_cost"` + Requests int64 `json:"requests"` + Tokens int64 `json:"tokens"` + }{ + Date: history[i].Date, + Cost: history[i].ActualCost, + UserCost: history[i].UserCost, + Requests: history[i].Requests, + Tokens: history[i].Tokens, + } + break + } + } + + if highestCostDay != nil { + summary.HighestCostDay = &struct { + Date string `json:"date"` + Label string `json:"label"` + Cost float64 `json:"cost"` + UserCost float64 `json:"user_cost"` + Requests int64 `json:"requests"` + }{ + Date: highestCostDay.Date, + Label: highestCostDay.Label, + Cost: highestCostDay.ActualCost, + UserCost: highestCostDay.UserCost, + Requests: highestCostDay.Requests, + } + } + + if highestRequestDay != nil { + summary.HighestRequestDay = &struct { + Date string `json:"date"` + Label string `json:"label"` + Requests int64 `json:"requests"` + Cost float64 `json:"cost"` + UserCost float64 `json:"user_cost"` + }{ + Date: highestRequestDay.Date, + Label: highestRequestDay.Label, + Requests: highestRequestDay.Requests, + Cost: highestRequestDay.ActualCost, + UserCost: highestRequestDay.UserCost, + } + } + + models, err := r.GetModelStatsWithFilters(ctx, startTime, endTime, 0, 0, accountID, 0, nil, nil, nil) + if err != nil { + models = []ModelStat{} + } + endpoints, endpointErr := r.GetEndpointStatsWithFilters(ctx, startTime, endTime, 0, 0, accountID, 0, "", nil, nil, nil) + if endpointErr != nil { + logger.LegacyPrintf("repository.usage_log", "GetEndpointStatsWithFilters failed in GetAccountUsageStats: %v", endpointErr) + endpoints = []EndpointStat{} + } + upstreamEndpoints, upstreamEndpointErr := r.GetUpstreamEndpointStatsWithFilters(ctx, startTime, endTime, 0, 0, accountID, 0, "", nil, nil, nil) + if upstreamEndpointErr != nil { + logger.LegacyPrintf("repository.usage_log", "GetUpstreamEndpointStatsWithFilters failed in GetAccountUsageStats: %v", upstreamEndpointErr) + upstreamEndpoints = []EndpointStat{} + } + + resp = &AccountUsageStatsResponse{ + History: history, + Summary: summary, + Models: models, + Endpoints: endpoints, + UpstreamEndpoints: upstreamEndpoints, + } + return resp, nil +} diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go new file mode 100644 index 0000000000..392f1c8300 --- /dev/null +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -0,0 +1,799 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" +) + +// TrendDataPoint represents a single point in trend data +type TrendDataPoint = usagestats.TrendDataPoint + +// ModelStat represents usage statistics for a single model +type ModelStat = usagestats.ModelStat + +// UserUsageTrendPoint represents user usage trend data point +type UserUsageTrendPoint = usagestats.UserUsageTrendPoint + +// UserSpendingRankingItem represents a user spending ranking row. +type UserSpendingRankingItem = usagestats.UserSpendingRankingItem +type UserSpendingRankingResponse = usagestats.UserSpendingRankingResponse + +// APIKeyUsageTrendPoint represents API key usage trend data point +type APIKeyUsageTrendPoint = usagestats.APIKeyUsageTrendPoint + +// GetAPIKeyUsageTrend returns usage trend data grouped by API key and date +func (r *usageLogRepository) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) (results []APIKeyUsageTrendPoint, err error) { + dateFormat := safeDateFormat(granularity) + + query := fmt.Sprintf(` + WITH top_keys AS ( + SELECT api_key_id + FROM usage_logs + WHERE created_at >= $1 AND created_at < $2 + GROUP BY api_key_id + ORDER BY SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) DESC + LIMIT $3 + ) + SELECT + TO_CHAR(u.created_at, '%s') as date, + u.api_key_id, + COALESCE(k.name, '') as key_name, + COUNT(*) as requests, + COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens + FROM usage_logs u + LEFT JOIN api_keys k ON u.api_key_id = k.id + WHERE u.api_key_id IN (SELECT api_key_id FROM top_keys) + AND u.created_at >= $4 AND u.created_at < $5 + GROUP BY date, u.api_key_id, k.name + ORDER BY date ASC, tokens DESC + `, dateFormat) + + rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit, startTime, endTime) + if err != nil { + return nil, err + } + defer func() { + // 保持主错误优先;仅在无错误时回传 Close 失败。 + // 同时清空返回值,避免误用不完整结果。 + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + results = nil + } + }() + + results = make([]APIKeyUsageTrendPoint, 0) + for rows.Next() { + var row APIKeyUsageTrendPoint + if err = rows.Scan(&row.Date, &row.APIKeyID, &row.KeyName, &row.Requests, &row.Tokens); err != nil { + return nil, err + } + results = append(results, row) + } + if err = rows.Err(); err != nil { + return nil, err + } + + return results, nil +} + +// GetUserUsageTrend returns usage trend data grouped by user and date +func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) (results []UserUsageTrendPoint, err error) { + dateFormat := safeDateFormat(granularity) + + query := fmt.Sprintf(` + WITH top_users AS ( + SELECT user_id + FROM usage_logs + WHERE created_at >= $1 AND created_at < $2 + GROUP BY user_id + ORDER BY SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) DESC + LIMIT $3 + ) + SELECT + TO_CHAR(u.created_at, '%s') as date, + u.user_id, + COALESCE(us.email, '') as email, + COALESCE(us.username, '') as username, + COUNT(*) as requests, + COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens, + COALESCE(SUM(u.total_cost), 0) as cost, + COALESCE(SUM(u.actual_cost), 0) as actual_cost + FROM usage_logs u + LEFT JOIN users us ON u.user_id = us.id + WHERE u.user_id IN (SELECT user_id FROM top_users) + AND u.created_at >= $4 AND u.created_at < $5 + GROUP BY date, u.user_id, us.email, us.username + ORDER BY date ASC, tokens DESC + `, dateFormat) + + rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit, startTime, endTime) + if err != nil { + return nil, err + } + defer func() { + // 保持主错误优先;仅在无错误时回传 Close 失败。 + // 同时清空返回值,避免误用不完整结果。 + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + results = nil + } + }() + + results = make([]UserUsageTrendPoint, 0) + for rows.Next() { + var row UserUsageTrendPoint + if err = rows.Scan(&row.Date, &row.UserID, &row.Email, &row.Username, &row.Requests, &row.Tokens, &row.Cost, &row.ActualCost); err != nil { + return nil, err + } + results = append(results, row) + } + if err = rows.Err(); err != nil { + return nil, err + } + + return results, nil +} + +// GetUserSpendingRanking returns user spending ranking aggregated within the time range. +func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (result *UserSpendingRankingResponse, err error) { + if limit <= 0 { + limit = 12 + } + + query := ` + WITH user_spend AS ( + SELECT + u.user_id, + COALESCE(us.email, '') as email, + COALESCE(SUM(u.actual_cost), 0) as actual_cost, + COUNT(*) as requests, + COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens + FROM usage_logs u + LEFT JOIN users us ON u.user_id = us.id + WHERE u.created_at >= $1 AND u.created_at < $2 + GROUP BY u.user_id, us.email + ), + ranked AS ( + SELECT + user_id, + email, + actual_cost, + requests, + tokens, + COALESCE(SUM(actual_cost) OVER (), 0) as total_actual_cost, + COALESCE(SUM(requests) OVER (), 0) as total_requests, + COALESCE(SUM(tokens) OVER (), 0) as total_tokens + FROM user_spend + ORDER BY actual_cost DESC, tokens DESC, user_id ASC + LIMIT $3 + ) + SELECT + user_id, + email, + actual_cost, + requests, + tokens, + total_actual_cost, + total_requests, + total_tokens + FROM ranked + ORDER BY actual_cost DESC, tokens DESC, user_id ASC + ` + + rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit) + if err != nil { + return nil, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + result = nil + } + }() + + ranking := make([]UserSpendingRankingItem, 0) + totalActualCost := 0.0 + totalRequests := int64(0) + totalTokens := int64(0) + for rows.Next() { + var row UserSpendingRankingItem + if err = rows.Scan(&row.UserID, &row.Email, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil { + return nil, err + } + ranking = append(ranking, row) + } + if err = rows.Err(); err != nil { + return nil, err + } + + return &UserSpendingRankingResponse{ + Ranking: ranking, + TotalActualCost: totalActualCost, + TotalRequests: totalRequests, + TotalTokens: totalTokens, + }, nil +} + +// GetUserUsageTrendByUserID 获取指定用户的使用趋势 +func (r *usageLogRepository) GetUserUsageTrendByUserID(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) { + dateFormat := safeDateFormat(granularity) + + query := fmt.Sprintf(` + SELECT + TO_CHAR(created_at, '%s') as date, + COUNT(*) as requests, + COALESCE(SUM(input_tokens), 0) as input_tokens, + COALESCE(SUM(output_tokens), 0) as output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens, + COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens, + COALESCE(SUM(total_cost), 0) as cost, + COALESCE(SUM(actual_cost), 0) as actual_cost + FROM usage_logs + WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 + GROUP BY date + ORDER BY date ASC + `, dateFormat) + + rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime) + if err != nil { + return nil, err + } + defer func() { + // 保持主错误优先;仅在无错误时回传 Close 失败。 + // 同时清空返回值,避免误用不完整结果。 + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + results = nil + } + }() + + results, err = scanTrendRows(rows) + if err != nil { + return nil, err + } + return results, nil +} + +// GetUserModelStats 获取指定用户的模型统计 +func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) (results []ModelStat, err error) { + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "") +} + +// GetUsageTrendWithFilters returns usage trend data with optional filters +func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []TrendDataPoint, err error) { + return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") +} + +func (r *usageLogRepository) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters UsageLogFilters) (results []TrendDataPoint, err error) { + return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) +} + +func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []TrendDataPoint, err error) { + if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode) { + aggregated, aggregatedErr := r.getUsageTrendFromAggregates(ctx, startTime, endTime, granularity) + if aggregatedErr == nil && len(aggregated) > 0 { + return aggregated, nil + } + } + + dateFormat := safeDateFormat(granularity) + + query := fmt.Sprintf(` + SELECT + TO_CHAR(created_at, '%s') as date, + COUNT(*) as requests, + COALESCE(SUM(input_tokens), 0) as input_tokens, + COALESCE(SUM(output_tokens), 0) as output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens, + COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens, + COALESCE(SUM(total_cost), 0) as cost, + COALESCE(SUM(actual_cost), 0) as actual_cost + FROM usage_logs + WHERE created_at >= $1 AND created_at < $2 + `, dateFormat) + + args := []any{startTime, endTime} + if userID > 0 { + query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) + args = append(args, userID) + } + if apiKeyID > 0 { + query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) + args = append(args, apiKeyID) + } + if accountID > 0 { + query += fmt.Sprintf(" AND account_id = $%d", len(args)+1) + args = append(args, accountID) + } + if groupID > 0 { + query += fmt.Sprintf(" AND group_id = $%d", len(args)+1) + args = append(args, groupID) + } + query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource) + query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) + if billingType != nil { + query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1) + args = append(args, int16(*billingType)) + } + query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") + query += " GROUP BY date ORDER BY date ASC" + + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { + // 保持主错误优先;仅在无错误时回传 Close 失败。 + // 同时清空返回值,避免误用不完整结果。 + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + results = nil + } + }() + + results, err = scanTrendRows(rows) + if err != nil { + return nil, err + } + return results, nil +} + +func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) bool { + if granularity != "day" && granularity != "hour" { + return false + } + return userID == 0 && + apiKeyID == 0 && + accountID == 0 && + groupID == 0 && + model == "" && + requestType == nil && + stream == nil && + billingType == nil && + billingMode == "" +} + +func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) { + dateFormat := safeDateFormat(granularity) + query := "" + args := []any{startTime, endTime} + + switch granularity { + case "hour": + query = fmt.Sprintf(` + SELECT + TO_CHAR(bucket_start, '%s') as date, + total_requests as requests, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + (input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) as total_tokens, + total_cost as cost, + actual_cost + FROM usage_dashboard_hourly + WHERE bucket_start >= $1 AND bucket_start < $2 + ORDER BY bucket_start ASC + `, dateFormat) + case "day": + query = fmt.Sprintf(` + SELECT + TO_CHAR(bucket_date::timestamp, '%s') as date, + total_requests as requests, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + (input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) as total_tokens, + total_cost as cost, + actual_cost + FROM usage_dashboard_daily + WHERE bucket_date >= $1::date AND bucket_date < $2::date + ORDER BY bucket_date ASC + `, dateFormat) + default: + return nil, nil + } + + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + results = nil + } + }() + + results, err = scanTrendRows(rows) + if err != nil { + return nil, err + } + return results, nil +} + +// GetModelStatsWithFilters returns model statistics with optional filters +func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) { + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "") +} + +// GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension. +// source: requested | upstream | mapping. +func (r *usageLogRepository) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) { + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "") +} + +func (r *usageLogRepository) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters, source string) (results []ModelStat, err error) { + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode) +} + +func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string) (results []ModelStat, err error) { + actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" + // 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。 + if accountID > 0 && userID == 0 && apiKeyID == 0 { + actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost" + } + accountCostExpr := "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as account_cost" + modelExpr := resolveModelDimensionExpression(source) + + query := fmt.Sprintf(` + SELECT + %s as model, + COUNT(*) as requests, + COALESCE(SUM(input_tokens), 0) as input_tokens, + COALESCE(SUM(output_tokens), 0) as output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens, + COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens, + COALESCE(SUM(total_cost), 0) as cost, + %s, + %s + FROM usage_logs + WHERE created_at >= $1 AND created_at < $2 + `, modelExpr, actualCostExpr, accountCostExpr) + + args := []any{startTime, endTime} + if userID > 0 { + query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) + args = append(args, userID) + } + if apiKeyID > 0 { + query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) + args = append(args, apiKeyID) + } + if accountID > 0 { + query += fmt.Sprintf(" AND account_id = $%d", len(args)+1) + args = append(args, accountID) + } + if groupID > 0 { + query += fmt.Sprintf(" AND group_id = $%d", len(args)+1) + args = append(args, groupID) + } + if strings.TrimSpace(model) != "" { + query += fmt.Sprintf(" AND %s = $%d", modelExpr, len(args)+1) + args = append(args, model) + } + query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) + if billingType != nil { + query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1) + args = append(args, int16(*billingType)) + } + query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") + query += fmt.Sprintf(" GROUP BY %s ORDER BY total_tokens DESC", modelExpr) + + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { + // 保持主错误优先;仅在无错误时回传 Close 失败。 + // 同时清空返回值,避免误用不完整结果。 + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + results = nil + } + }() + + results, err = scanModelStatsRows(rows) + if err != nil { + return nil, err + } + return results, nil +} + +// GetGroupStatsWithFilters returns group usage statistics with optional filters +func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []usagestats.GroupStat, err error) { + return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "") +} + +func (r *usageLogRepository) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters) (results []usagestats.GroupStat, err error) { + return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) +} + +func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []usagestats.GroupStat, err error) { + query := ` + SELECT + COALESCE(ul.group_id, 0) as group_id, + COALESCE(g.name, '') as group_name, + COUNT(*) as requests, + COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens, + COALESCE(SUM(ul.total_cost), 0) as cost, + COALESCE(SUM(ul.actual_cost), 0) as actual_cost, + COALESCE(SUM(COALESCE(ul.account_stats_cost, ul.total_cost) * COALESCE(ul.account_rate_multiplier, 1)), 0) as account_cost + FROM usage_logs ul + LEFT JOIN groups g ON g.id = ul.group_id + WHERE ul.created_at >= $1 AND ul.created_at < $2 + ` + + args := []any{startTime, endTime} + if userID > 0 { + query += fmt.Sprintf(" AND ul.user_id = $%d", len(args)+1) + args = append(args, userID) + } + if apiKeyID > 0 { + query += fmt.Sprintf(" AND ul.api_key_id = $%d", len(args)+1) + args = append(args, apiKeyID) + } + if accountID > 0 { + query += fmt.Sprintf(" AND ul.account_id = $%d", len(args)+1) + args = append(args, accountID) + } + if groupID > 0 { + query += fmt.Sprintf(" AND ul.group_id = $%d", len(args)+1) + args = append(args, groupID) + } + if strings.TrimSpace(model) != "" { + modelExpr := resolveModelDimensionExpressionWithAlias(usagestats.ModelSourceRequested, "ul") + query += fmt.Sprintf(" AND %s = $%d", modelExpr, len(args)+1) + args = append(args, model) + } + query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) + if billingType != nil { + query += fmt.Sprintf(" AND ul.billing_type = $%d", len(args)+1) + args = append(args, int16(*billingType)) + } + query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "ul") + query += " GROUP BY ul.group_id, g.name ORDER BY total_tokens DESC" + + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + results = nil + } + }() + + results = make([]usagestats.GroupStat, 0) + for rows.Next() { + var row usagestats.GroupStat + if err := rows.Scan( + &row.GroupID, + &row.GroupName, + &row.Requests, + &row.TotalTokens, + &row.Cost, + &row.ActualCost, + &row.AccountCost, + ); err != nil { + return nil, err + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +// GetUserBreakdownStats returns per-user usage breakdown within a specific dimension. +func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) (results []usagestats.UserBreakdownItem, err error) { + query := ` + SELECT + COALESCE(ul.user_id, 0) as user_id, + COALESCE(u.email, '') as email, + COUNT(*) as requests, + COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens, + COALESCE(SUM(ul.total_cost), 0) as cost, + COALESCE(SUM(ul.actual_cost), 0) as actual_cost, + COALESCE(SUM(COALESCE(ul.account_stats_cost, ul.total_cost) * COALESCE(ul.account_rate_multiplier, 1)), 0) as account_cost + FROM usage_logs ul + LEFT JOIN users u ON u.id = ul.user_id + WHERE ul.created_at >= $1 AND ul.created_at < $2 + ` + args := []any{startTime, endTime} + + if dim.GroupID > 0 { + query += fmt.Sprintf(" AND ul.group_id = $%d", len(args)+1) + args = append(args, dim.GroupID) + } + if dim.Model != "" { + query += fmt.Sprintf(" AND %s = $%d", resolveModelDimensionExpression(dim.ModelType), len(args)+1) + args = append(args, dim.Model) + } + if dim.Endpoint != "" { + col := resolveEndpointColumn(dim.EndpointType) + query += fmt.Sprintf(" AND %s = $%d", col, len(args)+1) + args = append(args, dim.Endpoint) + } + if dim.UserID > 0 { + query += fmt.Sprintf(" AND ul.user_id = $%d", len(args)+1) + args = append(args, dim.UserID) + } + if dim.APIKeyID > 0 { + query += fmt.Sprintf(" AND ul.api_key_id = $%d", len(args)+1) + args = append(args, dim.APIKeyID) + } + if dim.AccountID > 0 { + query += fmt.Sprintf(" AND ul.account_id = $%d", len(args)+1) + args = append(args, dim.AccountID) + } + if dim.RequestType != nil { + query += fmt.Sprintf(" AND ul.request_type = $%d", len(args)+1) + args = append(args, *dim.RequestType) + } + if dim.Stream != nil { + query += fmt.Sprintf(" AND ul.stream = $%d", len(args)+1) + args = append(args, *dim.Stream) + } + if dim.BillingType != nil { + query += fmt.Sprintf(" AND ul.billing_type = $%d", len(args)+1) + args = append(args, *dim.BillingType) + } + + query += " GROUP BY ul.user_id, u.email ORDER BY actual_cost DESC" + if limit > 0 { + query += fmt.Sprintf(" LIMIT %d", limit) + } + + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + results = nil + } + }() + + results = make([]usagestats.UserBreakdownItem, 0) + for rows.Next() { + var row usagestats.UserBreakdownItem + if err := rows.Scan( + &row.UserID, + &row.Email, + &row.Requests, + &row.TotalTokens, + &row.Cost, + &row.ActualCost, + &row.AccountCost, + ); err != nil { + return nil, err + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +// GetAllGroupUsageSummary returns today's and cumulative actual_cost for every group. +// todayStart is the start-of-day in the caller's timezone (UTC-based). +// TODO(perf): This query scans ALL usage_logs rows for total_cost aggregation. +// When usage_logs exceeds ~1M rows, consider adding a short-lived cache (30s) +// or a materialized view / pre-aggregation table for cumulative costs. +func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { + query := ` + SELECT + g.id AS group_id, + COALESCE(SUM(ul.actual_cost), 0) AS total_cost, + COALESCE(SUM(CASE WHEN ul.created_at >= $1 THEN ul.actual_cost ELSE 0 END), 0) AS today_cost + FROM groups g + LEFT JOIN usage_logs ul ON ul.group_id = g.id + GROUP BY g.id + ` + + rows, err := r.sql.QueryContext(ctx, query, todayStart) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var results []usagestats.GroupUsageSummary + for rows.Next() { + var row usagestats.GroupUsageSummary + if err := rows.Scan(&row.GroupID, &row.TotalCost, &row.TodayCost); err != nil { + return nil, err + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +// resolveModelDimensionExpression maps model source type to a safe SQL expression. +func resolveModelDimensionExpression(modelType string) string { + return resolveModelDimensionExpressionWithAlias(modelType, "") +} + +func resolveModelDimensionExpressionWithAlias(modelType, alias string) string { + column := func(name string) string { + if alias == "" { + return name + } + return alias + "." + name + } + requestedExpr := fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("requested_model"), column("model")) + switch usagestats.NormalizeModelSource(modelType) { + case usagestats.ModelSourceUpstream: + return fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("upstream_model"), requestedExpr) + case usagestats.ModelSourceMapping: + return fmt.Sprintf("(%s || ' -> ' || COALESCE(NULLIF(TRIM(%s), ''), %s))", requestedExpr, column("upstream_model"), requestedExpr) + default: + return requestedExpr + } +} + +func scanTrendRows(rows *sql.Rows) ([]TrendDataPoint, error) { + results := make([]TrendDataPoint, 0) + for rows.Next() { + var row TrendDataPoint + if err := rows.Scan( + &row.Date, + &row.Requests, + &row.InputTokens, + &row.OutputTokens, + &row.CacheCreationTokens, + &row.CacheReadTokens, + &row.TotalTokens, + &row.Cost, + &row.ActualCost, + ); err != nil { + return nil, err + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +func scanModelStatsRows(rows *sql.Rows) ([]ModelStat, error) { + results := make([]ModelStat, 0) + for rows.Next() { + var row ModelStat + if err := rows.Scan( + &row.Model, + &row.Requests, + &row.InputTokens, + &row.OutputTokens, + &row.CacheCreationTokens, + &row.CacheReadTokens, + &row.TotalTokens, + &row.Cost, + &row.ActualCost, + &row.AccountCost, + ); err != nil { + return nil, err + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go new file mode 100644 index 0000000000..52e5ce719b --- /dev/null +++ b/backend/internal/service/admin_account.go @@ -0,0 +1,1075 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +// Account management implementations +func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]Account, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} + accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID, privacyMode) + if err != nil { + return nil, 0, err + } + return accounts, result.Total, nil +} + +func (s *adminServiceImpl) ListAccountsForSchedulerScoreFilter(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error) { + if s == nil || s.accountRepo == nil { + return nil, nil + } + return s.accountRepo.ListAllWithFilters(ctx, platform, accountType, status, search, groupID, privacyMode) +} + +func (s *adminServiceImpl) ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]Account, error) { + if s == nil || s.accountRepo == nil { + return nil, nil + } + if groupID != nil { + return s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI) + } + return s.accountRepo.ListSchedulableUngroupedByPlatform(ctx, PlatformOpenAI) +} + +func (s *adminServiceImpl) GetAccount(ctx context.Context, id int64) (*Account, error) { + return s.accountRepo.GetByID(ctx, id) +} + +func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error) { + if len(ids) == 0 { + return []*Account{}, nil + } + + accounts, err := s.accountRepo.GetByIDs(ctx, ids) + if err != nil { + return nil, fmt.Errorf("failed to get accounts by IDs: %w", err) + } + + return accounts, nil +} + +func normalizeAccountConcurrency(platform, accountType string, concurrency int) int { + if platform == PlatformGrok && accountType == AccountTypeOAuth { + if concurrency <= 0 { + return 1 + } + } + return concurrency +} + +func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) { + // 绑定分组 + groupIDs := input.GroupIDs + // 如果没有指定分组,自动绑定对应平台的默认分组 + if len(groupIDs) == 0 && !input.SkipDefaultGroupBind { + defaultGroupName := input.Platform + "-default" + groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform) + if err == nil { + for _, g := range groups { + if g.Name == defaultGroupName { + groupIDs = []int64{g.ID} + break + } + } + } + } + + // 检查混合渠道风险(除非用户已确认) + if len(groupIDs) > 0 && !input.SkipMixedChannelCheck { + if err := s.checkMixedChannelRisk(ctx, 0, input.Platform, groupIDs); err != nil { + return nil, err + } + } + + // 校验并规范化请求头覆写配置(header 名小写化、格式检查) + if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { + return nil, err + } + + account := &Account{ + Name: input.Name, + Notes: normalizeAccountNotes(input.Notes), + Platform: input.Platform, + Type: input.Type, + Credentials: input.Credentials, + Extra: input.Extra, + ProxyID: input.ProxyID, + Concurrency: normalizeAccountConcurrency(input.Platform, input.Type, input.Concurrency), + Priority: input.Priority, + Status: StatusActive, + Schedulable: true, + } + // 预计算固定时间重置的下次重置时间 + if account.Extra != nil { + if err := ValidateQuotaResetConfig(account.Extra); err != nil { + return nil, err + } + ComputeQuotaResetAt(account.Extra) + NormalizeFixedQuotaWindows(account.Extra) + } + if input.ExpiresAt != nil && *input.ExpiresAt > 0 { + expiresAt := time.Unix(*input.ExpiresAt, 0) + account.ExpiresAt = &expiresAt + } + if input.AutoPauseOnExpired != nil { + account.AutoPauseOnExpired = *input.AutoPauseOnExpired + } else { + account.AutoPauseOnExpired = true + } + if input.RateMultiplier != nil { + if *input.RateMultiplier < 0 { + return nil, errors.New("rate_multiplier must be >= 0") + } + account.RateMultiplier = input.RateMultiplier + } + if input.LoadFactor != nil && *input.LoadFactor > 0 { + if *input.LoadFactor > 10000 { + return nil, errors.New("load_factor must be <= 10000") + } + account.LoadFactor = input.LoadFactor + } + if err := s.accountRepo.Create(ctx, account); err != nil { + return nil, err + } + + // 绑定分组 + if len(groupIDs) > 0 { + if err := s.accountRepo.BindGroups(ctx, account.ID, groupIDs); err != nil { + return nil, err + } + } + + // OAuth 账号:创建后异步设置隐私。 + // 使用 Ensure(幂等)而非 Force:新建账号 Extra 为空时效果相同,但更安全。 + if account.Type == AccountTypeOAuth { + switch account.Platform { + case PlatformOpenAI: + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("create_account_openai_privacy_panic", "account_id", account.ID, "recover", r) + } + }() + s.EnsureOpenAIPrivacy(context.Background(), account) + }() + case PlatformAntigravity: + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("create_account_antigravity_privacy_panic", "account_id", account.ID, "recover", r) + } + }() + s.EnsureAntigravityPrivacy(context.Background(), account) + }() + } + } + + return account, nil +} + +func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error) { + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + // 安全/身份不变量(影子账号):通用更新路径被 edit/re-auth/refresh/batch 共用, + // 必须在此守住,否则仅在创建时的保证可被这些路径绕过。 + if account.IsCredentialShadow() { + // 影子绝不持有凭据(凭据只在母账号)——外审 F5。 + if !isAllowedSparkShadowCredentialsUpdate(input.Credentials) { + return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS", + "spark shadow accounts do not hold auth credentials; only model mapping can be configured on the shadow account") + } + // 影子 type 不可变——很多上游逻辑按 account.Type 分支(OAuth transform / ChatGPT + // header 注入 / WS OAuth 决策),改成 apikey 会让 spark 影子被选中后按错误协议转发(外审 G7)。 + if input.Type != "" && input.Type != account.Type { + return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_IMMUTABLE_TYPE", + "spark shadow account type cannot be changed; it must remain an OpenAI OAuth shadow") + } + } else if input.Type != "" && input.Type != account.Type && input.Type != AccountTypeOAuth { + // 母账号守卫(外审 D/P1):有 spark 影子的账号不能把 type 改出 OpenAI OAuth——影子读透母 + // 凭据,母变成 apikey/setup_token 会让影子被调度后按错协议失败(resolveCredentialAccount + // 必报错)。须先删影子再改 type。 + shadows, serr := s.accountRepo.ListShadowsByParent(ctx, id) + if serr != nil { + return nil, serr + } + if len(shadows) > 0 { + return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IMMUTABLE_TYPE", + "cannot change account type while it has a spark shadow; delete the shadow first") + } + } + wasOveragesEnabled := account.IsOveragesEnabled() + + if input.Name != "" { + account.Name = input.Name + } + if input.Type != "" { + account.Type = input.Type + } + if input.Notes != nil { + account.Notes = normalizeAccountNotes(input.Notes) + } + if account.IsCredentialShadow() && input.Credentials != nil { + account.Credentials = sanitizeSparkShadowCredentials(input.Credentials) + } else if len(input.Credentials) > 0 { + // 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏, + // 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。 + account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials) + // 校验并规范化请求头覆写配置(header 名小写化、格式检查) + if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil { + return nil, err + } + } + // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。 + // 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。 + if input.Extra != nil { + // 保留配额用量字段,防止编辑账号时意外重置 + for _, key := range []string{"quota_used", "quota_daily_used", "quota_daily_start", "quota_weekly_used", "quota_weekly_start"} { + if v, ok := account.Extra[key]; ok { + input.Extra[key] = v + } + } + account.Extra = input.Extra + if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() { + delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态 + // 清除 AICredits 限流 key + if rawLimits, ok := account.Extra[modelRateLimitsKey].(map[string]any); ok { + delete(rawLimits, creditsExhaustedKey) + } + } + if account.Platform == PlatformAntigravity && !wasOveragesEnabled && account.IsOveragesEnabled() { + delete(account.Extra, modelRateLimitsKey) + delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态 + } + // 校验并预计算固定时间重置的下次重置时间 + if err := ValidateQuotaResetConfig(account.Extra); err != nil { + return nil, err + } + ComputeQuotaResetAt(account.Extra) + NormalizeFixedQuotaWindows(account.Extra) + } + // 影子代理恒继承母账号(由 propagateProxyToShadows 同步),不接受独立编辑——外审 B/P1; + // 否则要等母账号下次改 proxy 才被覆盖,期间影子会出现"有时继承、有时独立"的漂移。 + if input.ProxyID != nil && !account.IsCredentialShadow() { + // 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图) + if *input.ProxyID == 0 { + account.ProxyID = nil + } else { + account.ProxyID = input.ProxyID + } + account.Proxy = nil // 清除关联对象,防止 GORM Save 时根据 Proxy.ID 覆盖 ProxyID + } + // 只在指针非 nil 时更新 Concurrency(支持设置为 0) + if input.Concurrency != nil { + account.Concurrency = normalizeAccountConcurrency(account.Platform, account.Type, *input.Concurrency) + } + // 只在指针非 nil 时更新 Priority(支持设置为 0) + if input.Priority != nil { + account.Priority = *input.Priority + } + if input.RateMultiplier != nil { + if *input.RateMultiplier < 0 { + return nil, errors.New("rate_multiplier must be >= 0") + } + account.RateMultiplier = input.RateMultiplier + } + if input.LoadFactor != nil { + if *input.LoadFactor <= 0 { + account.LoadFactor = nil // 0 或负数表示清除 + } else if *input.LoadFactor > 10000 { + return nil, errors.New("load_factor must be <= 10000") + } else { + account.LoadFactor = input.LoadFactor + } + } + if input.Status != "" { + account.Status = input.Status + } + if input.ExpiresAt != nil { + if *input.ExpiresAt <= 0 { + account.ExpiresAt = nil + } else { + expiresAt := time.Unix(*input.ExpiresAt, 0) + account.ExpiresAt = &expiresAt + } + } + if input.AutoPauseOnExpired != nil { + account.AutoPauseOnExpired = *input.AutoPauseOnExpired + } + + // 先验证分组是否存在(在任何写操作之前) + if input.GroupIDs != nil { + if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil { + return nil, err + } + + // 检查混合渠道风险(除非用户已确认) + if !input.SkipMixedChannelCheck { + if err := s.checkMixedChannelRisk(ctx, account.ID, account.Platform, *input.GroupIDs); err != nil { + return nil, err + } + } + } + + if err := s.accountRepo.Update(ctx, account); err != nil { + return nil, err + } + + // 将 proxy 变更传播到 spark 影子账号(同步;Update 内部已触发调度快照)。 + // 影子自身 proxy 不可独立编辑(见上),故对影子的更新不触发传播。 + if input.ProxyID != nil && !account.IsCredentialShadow() { + if err := s.propagateProxyToShadows(ctx, id, account.ProxyID); err != nil { + return nil, err + } + } + + // 绑定分组 + if input.GroupIDs != nil { + if err := s.accountRepo.BindGroups(ctx, account.ID, *input.GroupIDs); err != nil { + return nil, err + } + } + + // 重新查询以确保返回完整数据(包括正确的 Proxy 关联对象) + updated, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + return updated, nil +} + +// UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键 +// (如 model_rate_limits / passive_usage_* 等)。 +func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error { + if len(updates) == 0 { + return nil + } + return s.accountRepo.UpdateExtra(ctx, id, updates) +} + +// BulkUpdateAccounts updates multiple accounts in one request. +// It merges credentials/extra keys instead of overwriting the whole object. +func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) { + if len(input.AccountIDs) == 0 && input.Filters != nil { + accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters) + if err != nil { + return nil, err + } + input.AccountIDs = accountIDs + } + + result := &BulkUpdateAccountsResult{ + SuccessIDs: make([]int64, 0, len(input.AccountIDs)), + FailedIDs: make([]int64, 0, len(input.AccountIDs)), + Results: make([]BulkUpdateAccountResult, 0, len(input.AccountIDs)), + } + + if len(input.AccountIDs) == 0 { + return result, nil + } + if input.GroupIDs != nil { + if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil { + return nil, err + } + } + + needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck + + // 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。 + var cachedTargets []*Account + if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck { + loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs) + if err != nil { + return nil, err + } + cachedTargets = loaded + } + + // 影子账号绝不持有凭据:批量更新携带凭据时,目标中不得含影子(外审 G5,与单账号 + // UpdateAccount 守卫对齐)。覆盖显式 IDs 与 filter 解析出的 IDs(此处 AccountIDs 已解析完成)。 + if len(input.Credentials) > 0 { + for _, acc := range cachedTargets { + if acc != nil && acc.IsCredentialShadow() { + return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS", + "spark shadow account %d cannot hold credentials; manage credentials on the parent account", acc.ID) + } + } + } + + // 影子账号 proxy 恒继承母账号(与单账号 UpdateAccount 守卫对齐——外审第4轮 P1):批量携带 proxy + // 时目标不得含影子,否则影子会获得独立 proxy、破坏继承不变量(网关按所选影子自身 proxy 出站, + // 要等母账号下次改 proxy 才覆盖→漂移)。含影子即整体拒绝,提示从选择中剔除影子。 + if input.ProxyID != nil { + for _, acc := range cachedTargets { + if acc != nil && acc.IsCredentialShadow() { + return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_PROXY_INHERITED", + "spark shadow account %d proxy is inherited from its parent and cannot be set in bulk; manage it on the parent account", acc.ID) + } + } + } + + // 预加载账号平台信息(混合渠道检查需要)。 + platformByID := map[int64]string{} + if needMixedChannelCheck { + for _, account := range cachedTargets { + if account != nil { + platformByID[account.ID] = account.Platform + } + } + } + + // 预检查混合渠道风险:在任何写操作之前,若发现风险立即返回错误。 + if needMixedChannelCheck { + for _, accountID := range input.AccountIDs { + platform := platformByID[accountID] + if platform == "" { + continue + } + if err := s.checkMixedChannelRisk(ctx, accountID, platform, *input.GroupIDs); err != nil { + return nil, err + } + } + } + + if input.RateMultiplier != nil { + if *input.RateMultiplier < 0 { + return nil, errors.New("rate_multiplier must be >= 0") + } + } + + // 校验并规范化请求头覆写配置(批量路径为 JSONB 顶层 key 合并,直接校验增量即可) + if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { + return nil, err + } + + // Prepare bulk updates for columns and JSONB fields. + repoUpdates := AccountBulkUpdate{ + Credentials: input.Credentials, + Extra: input.Extra, + } + if input.Name != "" { + repoUpdates.Name = &input.Name + } + if input.ProxyID != nil { + repoUpdates.ProxyID = input.ProxyID + } + if input.Concurrency != nil { + repoUpdates.Concurrency = input.Concurrency + } + if input.Priority != nil { + repoUpdates.Priority = input.Priority + } + if input.RateMultiplier != nil { + repoUpdates.RateMultiplier = input.RateMultiplier + } + if input.LoadFactor != nil { + if *input.LoadFactor <= 0 { + repoUpdates.LoadFactor = nil // 0 或负数表示清除 + } else if *input.LoadFactor > 10000 { + return nil, errors.New("load_factor must be <= 10000") + } else { + repoUpdates.LoadFactor = input.LoadFactor + } + } + if input.Status != "" { + repoUpdates.Status = &input.Status + } + if input.Schedulable != nil { + repoUpdates.Schedulable = input.Schedulable + } + + // Run bulk update for column/jsonb fields first. + if _, err := s.accountRepo.BulkUpdate(ctx, input.AccountIDs, repoUpdates); err != nil { + return nil, err + } + + // 将 proxy 变更传播到每个目标账号的 spark 影子账号 + if repoUpdates.ProxyID != nil { + var effectiveProxyID *int64 + if *repoUpdates.ProxyID != 0 { + effectiveProxyID = repoUpdates.ProxyID + } + for _, accountID := range input.AccountIDs { + if err := s.propagateProxyToShadows(ctx, accountID, effectiveProxyID); err != nil { + return nil, err + } + } + } + + // Handle group bindings per account (requires individual operations). + for _, accountID := range input.AccountIDs { + entry := BulkUpdateAccountResult{AccountID: accountID} + + if input.GroupIDs != nil { + if err := s.accountRepo.BindGroups(ctx, accountID, *input.GroupIDs); err != nil { + entry.Success = false + entry.Error = err.Error() + result.Failed++ + result.FailedIDs = append(result.FailedIDs, accountID) + result.Results = append(result.Results, entry) + continue + } + } + + entry.Success = true + result.Success++ + result.SuccessIDs = append(result.SuccessIDs, accountID) + result.Results = append(result.Results, entry) + } + + return result, nil +} + +func (s *adminServiceImpl) resolveBulkUpdateTargetIDs(ctx context.Context, filters *BulkUpdateAccountFilters) ([]int64, error) { + if filters == nil { + return nil, nil + } + + groupID := int64(0) + switch strings.TrimSpace(filters.Group) { + case "": + case "ungrouped": + groupID = AccountListGroupUngrouped + default: + parsedGroupID, err := strconv.ParseInt(strings.TrimSpace(filters.Group), 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid group filter: %w", err) + } + groupID = parsedGroupID + } + + const pageSize = 500 + page := 1 + accountIDs := make([]int64, 0, pageSize) + + for { + accounts, total, err := s.ListAccounts( + ctx, + page, + pageSize, + filters.Platform, + filters.Type, + filters.Status, + filters.Search, + groupID, + filters.PrivacyMode, + "", + "", + ) + if err != nil { + return nil, err + } + for _, account := range accounts { + accountIDs = append(accountIDs, account.ID) + } + if int64(len(accountIDs)) >= total || len(accounts) == 0 { + return accountIDs, nil + } + page++ + } +} + +func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error { + // 级联删除 spark 影子账号(先删影子,再删母账号) + shadows, err := s.accountRepo.ListShadowsByParent(ctx, id) + if err != nil { + return fmt.Errorf("list spark shadows for cascade delete: %w", err) + } + for _, shadow := range shadows { + if err := s.accountRepo.Delete(ctx, shadow.ID); err != nil { + return fmt.Errorf("cascade delete spark shadow %d: %w", shadow.ID, err) + } + } + if err := s.accountRepo.Delete(ctx, id); err != nil { + return err + } + return nil +} + +func (s *adminServiceImpl) RefreshAccountCredentials(ctx context.Context, id int64) (*Account, error) { + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + // TODO: Implement refresh logic + return account, nil +} + +func (s *adminServiceImpl) ClearAccountError(ctx context.Context, id int64) (*Account, error) { + if err := s.accountRepo.ClearError(ctx, id); err != nil { + return nil, err + } + if err := s.accountRepo.ClearRateLimit(ctx, id); err != nil { + return nil, err + } + if err := s.accountRepo.ClearAntigravityQuotaScopes(ctx, id); err != nil { + return nil, err + } + if err := s.accountRepo.ClearModelRateLimits(ctx, id); err != nil { + return nil, err + } + if err := s.accountRepo.ClearTempUnschedulable(ctx, id); err != nil { + return nil, err + } + if s.runtimeBlocker != nil { + s.runtimeBlocker.ClearAccountSchedulingBlock(id) + } + return s.accountRepo.GetByID(ctx, id) +} + +func (s *adminServiceImpl) SetAccountError(ctx context.Context, id int64, errorMsg string) error { + return s.accountRepo.SetError(ctx, id, errorMsg) +} + +func (s *adminServiceImpl) SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error) { + if err := s.accountRepo.SetSchedulable(ctx, id, schedulable); err != nil { + return nil, err + } + updated, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + return updated, nil +} + +func (s *adminServiceImpl) RevertAccountProxyFallback(ctx context.Context, id int64) error { + if err := s.accountRepo.RevertProxyFallback(ctx, id); err != nil { + return err + } + // 加载回退后的账号以获取实际 ProxyID,再传播到影子账号 + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return fmt.Errorf("get account after proxy revert: %w", err) + } + return s.propagateProxyToShadows(ctx, id, account.ProxyID) +} + +// CreateShadow 为指定 OpenAI OAuth 母账号创建 spark 维度影子账号(一母一影)。 +// 安全不变量:Credentials 恒不含 auth token(仅 model_mapping,守卫 isAllowedSparkShadowCredentialsUpdate 放行)。 +func (s *adminServiceImpl) CreateShadow(ctx context.Context, parentID int64, opts ShadowOptions) (*Account, error) { + // 1. 加载母账号并校验平台/类型 + parent, err := s.accountRepo.GetByID(ctx, parentID) + if err != nil { + return nil, fmt.Errorf("get parent account: %w", err) + } + if !parent.IsOpenAIOAuth() { + return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_INVALID_PARENT", + "spark shadow requires an OpenAI OAuth parent account") + } + // G6:母账号本身不能是影子,否则会建出二级影子——resolveCredentialAccount 只解一层, + // 会解析到无凭据的一级影子,进入坏调度/上游失败。 + if parent.IsCredentialShadow() { + return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IS_SHADOW", + "spark shadow parent must be a real account, not another spark shadow") + } + + // 2. 一母一影校验 + shadows, err := s.accountRepo.ListShadowsByParent(ctx, parentID) + if err != nil { + return nil, fmt.Errorf("check existing spark shadows: %w", err) + } + if len(shadows) > 0 { + return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS", + "parent account already has a spark shadow account") + } + + // 3. 解析分组。未指定 GroupIDs 时:优先**继承母账号当前分组**(影子与母同路由域,母在自定义 + // 组时该组的 spark 请求也能选到影子;G1 决策);母无分组再回落 openai-default(F4)。 + // 显式指定 GroupIDs 时,与 UpdateAccount 对齐先校验存在性(创建前),避免建出影子后再因无效组 + // 失败而留下孤儿影子(一母一影唯一索引会挡住重试)——外审 C/P1。 + groupIDs := opts.GroupIDs + if len(groupIDs) > 0 { + if s.groupRepo != nil { + if err := s.validateGroupIDsExist(ctx, groupIDs); err != nil { + return nil, err + } + } + } else if len(parent.GroupIDs) > 0 { + groupIDs = append([]int64(nil), parent.GroupIDs...) + } else if s.groupRepo != nil { + defaultGroupName := PlatformOpenAI + "-default" + if groups, gerr := s.groupRepo.ListActiveByPlatform(ctx, PlatformOpenAI); gerr == nil { + for _, g := range groups { + if g.Name == defaultGroupName { + groupIDs = []int64{g.ID} + break + } + } + } + } + + // 4. 构造影子账号(安全不变量:Credentials 恒不含 auth token,仅含 model_mapping)。 + // name 为空时默认 "<母账号名> (Spark)"——否则空 name 会在 ent(name NotEmpty)处变成裸 500 + // (外审 E/P2);并 rune 安全截断到 ent MaxLen(100)。 + name := strings.TrimSpace(opts.Name) + if name == "" { + name = parent.Name + " (Spark)" + } + if runes := []rune(name); len(runes) > 100 { + name = string(runes[:100]) + } + // 并发未指定(<=0)时继承母账号,避免 0 被限流器解读为"无限并发"(外审 F3)。 + concurrency := opts.Concurrency + if concurrency <= 0 { + concurrency = parent.Concurrency + } + // 优先级未指定(<=0)时继承母账号——前端一键创建只传 name,opts.Priority 省略即 0,而调度 + // 比较是「数值越小越优先」(openai_account_scheduler.isOpenAIAccountCandidateBetter),且 repo + // 显式 SetPriority 会绕过 ent 默认 50,直写 0 会让影子意外抢到最高优先级(外审第5轮 P1)。 + // 与上方 Concurrency 一致采用「省略继承母账号」语义(影子的 proxy/分组/并发亦全部继承母账号)。 + priority := opts.Priority + if priority <= 0 { + priority = parent.Priority + } + shadow := &Account{ + Name: name, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{"model_mapping": defaultSparkShadowModelMapping()}, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + ProxyID: parent.ProxyID, + Priority: priority, + Concurrency: concurrency, + Schedulable: true, + } + + // 5. 持久化(Create 填充 shadow.ID)。并发竞态:预查(步骤2)放行后另一请求抢先建成,本次会撞 + // 一母一影唯一索引。复查确认确为"已存在"竞态时返回结构化 409 而非裸 500——外审 A/P1。 + if err := s.accountRepo.Create(ctx, shadow); err != nil { + if existing, qerr := s.accountRepo.ListShadowsByParent(ctx, parentID); qerr == nil && len(existing) > 0 { + return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS", + "parent account already has a spark shadow account") + } + return nil, fmt.Errorf("create spark shadow: %w", err) + } + + // 6. 绑定分组。注意:create+bind 非单一 DB 事务(通用 Create 走 r.client、outbox 走 r.sql, + // 无现成共享事务路径),故绑组失败时做 best-effort 补偿删除刚建的影子,避免半成品影子(否则 + // 一母一影唯一索引会挡住重试)——外审 C/P1。补偿删除用 detached ctx,即便请求 ctx 已取消/超时 + // 仍能完成清理(外审第4轮);进程崩溃这种极端仍可能残留,属已知权衡。 + if len(groupIDs) > 0 { + if err := s.accountRepo.BindGroups(ctx, shadow.ID, groupIDs); err != nil { + if delErr := s.accountRepo.Delete(context.WithoutCancel(ctx), shadow.ID); delErr != nil { + slog.Error("spark_shadow_bind_groups_rollback_failed", + "shadow_id", shadow.ID, "parent_id", parentID, "delete_err", delErr) + } + return nil, fmt.Errorf("bind groups for spark shadow: %w", err) + } + shadow.GroupIDs = groupIDs + } + + return shadow, nil +} + +// propagateProxyToShadows syncs proxyID to all spark shadow accounts of parentID. +// It is called synchronously so that proxy changes are immediately consistent; +// accountRepo.Update triggers the scheduler outbox + cache propagation internally. +// Calling this for a non-parent account is a harmless no-op. +func (s *adminServiceImpl) propagateProxyToShadows(ctx context.Context, parentID int64, proxyID *int64) error { + return propagateAccountProxyToShadows(ctx, s.accountRepo, parentID, proxyID) +} + +// propagateAccountProxyToShadows 把母账号的 proxy 同步到其所有 spark 影子(影子 proxy 恒继承母账号)。 +// 供 AdminService 编辑路径与 CRS 同步路径共用——后者改动母账号 proxy 后必须同样传播,否则影子保留 +// 旧 proxy 出现出站漂移(外审第8轮)。 +func propagateAccountProxyToShadows(ctx context.Context, repo AccountRepository, parentID int64, proxyID *int64) error { + shadows, err := repo.ListShadowsByParent(ctx, parentID) + if err != nil { + return fmt.Errorf("list spark shadows for proxy propagation: %w", err) + } + for _, shadow := range shadows { + shadow.ProxyID = proxyID + if err := repo.Update(ctx, shadow); err != nil { + return fmt.Errorf("update spark shadow %d proxy: %w", shadow.ID, err) + } + } + return nil +} + +// checkMixedChannelRisk 检查分组中是否存在混合渠道(Antigravity + Anthropic) +// 如果存在混合,返回错误提示用户确认 +func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { + // 判断当前账号的渠道类型(基于 platform 字段,而不是 type 字段) + currentPlatform := getAccountPlatform(currentAccountPlatform) + if currentPlatform == "" { + // 不是 Antigravity 或 Anthropic,无需检查 + return nil + } + + // 检查每个分组中的其他账号 + for _, groupID := range groupIDs { + accounts, err := s.accountRepo.ListByGroup(ctx, groupID) + if err != nil { + return fmt.Errorf("get accounts in group %d: %w", groupID, err) + } + + // 检查是否存在不同渠道的账号 + for _, account := range accounts { + if currentAccountID > 0 && account.ID == currentAccountID { + continue // 跳过当前账号 + } + + otherPlatform := getAccountPlatform(account.Platform) + if otherPlatform == "" { + continue // 不是 Antigravity 或 Anthropic,跳过 + } + + // 检测混合渠道 + if currentPlatform != otherPlatform { + group, _ := s.groupRepo.GetByID(ctx, groupID) + groupName := fmt.Sprintf("Group %d", groupID) + if group != nil { + groupName = group.Name + } + + return &MixedChannelError{ + GroupID: groupID, + GroupName: groupName, + CurrentPlatform: currentPlatform, + OtherPlatform: otherPlatform, + } + } + } + } + + return nil +} + +func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs []int64) error { + if len(groupIDs) == 0 { + return nil + } + if s.groupRepo == nil { + return errors.New("group repository not configured") + } + + if batchReader, ok := s.groupRepo.(groupExistenceBatchReader); ok { + existsByID, err := batchReader.ExistsByIDs(ctx, groupIDs) + if err != nil { + return fmt.Errorf("check groups exists: %w", err) + } + for _, groupID := range groupIDs { + if groupID <= 0 || !existsByID[groupID] { + return fmt.Errorf("get group: %w", ErrGroupNotFound) + } + } + return nil + } + + for _, groupID := range groupIDs { + if _, err := s.groupRepo.GetByID(ctx, groupID); err != nil { + return fmt.Errorf("get group: %w", err) + } + } + return nil +} + +// CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform. +func (s *adminServiceImpl) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { + return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs) +} + +// getAccountPlatform 根据账号 platform 判断混合渠道检查用的平台标识 +func getAccountPlatform(accountPlatform string) string { + switch strings.ToLower(strings.TrimSpace(accountPlatform)) { + case PlatformAntigravity: + return "Antigravity" + case PlatformAnthropic, "claude": + return "Anthropic" + default: + return "" + } +} + +// MixedChannelError 混合渠道错误 +type MixedChannelError struct { + GroupID int64 + GroupName string + CurrentPlatform string + OtherPlatform string +} + +func (e *MixedChannelError) Error() string { + return fmt.Sprintf("mixed_channel_warning: Group '%s' contains both %s and %s accounts. Using mixed channels in the same context may cause thinking block signature validation issues, which will fallback to non-thinking mode for historical messages.", + e.GroupName, e.CurrentPlatform, e.OtherPlatform) +} + +func (s *adminServiceImpl) ResetAccountQuota(ctx context.Context, id int64) error { + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return err + } + // spark 影子账号不持自有配额(凭据透传母账号、spark 用量走独立 codex_* 维度由 QueryUsage 维护), + // 通用 quota 重置对其无意义且语义不一致——明确 400 拒绝(与 OpenAI reset-credit 对影子一致)(外审第7轮 P2)。 + if account.IsCredentialShadow() { + return infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_NO_QUOTA_RESET", + "cannot reset quota for a spark shadow account; manage it on the parent account") + } + return s.accountRepo.ResetQuotaUsed(ctx, id) +} + +// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号是否已设置 privacy_mode, +// 未设置则调用 disableOpenAITraining 并持久化到 Extra,返回设置的 mode 值。 +func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Account) string { + // 影子账号不持凭据,隐私设置由母账号管理,直接跳过。 + if account.IsCredentialShadow() { + return "" + } + if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { + return "" + } + if s.privacyClientFactory == nil { + return "" + } + if shouldSkipOpenAIPrivacyEnsure(account.Extra) { + return "" + } + + token, _ := account.Credentials["access_token"].(string) + if token == "" { + return "" + } + + var proxyURL string + if account.ProxyID != nil { + if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { + proxyURL = p.URL() + } + } + + mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL) + if mode == "" { + return "" + } + + _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}) + return mode +} + +// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。 +func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Account) string { + // 影子账号不持凭据,隐私由母账号管理,直接跳过(与 EnsureOpenAIPrivacy 一致——外审第4轮)。 + if account.IsCredentialShadow() { + return "" + } + if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { + return "" + } + if s.privacyClientFactory == nil { + return "" + } + + token, _ := account.Credentials["access_token"].(string) + if token == "" { + return "" + } + + var proxyURL string + if account.ProxyID != nil { + if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { + proxyURL = p.URL() + } + } + + mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL) + if mode == "" { + return "" + } + + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { + logger.LegacyPrintf("service.admin", "force_update_openai_privacy_mode_failed: account_id=%d err=%v", account.ID, err) + return mode + } + if account.Extra == nil { + account.Extra = make(map[string]any) + } + account.Extra["privacy_mode"] = mode + return mode +} + +// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。 +// 仅当 privacy_mode 已成功设置("privacy_set")时跳过; +// 未设置或之前失败("privacy_set_failed")均会重试。 +func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account *Account) string { + if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth { + return "" + } + if account.Extra != nil { + if existing, ok := account.Extra["privacy_mode"].(string); ok && existing == AntigravityPrivacySet { + return existing + } + } + + token, _ := account.Credentials["access_token"].(string) + if token == "" { + return "" + } + + projectID, _ := account.Credentials["project_id"].(string) + + var proxyURL string + if account.ProxyID != nil { + if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { + proxyURL = p.URL() + } + } + + mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL) + if mode == "" { + return "" + } + + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { + logger.LegacyPrintf("service.admin", "update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err) + return mode + } + applyAntigravityPrivacyMode(account, mode) + return mode +} + +// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。 +func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account *Account) string { + if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth { + return "" + } + + token, _ := account.Credentials["access_token"].(string) + if token == "" { + return "" + } + + projectID, _ := account.Credentials["project_id"].(string) + + var proxyURL string + if account.ProxyID != nil { + if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { + proxyURL = p.URL() + } + } + + mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL) + if mode == "" { + return "" + } + + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { + logger.LegacyPrintf("service.admin", "force_update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err) + return mode + } + applyAntigravityPrivacyMode(account, mode) + return mode +} diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go new file mode 100644 index 0000000000..43f7508722 --- /dev/null +++ b/backend/internal/service/admin_group.go @@ -0,0 +1,965 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" +) + +// Group management implementations +func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} + groups, result, err := s.groupRepo.ListWithFilters(ctx, params, platform, status, search, isExclusive) + if err != nil { + return nil, 0, err + } + return groups, result.Total, nil +} + +func (s *adminServiceImpl) GetAllGroups(ctx context.Context) ([]Group, error) { + return s.groupRepo.ListActive(ctx) +} + +func (s *adminServiceImpl) GetAllGroupsByPlatform(ctx context.Context, platform string) ([]Group, error) { + return s.groupRepo.ListActiveByPlatform(ctx, platform) +} + +func (s *adminServiceImpl) GetAllGroupsIncludingInactive(ctx context.Context) ([]Group, error) { + // ListWithFilters with empty status = no status filter, so active + disabled groups are returned. + // PageSize 10000 is intentionally large; group count is O(dozens) in practice. + groups, _, err := s.groupRepo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10000}, "", "", "", nil) + return groups, err +} + +func (s *adminServiceImpl) GetGroup(ctx context.Context, id int64) (*Group, error) { + return s.groupRepo.GetByID(ctx, id) +} + +func (s *adminServiceImpl) GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error) { + platform = strings.TrimSpace(platform) + if id > 0 { + group, err := s.groupRepo.GetByIDLite(ctx, id) + if err != nil { + return nil, err + } + if platform == "" { + platform = group.Platform + } + } + if platform == "" { + platform = PlatformAnthropic + } + + candidates := defaultModelsListCandidateIDs(platform) + if id <= 0 || s.accountRepo == nil { + return candidates, nil + } + + accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, id) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}, len(candidates)) + for _, model := range candidates { + seen[model] = struct{}{} + } + for _, acc := range accounts { + if acc.Platform != platform { + continue + } + for model := range acc.GetModelMapping() { + model = strings.TrimSpace(model) + if model == "" { + continue + } + if _, ok := seen[model]; ok { + continue + } + seen[model] = struct{}{} + candidates = append(candidates, model) + } + } + return candidates, nil +} + +func defaultModelsListCandidateIDs(platform string) []string { + switch platform { + case PlatformOpenAI: + return openai.DefaultModelIDs() + case PlatformGemini: + ids := make([]string, 0, len(geminicli.DefaultModels)) + for _, model := range geminicli.DefaultModels { + ids = append(ids, model.ID) + } + return ids + case PlatformAntigravity: + models := antigravity.DefaultModels() + ids := make([]string, 0, len(models)) + for _, model := range models { + ids = append(ids, model.ID) + } + return ids + case PlatformGrok: + return xai.DefaultModelIDs() + default: + ids := make([]string, 0, len(claude.DefaultModels)) + for _, model := range claude.DefaultModels { + ids = append(ids, model.ID) + } + return ids + } +} + +func defaultAllowImageGenerationForPlatform(platform string) bool { + // Grok image and video generation routes share the legacy image-generation gate. + // Older clients send the false zero value, so Grok groups must default enabled. + return platform == PlatformGrok +} + +func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) { + if input.RateMultiplier <= 0 { + return nil, errors.New("rate_multiplier must be > 0") + } + + platform := input.Platform + if platform == "" { + platform = PlatformAnthropic + } + + subscriptionType := input.SubscriptionType + if subscriptionType == "" { + subscriptionType = SubscriptionTypeStandard + } + + // 限额字段:nil/负数 表示"无限制",0 表示"不允许用量",正数表示具体限额 + dailyLimit := normalizeLimit(input.DailyLimitUSD) + weeklyLimit := normalizeLimit(input.WeeklyLimitUSD) + monthlyLimit := normalizeLimit(input.MonthlyLimitUSD) + + // 图片价格:负数表示清除(使用默认价格),0 保留(表示免费) + imagePrice1K := normalizePrice(input.ImagePrice1K) + imagePrice2K := normalizePrice(input.ImagePrice2K) + imagePrice4K := normalizePrice(input.ImagePrice4K) + imageRateMultiplier := 1.0 + if input.ImageRateMultiplier != nil { + if *input.ImageRateMultiplier < 0 { + return nil, errors.New("image_rate_multiplier must be >= 0") + } + imageRateMultiplier = *input.ImageRateMultiplier + } + batchImageDiscountMultiplier := defaultBatchImageDiscountMultiplier + if input.BatchImageDiscountMultiplier != nil { + if *input.BatchImageDiscountMultiplier < 0 { + return nil, errors.New("batch_image_discount_multiplier must be >= 0") + } + batchImageDiscountMultiplier = *input.BatchImageDiscountMultiplier + } + batchImageHoldMultiplier := defaultBatchImageHoldMultiplier + if input.BatchImageHoldMultiplier != nil { + if *input.BatchImageHoldMultiplier < 0 { + return nil, errors.New("batch_image_hold_multiplier must be >= 0") + } + batchImageHoldMultiplier = *input.BatchImageHoldMultiplier + } + // 不变式:hold 比例 >= discount 比例。否则批量任务成功率足够高时 + // 实际成本会超过冻结额,结算永远失败、用户冻结余额无法解冻。 + if batchImageHoldMultiplier < batchImageDiscountMultiplier { + return nil, errors.New("batch_image_hold_multiplier must be >= batch_image_discount_multiplier") + } + + peakRateMultiplier := 1.0 + if input.PeakRateMultiplier != nil { + peakRateMultiplier = *input.PeakRateMultiplier + } + // 先归一化(非订阅分组清空高峰配置、清洗停用状态下的脏字段)再校验,与 UpdateGroup 同一收口。 + peakRateEnabled, peakStart, peakEnd, peakRateMultiplier := NormalizePeakRateConfig(subscriptionType, input.PeakRateEnabled, input.PeakStart, input.PeakEnd, peakRateMultiplier) + if err := ValidatePeakRateConfig(subscriptionType, peakRateEnabled, peakStart, peakEnd, peakRateMultiplier); err != nil { + return nil, err + } + + // 校验降级分组 + if input.FallbackGroupID != nil { + if err := s.validateFallbackGroup(ctx, 0, *input.FallbackGroupID); err != nil { + return nil, err + } + } + fallbackOnInvalidRequest := input.FallbackGroupIDOnInvalidRequest + if fallbackOnInvalidRequest != nil && *fallbackOnInvalidRequest <= 0 { + fallbackOnInvalidRequest = nil + } + // 校验无效请求兜底分组 + if fallbackOnInvalidRequest != nil { + if err := s.validateFallbackGroupOnInvalidRequest(ctx, 0, platform, subscriptionType, *fallbackOnInvalidRequest); err != nil { + return nil, err + } + } + + // MCPXMLInject:默认为 true,仅当显式传入 false 时关闭 + mcpXMLInject := true + if input.MCPXMLInject != nil { + mcpXMLInject = *input.MCPXMLInject + } + + allowImageGeneration := input.AllowImageGeneration || defaultAllowImageGenerationForPlatform(platform) + allowBatchImageGeneration := input.AllowBatchImageGeneration && allowImageGeneration && platform == PlatformGemini + + // 如果指定了复制账号的源分组,先获取账号 ID 列表 + var accountIDsToCopy []int64 + if len(input.CopyAccountsFromGroupIDs) > 0 { + // 去重源分组 IDs + seen := make(map[int64]struct{}) + uniqueSourceGroupIDs := make([]int64, 0, len(input.CopyAccountsFromGroupIDs)) + for _, srcGroupID := range input.CopyAccountsFromGroupIDs { + if _, exists := seen[srcGroupID]; !exists { + seen[srcGroupID] = struct{}{} + uniqueSourceGroupIDs = append(uniqueSourceGroupIDs, srcGroupID) + } + } + + // 校验源分组的平台是否与新分组一致 + for _, srcGroupID := range uniqueSourceGroupIDs { + srcGroup, err := s.groupRepo.GetByIDLite(ctx, srcGroupID) + if err != nil { + return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err) + } + if srcGroup.Platform != platform { + return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, platform, srcGroup.Platform) + } + } + + // 获取所有源分组的账号(去重) + var err error + accountIDsToCopy, err = s.groupRepo.GetAccountIDsByGroupIDs(ctx, uniqueSourceGroupIDs) + if err != nil { + return nil, fmt.Errorf("failed to get accounts from source groups: %w", err) + } + } + + group := &Group{ + Name: input.Name, + Description: input.Description, + Platform: platform, + RateMultiplier: input.RateMultiplier, + IsExclusive: input.IsExclusive, + Status: StatusActive, + SubscriptionType: subscriptionType, + DailyLimitUSD: dailyLimit, + WeeklyLimitUSD: weeklyLimit, + MonthlyLimitUSD: monthlyLimit, + AllowImageGeneration: allowImageGeneration, + AllowBatchImageGeneration: allowBatchImageGeneration, + ImageRateIndependent: input.ImageRateIndependent, + ImageRateMultiplier: imageRateMultiplier, + BatchImageDiscountMultiplier: batchImageDiscountMultiplier, + BatchImageHoldMultiplier: batchImageHoldMultiplier, + PeakRateEnabled: peakRateEnabled, + PeakStart: peakStart, + PeakEnd: peakEnd, + PeakRateMultiplier: peakRateMultiplier, + ImagePrice1K: imagePrice1K, + ImagePrice2K: imagePrice2K, + ImagePrice4K: imagePrice4K, + ClaudeCodeOnly: input.ClaudeCodeOnly, + FallbackGroupID: input.FallbackGroupID, + FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest, + ModelRouting: input.ModelRouting, + MCPXMLInject: mcpXMLInject, + SupportedModelScopes: input.SupportedModelScopes, + AllowMessagesDispatch: input.AllowMessagesDispatch, + RequireOAuthOnly: input.RequireOAuthOnly, + RequirePrivacySet: input.RequirePrivacySet, + DefaultMappedModel: input.DefaultMappedModel, + MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig), + ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig), + RPMLimit: input.RPMLimit, + } + sanitizeGroupMessagesDispatchFields(group) + if err := s.groupRepo.Create(ctx, group); err != nil { + return nil, err + } + + // require_oauth_only: 过滤掉 apikey 类型账号 + if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && len(accountIDsToCopy) > 0 { + accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy) + if err != nil { + return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err) + } + oauthIDs := make(map[int64]struct{}, len(accounts)) + for _, acc := range accounts { + if acc.Type != AccountTypeAPIKey { + oauthIDs[acc.ID] = struct{}{} + } + } + var filtered []int64 + for _, aid := range accountIDsToCopy { + if _, ok := oauthIDs[aid]; ok { + filtered = append(filtered, aid) + } + } + accountIDsToCopy = filtered + } + + // 如果有需要复制的账号,绑定到新分组 + if len(accountIDsToCopy) > 0 { + if err := s.groupRepo.BindAccountsToGroup(ctx, group.ID, accountIDsToCopy); err != nil { + return nil, fmt.Errorf("failed to bind accounts to new group: %w", err) + } + group.AccountCount = int64(len(accountIDsToCopy)) + } + + return group, nil +} + +// normalizeLimit 将负数转换为 nil(表示无限制),0 保留(表示限额为零) +func normalizeLimit(limit *float64) *float64 { + if limit == nil || *limit < 0 { + return nil + } + return limit +} + +// normalizePrice 将负数转换为 nil(表示使用默认价格),0 保留(表示免费) +func normalizePrice(price *float64) *float64 { + if price == nil || *price < 0 { + return nil + } + return price +} + +// validateFallbackGroup 校验降级分组的有效性 +// currentGroupID: 当前分组 ID(新建时为 0) +// fallbackGroupID: 降级分组 ID +func (s *adminServiceImpl) validateFallbackGroup(ctx context.Context, currentGroupID, fallbackGroupID int64) error { + // 不能将自己设置为降级分组 + if currentGroupID > 0 && currentGroupID == fallbackGroupID { + return fmt.Errorf("cannot set self as fallback group") + } + + visited := map[int64]struct{}{} + nextID := fallbackGroupID + for { + if _, seen := visited[nextID]; seen { + return fmt.Errorf("fallback group cycle detected") + } + visited[nextID] = struct{}{} + if currentGroupID > 0 && nextID == currentGroupID { + return fmt.Errorf("fallback group cycle detected") + } + + // 检查降级分组是否存在 + fallbackGroup, err := s.groupRepo.GetByIDLite(ctx, nextID) + if err != nil { + return fmt.Errorf("fallback group not found: %w", err) + } + + // 降级分组不能启用 claude_code_only,否则会造成死循环 + if nextID == fallbackGroupID && fallbackGroup.ClaudeCodeOnly { + return fmt.Errorf("fallback group cannot have claude_code_only enabled") + } + + if fallbackGroup.FallbackGroupID == nil { + return nil + } + nextID = *fallbackGroup.FallbackGroupID + } +} + +// validateFallbackGroupOnInvalidRequest 校验无效请求兜底分组的有效性 +// currentGroupID: 当前分组 ID(新建时为 0) +// platform/subscriptionType: 当前分组的有效平台/订阅类型 +// fallbackGroupID: 兜底分组 ID +func (s *adminServiceImpl) validateFallbackGroupOnInvalidRequest(ctx context.Context, currentGroupID int64, platform, subscriptionType string, fallbackGroupID int64) error { + if platform != PlatformAnthropic && platform != PlatformAntigravity { + return fmt.Errorf("invalid request fallback only supported for anthropic or antigravity groups") + } + if subscriptionType == SubscriptionTypeSubscription { + return fmt.Errorf("subscription groups cannot set invalid request fallback") + } + if currentGroupID > 0 && currentGroupID == fallbackGroupID { + return fmt.Errorf("cannot set self as invalid request fallback group") + } + + fallbackGroup, err := s.groupRepo.GetByIDLite(ctx, fallbackGroupID) + if err != nil { + return fmt.Errorf("fallback group not found: %w", err) + } + if fallbackGroup.Platform != PlatformAnthropic { + return fmt.Errorf("fallback group must be anthropic platform") + } + if fallbackGroup.SubscriptionType == SubscriptionTypeSubscription { + return fmt.Errorf("fallback group cannot be subscription type") + } + if fallbackGroup.FallbackGroupIDOnInvalidRequest != nil { + return fmt.Errorf("fallback group cannot have invalid request fallback configured") + } + return nil +} + +func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) { + group, err := s.groupRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if input.Name != "" { + group.Name = input.Name + } + if input.Description != nil { + group.Description = *input.Description + } + if input.Platform != "" { + group.Platform = input.Platform + } + if input.RateMultiplier != nil { + if *input.RateMultiplier <= 0 { + return nil, errors.New("rate_multiplier must be > 0") + } + group.RateMultiplier = *input.RateMultiplier + } + if input.IsExclusive != nil { + group.IsExclusive = *input.IsExclusive + } + if input.Status != "" { + group.Status = input.Status + } + + // 订阅相关字段 + if input.SubscriptionType != "" { + group.SubscriptionType = input.SubscriptionType + } + // 限额字段:nil/负数 表示"无限制",0 表示"不允许用量",正数表示具体限额 + // 前端始终发送这三个字段,无需 nil 守卫 + group.DailyLimitUSD = normalizeLimit(input.DailyLimitUSD) + group.WeeklyLimitUSD = normalizeLimit(input.WeeklyLimitUSD) + group.MonthlyLimitUSD = normalizeLimit(input.MonthlyLimitUSD) + // 图片生成计费配置:负数表示清除(使用默认价格) + if input.AllowImageGeneration != nil { + group.AllowImageGeneration = *input.AllowImageGeneration + } + if input.AllowBatchImageGeneration != nil { + group.AllowBatchImageGeneration = *input.AllowBatchImageGeneration + } + if !group.AllowImageGeneration || group.Platform != PlatformGemini { + group.AllowBatchImageGeneration = false + } + if input.ImageRateIndependent != nil { + group.ImageRateIndependent = *input.ImageRateIndependent + } + if input.ImageRateMultiplier != nil { + if *input.ImageRateMultiplier < 0 { + return nil, errors.New("image_rate_multiplier must be >= 0") + } + group.ImageRateMultiplier = *input.ImageRateMultiplier + } + if input.BatchImageDiscountMultiplier != nil { + if *input.BatchImageDiscountMultiplier < 0 { + return nil, errors.New("batch_image_discount_multiplier must be >= 0") + } + group.BatchImageDiscountMultiplier = *input.BatchImageDiscountMultiplier + } + if input.BatchImageHoldMultiplier != nil { + if *input.BatchImageHoldMultiplier < 0 { + return nil, errors.New("batch_image_hold_multiplier must be >= 0") + } + group.BatchImageHoldMultiplier = *input.BatchImageHoldMultiplier + } + // 仅在本次更新显式触碰任一比例时校验合并后的不变式(hold >= discount), + // 避免存量脏数据阻塞其他字段的正常更新(提交侧另有钳制兜底)。 + if (input.BatchImageDiscountMultiplier != nil || input.BatchImageHoldMultiplier != nil) && + group.BatchImageHoldMultiplier < group.BatchImageDiscountMultiplier { + return nil, errors.New("batch_image_hold_multiplier must be >= batch_image_discount_multiplier") + } + if input.PeakRateEnabled != nil { + group.PeakRateEnabled = *input.PeakRateEnabled + } + if input.PeakStart != nil { + group.PeakStart = *input.PeakStart + } + if input.PeakEnd != nil { + group.PeakEnd = *input.PeakEnd + } + if input.PeakRateMultiplier != nil { + group.PeakRateMultiplier = *input.PeakRateMultiplier + } + // 先归一化(非订阅分组——含本次更新转为非订阅——静默清空高峰配置,清洗停用状态下的脏字段), + // 再收敛校验:Update 可能只传部分 peak 字段,需对合并后的最终配置统一校验, + // 防止单独修改 start/end 导致最终 start>=end 等非法配置入库。与 CreateGroup 同一收口。 + group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier = NormalizePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier) + if err := ValidatePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier); err != nil { + return nil, err + } + if input.ImagePrice1K != nil { + group.ImagePrice1K = normalizePrice(input.ImagePrice1K) + } + if input.ImagePrice2K != nil { + group.ImagePrice2K = normalizePrice(input.ImagePrice2K) + } + if input.ImagePrice4K != nil { + group.ImagePrice4K = normalizePrice(input.ImagePrice4K) + } + + // Claude Code 客户端限制 + if input.ClaudeCodeOnly != nil { + group.ClaudeCodeOnly = *input.ClaudeCodeOnly + } + if input.FallbackGroupID != nil { + // 校验降级分组 + if *input.FallbackGroupID > 0 { + if err := s.validateFallbackGroup(ctx, id, *input.FallbackGroupID); err != nil { + return nil, err + } + group.FallbackGroupID = input.FallbackGroupID + } else { + // 传入 0 或负数表示清除降级分组 + group.FallbackGroupID = nil + } + } + fallbackOnInvalidRequest := group.FallbackGroupIDOnInvalidRequest + if input.FallbackGroupIDOnInvalidRequest != nil { + if *input.FallbackGroupIDOnInvalidRequest > 0 { + fallbackOnInvalidRequest = input.FallbackGroupIDOnInvalidRequest + } else { + fallbackOnInvalidRequest = nil + } + } + if fallbackOnInvalidRequest != nil { + if err := s.validateFallbackGroupOnInvalidRequest(ctx, id, group.Platform, group.SubscriptionType, *fallbackOnInvalidRequest); err != nil { + return nil, err + } + } + group.FallbackGroupIDOnInvalidRequest = fallbackOnInvalidRequest + + // 模型路由配置 + if input.ModelRouting != nil { + group.ModelRouting = input.ModelRouting + } + if input.ModelRoutingEnabled != nil { + group.ModelRoutingEnabled = *input.ModelRoutingEnabled + } + if input.MCPXMLInject != nil { + group.MCPXMLInject = *input.MCPXMLInject + } + + // 支持的模型系列(仅 antigravity 平台使用) + if input.SupportedModelScopes != nil { + group.SupportedModelScopes = *input.SupportedModelScopes + } + + // OpenAI Messages 调度配置 + if input.AllowMessagesDispatch != nil { + group.AllowMessagesDispatch = *input.AllowMessagesDispatch + } + if input.RequireOAuthOnly != nil { + group.RequireOAuthOnly = *input.RequireOAuthOnly + } + if input.RequirePrivacySet != nil { + group.RequirePrivacySet = *input.RequirePrivacySet + } + if input.DefaultMappedModel != nil { + group.DefaultMappedModel = *input.DefaultMappedModel + } + if input.MessagesDispatchModelConfig != nil { + group.MessagesDispatchModelConfig = normalizeOpenAIMessagesDispatchModelConfig(*input.MessagesDispatchModelConfig) + } + if input.ModelsListConfig != nil { + group.ModelsListConfig = normalizeGroupModelsListConfig(*input.ModelsListConfig) + } + if input.RPMLimit != nil { + group.RPMLimit = *input.RPMLimit + } + sanitizeGroupMessagesDispatchFields(group) + + if err := s.groupRepo.Update(ctx, group); err != nil { + return nil, err + } + + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id) + } + + // 如果指定了复制账号的源分组,同步绑定(替换当前分组的账号) + if len(input.CopyAccountsFromGroupIDs) > 0 { + // 去重源分组 IDs + seen := make(map[int64]struct{}) + uniqueSourceGroupIDs := make([]int64, 0, len(input.CopyAccountsFromGroupIDs)) + for _, srcGroupID := range input.CopyAccountsFromGroupIDs { + // 校验:源分组不能是自身 + if srcGroupID == id { + return nil, fmt.Errorf("cannot copy accounts from self") + } + // 去重 + if _, exists := seen[srcGroupID]; !exists { + seen[srcGroupID] = struct{}{} + uniqueSourceGroupIDs = append(uniqueSourceGroupIDs, srcGroupID) + } + } + + // 校验源分组的平台是否与当前分组一致 + for _, srcGroupID := range uniqueSourceGroupIDs { + srcGroup, err := s.groupRepo.GetByIDLite(ctx, srcGroupID) + if err != nil { + return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err) + } + if srcGroup.Platform != group.Platform { + return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, group.Platform, srcGroup.Platform) + } + } + + // 获取所有源分组的账号(去重) + accountIDsToCopy, err := s.groupRepo.GetAccountIDsByGroupIDs(ctx, uniqueSourceGroupIDs) + if err != nil { + return nil, fmt.Errorf("failed to get accounts from source groups: %w", err) + } + + // 先清空当前分组的所有账号绑定 + if _, err := s.groupRepo.DeleteAccountGroupsByGroupID(ctx, id); err != nil { + return nil, fmt.Errorf("failed to clear existing account bindings: %w", err) + } + + // require_oauth_only: 过滤掉 apikey 类型账号 + if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && len(accountIDsToCopy) > 0 { + accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy) + if err != nil { + return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err) + } + oauthIDs := make(map[int64]struct{}, len(accounts)) + for _, acc := range accounts { + if acc.Type != AccountTypeAPIKey { + oauthIDs[acc.ID] = struct{}{} + } + } + var filtered []int64 + for _, aid := range accountIDsToCopy { + if _, ok := oauthIDs[aid]; ok { + filtered = append(filtered, aid) + } + } + accountIDsToCopy = filtered + } + + // 再绑定源分组的账号 + if len(accountIDsToCopy) > 0 { + if err := s.groupRepo.BindAccountsToGroup(ctx, id, accountIDsToCopy); err != nil { + return nil, fmt.Errorf("failed to bind accounts to group: %w", err) + } + } + } + + return group, nil +} + +func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error { + var groupKeys []string + if s.authCacheInvalidator != nil { + keys, err := s.apiKeyRepo.ListKeysByGroupID(ctx, id) + if err == nil { + groupKeys = keys + } + } + + affectedUserIDs, err := s.groupRepo.DeleteCascade(ctx, id) + if err != nil { + return err + } + // 注意:user_group_rate_multipliers 表通过外键 ON DELETE CASCADE 自动清理 + + // 事务成功后,异步失效受影响用户的订阅缓存 + if len(affectedUserIDs) > 0 && s.billingCacheService != nil { + groupID := id + go func() { + cacheCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for _, userID := range affectedUserIDs { + if err := s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID); err != nil { + logger.LegacyPrintf("service.admin", "invalidate subscription cache failed: user_id=%d group_id=%d err=%v", userID, groupID, err) + } + } + }() + } + if s.authCacheInvalidator != nil { + for _, key := range groupKeys { + s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, key) + } + } + + return nil +} + +func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize} + keys, result, err := s.apiKeyRepo.ListByGroupID(ctx, groupID, params) + if err != nil { + return nil, 0, err + } + return keys, result.Total, nil +} + +func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error) { + if s.userGroupRateRepo == nil { + return nil, nil + } + return s.userGroupRateRepo.GetByGroupID(ctx, groupID) +} + +func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupID int64) error { + if s.userGroupRateRepo == nil { + return nil + } + return s.userGroupRateRepo.DeleteByGroupID(ctx, groupID) +} + +func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error { + if s.userGroupRateRepo == nil { + return nil + } + for _, e := range entries { + if e.RateMultiplier <= 0 { + return fmt.Errorf("rate_multiplier must be > 0 (user_id=%d)", e.UserID) + } + } + return s.userGroupRateRepo.SyncGroupRateMultipliers(ctx, groupID, entries) +} + +func (s *adminServiceImpl) ClearGroupRPMOverrides(ctx context.Context, groupID int64) error { + if s.userGroupRateRepo == nil { + return nil + } + if err := s.userGroupRateRepo.ClearGroupRPMOverrides(ctx, groupID); err != nil { + return err + } + // RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。 + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, groupID) + } + return nil +} + +func (s *adminServiceImpl) BatchSetGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error { + if s.userGroupRateRepo == nil { + return nil + } + for _, e := range entries { + if e.RPMOverride != nil && *e.RPMOverride < 0 { + return infraerrors.BadRequest("INVALID_RPM_OVERRIDE", fmt.Sprintf("rpm_override must be >= 0 (user_id=%d)", e.UserID)) + } + } + if err := s.userGroupRateRepo.SyncGroupRPMOverrides(ctx, groupID, entries); err != nil { + return err + } + // RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。 + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, groupID) + } + return nil +} + +func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error { + return s.groupRepo.UpdateSortOrders(ctx, updates) +} + +// AdminUpdateAPIKeyGroupID 管理员修改 API Key 分组绑定 +// groupID: nil=不修改, 指向0=解绑, 指向正整数=绑定到目标分组 +func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*AdminUpdateAPIKeyGroupIDResult, error) { + apiKey, err := s.apiKeyRepo.GetByID(ctx, keyID) + if err != nil { + return nil, err + } + + if groupID == nil { + // nil 表示不修改,直接返回 + return &AdminUpdateAPIKeyGroupIDResult{APIKey: apiKey}, nil + } + + if *groupID < 0 { + return nil, infraerrors.BadRequest("INVALID_GROUP_ID", "group_id must be non-negative") + } + + result := &AdminUpdateAPIKeyGroupIDResult{} + + if *groupID == 0 { + // 0 表示解绑分组(不修改 user_allowed_groups,避免影响用户其他 Key) + apiKey.GroupID = nil + apiKey.Group = nil + } else { + // 验证目标分组存在且状态为 active + group, err := s.groupRepo.GetByID(ctx, *groupID) + if err != nil { + return nil, err + } + if group.Status != StatusActive { + return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active") + } + // 订阅类型分组:用户须持有该分组的有效订阅才可绑定 + if group.IsSubscriptionType() { + if s.userSubRepo == nil { + return nil, infraerrors.InternalServer("SUBSCRIPTION_REPOSITORY_UNAVAILABLE", "subscription repository is not configured") + } + if _, err := s.userSubRepo.GetActiveByUserIDAndGroupID(ctx, apiKey.UserID, *groupID); err != nil { + if errors.Is(err, ErrSubscriptionNotFound) { + return nil, infraerrors.BadRequest("SUBSCRIPTION_REQUIRED", "user does not have an active subscription for this group") + } + return nil, err + } + } + + gid := *groupID + apiKey.GroupID = &gid + apiKey.Group = group + + // 专属标准分组:使用事务保证「添加分组权限」与「更新 API Key」的原子性 + if group.IsExclusive && !group.IsSubscriptionType() { + opCtx := ctx + var tx *dbent.Tx + if s.entClient == nil { + logger.LegacyPrintf("service.admin", "Warning: entClient is nil, skipping transaction protection for exclusive group binding") + } else { + var txErr error + tx, txErr = s.entClient.Tx(ctx) + if txErr != nil { + return nil, fmt.Errorf("begin transaction: %w", txErr) + } + defer func() { _ = tx.Rollback() }() + opCtx = dbent.NewTxContext(ctx, tx) + } + + if addErr := s.userRepo.AddGroupToAllowedGroups(opCtx, apiKey.UserID, gid); addErr != nil { + return nil, fmt.Errorf("add group to user allowed groups: %w", addErr) + } + if err := s.apiKeyRepo.Update(opCtx, apiKey); err != nil { + return nil, fmt.Errorf("update api key: %w", err) + } + if tx != nil { + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit transaction: %w", err) + } + } + + result.AutoGrantedGroupAccess = true + result.GrantedGroupID = &gid + result.GrantedGroupName = group.Name + + // 失效认证缓存(在事务提交后执行) + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key) + } + + result.APIKey = apiKey + return result, nil + } + } + + // 非专属分组 / 解绑:无需事务,单步更新即可 + if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil { + return nil, fmt.Errorf("update api key: %w", err) + } + + // 失效认证缓存 + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key) + } + + result.APIKey = apiKey + return result, nil +} + +// AdminResetAPIKeyRateLimitUsage resets all API key rate-limit usage windows. +func (s *adminServiceImpl) AdminResetAPIKeyRateLimitUsage(ctx context.Context, keyID int64) (*APIKey, error) { + apiKey, err := s.apiKeyRepo.GetByID(ctx, keyID) + if err != nil { + return nil, err + } + apiKey.Usage5h = 0 + apiKey.Usage1d = 0 + apiKey.Usage7d = 0 + apiKey.Window5hStart = nil + apiKey.Window1dStart = nil + apiKey.Window7dStart = nil + if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil { + return nil, fmt.Errorf("reset api key rate limit usage: %w", err) + } + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key) + } + if s.billingCacheService != nil { + _ = s.billingCacheService.InvalidateAPIKeyRateLimit(ctx, apiKey.ID) + } + return apiKey, nil +} + +// ReplaceUserGroup 替换用户的专属分组 +func (s *adminServiceImpl) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*ReplaceUserGroupResult, error) { + if oldGroupID == newGroupID { + return nil, infraerrors.BadRequest("SAME_GROUP", "old and new group must be different") + } + + // 验证新分组存在且为活跃的专属标准分组 + newGroup, err := s.groupRepo.GetByID(ctx, newGroupID) + if err != nil { + return nil, err + } + if newGroup.Status != StatusActive { + return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active") + } + if !newGroup.IsExclusive { + return nil, infraerrors.BadRequest("GROUP_NOT_EXCLUSIVE", "target group is not exclusive") + } + if newGroup.IsSubscriptionType() { + return nil, infraerrors.BadRequest("GROUP_IS_SUBSCRIPTION", "subscription groups are not supported for replacement") + } + + // 事务保证原子性 + if s.entClient == nil { + return nil, fmt.Errorf("entClient is nil, cannot perform group replacement") + } + tx, err := s.entClient.Tx(ctx) + if err != nil { + return nil, fmt.Errorf("begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + opCtx := dbent.NewTxContext(ctx, tx) + + // 1. 授予新分组权限 + if err := s.userRepo.AddGroupToAllowedGroups(opCtx, userID, newGroupID); err != nil { + return nil, fmt.Errorf("add new group to allowed groups: %w", err) + } + + // 2. 迁移绑定旧分组的 Key 到新分组 + migrated, err := s.apiKeyRepo.UpdateGroupIDByUserAndGroup(opCtx, userID, oldGroupID, newGroupID) + if err != nil { + return nil, fmt.Errorf("migrate api keys: %w", err) + } + + // 3. 移除旧分组权限 + if err := s.userRepo.RemoveGroupFromUserAllowedGroups(opCtx, userID, oldGroupID); err != nil { + return nil, fmt.Errorf("remove old group from allowed groups: %w", err) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit transaction: %w", err) + } + + // 失效该用户所有 Key 的认证缓存 + if s.authCacheInvalidator != nil { + keys, keyErr := s.apiKeyRepo.ListKeysByUserID(ctx, userID) + if keyErr == nil { + for _, k := range keys { + s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, k) + } + } + } + + return &ReplaceUserGroupResult{MigratedKeys: migrated}, nil +} diff --git a/backend/internal/service/admin_proxy.go b/backend/internal/service/admin_proxy.go new file mode 100644 index 0000000000..9b5a9223c7 --- /dev/null +++ b/backend/internal/service/admin_proxy.go @@ -0,0 +1,608 @@ +package service + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/util/httputil" +) + +// Proxy management implementations +func (s *adminServiceImpl) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]Proxy, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} + proxies, result, err := s.proxyRepo.ListWithFilters(ctx, params, protocol, status, search) + if err != nil { + return nil, 0, err + } + return proxies, result.Total, nil +} + +func (s *adminServiceImpl) ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]ProxyWithAccountCount, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} + proxies, result, err := s.proxyRepo.ListWithFiltersAndAccountCount(ctx, params, protocol, status, search) + if err != nil { + return nil, 0, err + } + s.attachProxyLatency(ctx, proxies) + return proxies, result.Total, nil +} + +func (s *adminServiceImpl) GetAllProxies(ctx context.Context) ([]Proxy, error) { + return s.proxyRepo.ListActive(ctx) +} + +func (s *adminServiceImpl) GetAllProxiesWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) { + proxies, err := s.proxyRepo.ListActiveWithAccountCount(ctx) + if err != nil { + return nil, err + } + s.attachProxyLatency(ctx, proxies) + return proxies, nil +} + +func (s *adminServiceImpl) GetProxy(ctx context.Context, id int64) (*Proxy, error) { + return s.proxyRepo.GetByID(ctx, id) +} + +func (s *adminServiceImpl) GetProxiesByIDs(ctx context.Context, ids []int64) ([]Proxy, error) { + return s.proxyRepo.ListByIDs(ctx, ids) +} + +func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error) { + // 规范化 fallback_mode + mode := input.FallbackMode + if mode == "" { + mode = FallbackModeNone + } + // 校验:mode=proxy 必须有 backup + if mode == FallbackModeProxy && input.BackupProxyID == nil { + return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy") + } + if input.ExpiryWarnDays < 0 { + return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0") + } + + proxy := &Proxy{ + Name: input.Name, + Protocol: input.Protocol, + Host: input.Host, + Port: input.Port, + Username: input.Username, + Password: input.Password, + Status: StatusActive, + ExpiresAt: input.ExpiresAt, + FallbackMode: mode, + BackupProxyID: input.BackupProxyID, + ExpiryWarnDays: input.ExpiryWarnDays, + } + if err := s.proxyRepo.Create(ctx, proxy); err != nil { + return nil, err + } + // Probe latency asynchronously so creation isn't blocked by network timeout. + go s.probeProxyLatency(context.Background(), proxy) + return proxy, nil +} + +func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *UpdateProxyInput) (*Proxy, error) { + // 校验:backup_proxy_id 不能是自身 + if input.BackupProxyID != nil && *input.BackupProxyID == id { + return nil, infraerrors.BadRequest("PROXY_BACKUP_SELF", "backup proxy cannot be itself") + } + // 规范化 fallback_mode + mode := input.FallbackMode + if mode == "" { + mode = FallbackModeNone + } + // 校验:mode=proxy 必须有 backup + if mode == FallbackModeProxy && input.BackupProxyID == nil { + return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy") + } + if input.ExpiryWarnDays < 0 { + return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0") + } + + proxy, err := s.proxyRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if input.Name != "" { + proxy.Name = input.Name + } + if input.Protocol != "" { + proxy.Protocol = input.Protocol + } + if input.Host != "" { + proxy.Host = input.Host + } + if input.Port != 0 { + proxy.Port = input.Port + } + if input.Username != "" { + proxy.Username = input.Username + } + if input.Password != "" { + proxy.Password = input.Password + } + if input.Status != "" { + proxy.Status = input.Status + } + // 透传有效期与回退字段 + proxy.ExpiresAt = input.ExpiresAt + proxy.FallbackMode = mode + proxy.BackupProxyID = input.BackupProxyID + proxy.ExpiryWarnDays = input.ExpiryWarnDays + + if err := s.proxyRepo.Update(ctx, proxy); err != nil { + return nil, err + } + return proxy, nil +} + +func (s *adminServiceImpl) DeleteProxy(ctx context.Context, id int64) error { + count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id) + if err != nil { + return err + } + if count > 0 { + return ErrProxyInUse + } + return s.proxyRepo.Delete(ctx, id) +} + +func (s *adminServiceImpl) BatchDeleteProxies(ctx context.Context, ids []int64) (*ProxyBatchDeleteResult, error) { + result := &ProxyBatchDeleteResult{} + if len(ids) == 0 { + return result, nil + } + + for _, id := range ids { + count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id) + if err != nil { + result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ + ID: id, + Reason: err.Error(), + }) + continue + } + if count > 0 { + result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ + ID: id, + Reason: ErrProxyInUse.Error(), + }) + continue + } + if err := s.proxyRepo.Delete(ctx, id); err != nil { + result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ + ID: id, + Reason: err.Error(), + }) + continue + } + result.DeletedIDs = append(result.DeletedIDs, id) + } + + return result, nil +} + +func (s *adminServiceImpl) GetProxyAccounts(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) { + return s.proxyRepo.ListAccountSummariesByProxyID(ctx, proxyID) +} + +func (s *adminServiceImpl) CheckProxyExists(ctx context.Context, host string, port int, username, password string) (bool, error) { + return s.proxyRepo.ExistsByHostPortAuth(ctx, host, port, username, password) +} + +func (s *adminServiceImpl) TestProxy(ctx context.Context, id int64) (*ProxyTestResult, error) { + proxy, err := s.proxyRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + proxyURL := proxy.URL() + exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxyURL) + if err != nil { + s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{ + Success: false, + Message: err.Error(), + UpdatedAt: time.Now(), + }) + return &ProxyTestResult{ + Success: false, + Message: err.Error(), + }, nil + } + + latency := latencyMs + s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{ + Success: true, + LatencyMs: &latency, + Message: "Proxy is accessible", + IPAddress: exitInfo.IP, + Country: exitInfo.Country, + CountryCode: exitInfo.CountryCode, + Region: exitInfo.Region, + City: exitInfo.City, + UpdatedAt: time.Now(), + }) + return &ProxyTestResult{ + Success: true, + Message: "Proxy is accessible", + LatencyMs: latencyMs, + IPAddress: exitInfo.IP, + City: exitInfo.City, + Region: exitInfo.Region, + Country: exitInfo.Country, + CountryCode: exitInfo.CountryCode, + }, nil +} + +func (s *adminServiceImpl) CheckProxyQuality(ctx context.Context, id int64) (*ProxyQualityCheckResult, error) { + proxy, err := s.proxyRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + result := &ProxyQualityCheckResult{ + ProxyID: id, + Score: 100, + Grade: "A", + CheckedAt: time.Now().Unix(), + Items: make([]ProxyQualityCheckItem, 0, len(proxyQualityTargets)+1), + } + + proxyURL := proxy.URL() + if s.proxyProber == nil { + result.Items = append(result.Items, ProxyQualityCheckItem{ + Target: "base_connectivity", + Status: "fail", + Message: "代理探测服务未配置", + }) + result.FailedCount++ + finalizeProxyQualityResult(result) + s.saveProxyQualitySnapshot(ctx, id, result, nil) + return result, nil + } + + exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxyURL) + if err != nil { + result.Items = append(result.Items, ProxyQualityCheckItem{ + Target: "base_connectivity", + Status: "fail", + LatencyMs: latencyMs, + Message: err.Error(), + }) + result.FailedCount++ + finalizeProxyQualityResult(result) + s.saveProxyQualitySnapshot(ctx, id, result, nil) + return result, nil + } + + result.ExitIP = exitInfo.IP + result.Country = exitInfo.Country + result.CountryCode = exitInfo.CountryCode + result.BaseLatencyMs = latencyMs + result.Items = append(result.Items, ProxyQualityCheckItem{ + Target: "base_connectivity", + Status: "pass", + LatencyMs: latencyMs, + Message: "代理出口连通正常", + }) + result.PassedCount++ + + client, err := httpclient.GetClient(httpclient.Options{ + ProxyURL: proxyURL, + Timeout: proxyQualityRequestTimeout, + ResponseHeaderTimeout: proxyQualityResponseHeaderTimeout, + }) + if err != nil { + result.Items = append(result.Items, ProxyQualityCheckItem{ + Target: "http_client", + Status: "fail", + Message: fmt.Sprintf("创建检测客户端失败: %v", err), + }) + result.FailedCount++ + finalizeProxyQualityResult(result) + s.saveProxyQualitySnapshot(ctx, id, result, exitInfo) + return result, nil + } + + for _, target := range proxyQualityTargets { + item := runProxyQualityTarget(ctx, client, target) + result.Items = append(result.Items, item) + switch item.Status { + case "pass": + result.PassedCount++ + case "warn": + result.WarnCount++ + case "challenge": + result.ChallengeCount++ + default: + result.FailedCount++ + } + } + + finalizeProxyQualityResult(result) + s.saveProxyQualitySnapshot(ctx, id, result, exitInfo) + return result, nil +} + +func runProxyQualityTarget(ctx context.Context, client *http.Client, target proxyQualityTarget) ProxyQualityCheckItem { + item := ProxyQualityCheckItem{ + Target: target.Target, + } + + req, err := http.NewRequestWithContext(ctx, target.Method, target.URL, nil) + if err != nil { + item.Status = "fail" + item.Message = fmt.Sprintf("构建请求失败: %v", err) + return item + } + req.Header.Set("Accept", "application/json,text/html,*/*") + req.Header.Set("User-Agent", proxyQualityClientUserAgent) + + start := time.Now() + resp, err := client.Do(req) + if err != nil { + item.Status = "fail" + item.LatencyMs = time.Since(start).Milliseconds() + item.Message = fmt.Sprintf("请求失败: %v", err) + return item + } + defer func() { _ = resp.Body.Close() }() + item.LatencyMs = time.Since(start).Milliseconds() + item.HTTPStatus = resp.StatusCode + + body, readErr := io.ReadAll(io.LimitReader(resp.Body, proxyQualityMaxBodyBytes+1)) + if readErr != nil { + item.Status = "fail" + item.Message = fmt.Sprintf("读取响应失败: %v", readErr) + return item + } + if int64(len(body)) > proxyQualityMaxBodyBytes { + body = body[:proxyQualityMaxBodyBytes] + } + + // Cloudflare challenge 检测 + if httputil.IsCloudflareChallengeResponse(resp.StatusCode, resp.Header, body) { + item.Status = "challenge" + item.CFRay = httputil.ExtractCloudflareRayID(resp.Header, body) + item.Message = "命中 Cloudflare challenge" + return item + } + + if _, ok := target.AllowedStatuses[resp.StatusCode]; ok { + // 白名单内的状态码均代表目标可达:2xx 表示接口直接可用, + // 401/405 等是无鉴权探测的预期结果,同样视为连通正常,不再扣分。 + item.Status = "pass" + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + item.Message = fmt.Sprintf("HTTP %d", resp.StatusCode) + } else { + item.Message = fmt.Sprintf("HTTP %d(目标可达)", resp.StatusCode) + } + return item + } + + if resp.StatusCode == http.StatusTooManyRequests { + item.Status = "warn" + item.Message = "目标返回 429,可能存在频控" + return item + } + + item.Status = "fail" + item.Message = fmt.Sprintf("非预期状态码: %d", resp.StatusCode) + return item +} + +func finalizeProxyQualityResult(result *ProxyQualityCheckResult) { + if result == nil { + return + } + score := 100 - result.WarnCount*10 - result.FailedCount*22 - result.ChallengeCount*30 + if score < 0 { + score = 0 + } + result.Score = score + result.Grade = proxyQualityGrade(score) + result.Summary = fmt.Sprintf( + "通过 %d 项,告警 %d 项,失败 %d 项,挑战 %d 项", + result.PassedCount, + result.WarnCount, + result.FailedCount, + result.ChallengeCount, + ) +} + +func proxyQualityGrade(score int) string { + switch { + case score >= 90: + return "A" + case score >= 75: + return "B" + case score >= 60: + return "C" + case score >= 40: + return "D" + default: + return "F" + } +} + +func proxyQualityOverallStatus(result *ProxyQualityCheckResult) string { + if result == nil { + return "" + } + if result.ChallengeCount > 0 { + return "challenge" + } + if result.FailedCount > 0 { + return "failed" + } + if result.WarnCount > 0 { + return "warn" + } + if result.PassedCount > 0 { + return "healthy" + } + return "failed" +} + +func proxyQualityFirstCFRay(result *ProxyQualityCheckResult) string { + if result == nil { + return "" + } + for _, item := range result.Items { + if item.CFRay != "" { + return item.CFRay + } + } + return "" +} + +func proxyQualityBaseConnectivityPass(result *ProxyQualityCheckResult) bool { + if result == nil { + return false + } + for _, item := range result.Items { + if item.Target == "base_connectivity" { + return item.Status == "pass" + } + } + return false +} + +func (s *adminServiceImpl) saveProxyQualitySnapshot(ctx context.Context, proxyID int64, result *ProxyQualityCheckResult, exitInfo *ProxyExitInfo) { + if result == nil { + return + } + score := result.Score + checkedAt := result.CheckedAt + info := &ProxyLatencyInfo{ + Success: proxyQualityBaseConnectivityPass(result), + Message: result.Summary, + QualityStatus: proxyQualityOverallStatus(result), + QualityScore: &score, + QualityGrade: result.Grade, + QualitySummary: result.Summary, + QualityCheckedAt: &checkedAt, + QualityCFRay: proxyQualityFirstCFRay(result), + UpdatedAt: time.Now(), + } + if result.BaseLatencyMs > 0 { + latency := result.BaseLatencyMs + info.LatencyMs = &latency + } + if exitInfo != nil { + info.IPAddress = exitInfo.IP + info.Country = exitInfo.Country + info.CountryCode = exitInfo.CountryCode + info.Region = exitInfo.Region + info.City = exitInfo.City + } + s.saveProxyLatency(ctx, proxyID, info) +} + +func (s *adminServiceImpl) probeProxyLatency(ctx context.Context, proxy *Proxy) { + if s.proxyProber == nil || proxy == nil { + return + } + exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxy.URL()) + if err != nil { + s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{ + Success: false, + Message: err.Error(), + UpdatedAt: time.Now(), + }) + return + } + + latency := latencyMs + s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{ + Success: true, + LatencyMs: &latency, + Message: "Proxy is accessible", + IPAddress: exitInfo.IP, + Country: exitInfo.Country, + CountryCode: exitInfo.CountryCode, + Region: exitInfo.Region, + City: exitInfo.City, + UpdatedAt: time.Now(), + }) +} + +func (s *adminServiceImpl) attachProxyLatency(ctx context.Context, proxies []ProxyWithAccountCount) { + if s.proxyLatencyCache == nil || len(proxies) == 0 { + return + } + + ids := make([]int64, 0, len(proxies)) + for i := range proxies { + ids = append(ids, proxies[i].ID) + } + + latencies, err := s.proxyLatencyCache.GetProxyLatencies(ctx, ids) + if err != nil { + logger.LegacyPrintf("service.admin", "Warning: load proxy latency cache failed: %v", err) + return + } + + for i := range proxies { + info := latencies[proxies[i].ID] + if info == nil { + continue + } + if info.Success { + proxies[i].LatencyStatus = "success" + proxies[i].LatencyMs = info.LatencyMs + } else { + proxies[i].LatencyStatus = "failed" + } + proxies[i].LatencyMessage = info.Message + proxies[i].IPAddress = info.IPAddress + proxies[i].Country = info.Country + proxies[i].CountryCode = info.CountryCode + proxies[i].Region = info.Region + proxies[i].City = info.City + proxies[i].QualityStatus = info.QualityStatus + proxies[i].QualityScore = info.QualityScore + proxies[i].QualityGrade = info.QualityGrade + proxies[i].QualitySummary = info.QualitySummary + proxies[i].QualityChecked = info.QualityCheckedAt + } +} + +func (s *adminServiceImpl) saveProxyLatency(ctx context.Context, proxyID int64, info *ProxyLatencyInfo) { + if s.proxyLatencyCache == nil || info == nil { + return + } + + merged := *info + if latencies, err := s.proxyLatencyCache.GetProxyLatencies(ctx, []int64{proxyID}); err == nil { + if existing := latencies[proxyID]; existing != nil { + if merged.QualityCheckedAt == nil && + merged.QualityScore == nil && + merged.QualityGrade == "" && + merged.QualityStatus == "" && + merged.QualitySummary == "" && + merged.QualityCFRay == "" { + merged.QualityStatus = existing.QualityStatus + merged.QualityScore = existing.QualityScore + merged.QualityGrade = existing.QualityGrade + merged.QualitySummary = existing.QualitySummary + merged.QualityCheckedAt = existing.QualityCheckedAt + merged.QualityCFRay = existing.QualityCFRay + } + } + } + + if err := s.proxyLatencyCache.SetProxyLatency(ctx, proxyID, &merged); err != nil { + logger.LegacyPrintf("service.admin", "Warning: store proxy latency cache failed: %v", err) + } +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index f3b6fd555a..beffca8e41 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2,31 +2,11 @@ package service import ( "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" "net/http" - "sort" - "strconv" - "strings" "time" dbent "github.com/Wei-Shaw/sub2api/ent" - "github.com/Wei-Shaw/sub2api/ent/authidentity" - "github.com/Wei-Shaw/sub2api/ent/authidentitychannel" - "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" - "github.com/Wei-Shaw/sub2api/internal/pkg/claude" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" - "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" - "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" - "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" - "github.com/Wei-Shaw/sub2api/internal/pkg/xai" - "github.com/Wei-Shaw/sub2api/internal/util/httputil" ) // AdminService interface defines admin management operations @@ -640,3770 +620,3 @@ func NewAdminService( runtimeBlocker: runtimeBlocker, } } - -// User management implementations -func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, filters UserListFilters, sortBy, sortOrder string) ([]User, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - users, result, err := s.userRepo.ListWithFilters(ctx, params, filters) - if err != nil { - return nil, 0, err - } - if len(users) > 0 { - userIDs := make([]int64, 0, len(users)) - for i := range users { - userIDs = append(userIDs, users[i].ID) - } - lastUsedByUserID, latestErr := s.userRepo.GetLatestUsedAtByUserIDs(ctx, userIDs) - if latestErr != nil { - logger.LegacyPrintf("service.admin", "failed to load user last_used_at in batch: err=%v", latestErr) - } else { - for i := range users { - users[i].LastUsedAt = lastUsedByUserID[users[i].ID] - } - } - } - // 批量加载用户专属分组倍率 - if s.userGroupRateRepo != nil && len(users) > 0 { - if batchRepo, ok := s.userGroupRateRepo.(userGroupRateBatchReader); ok { - userIDs := make([]int64, 0, len(users)) - for i := range users { - userIDs = append(userIDs, users[i].ID) - } - ratesByUser, err := batchRepo.GetByUserIDs(ctx, userIDs) - if err != nil { - logger.LegacyPrintf("service.admin", "failed to load user group rates in batch: err=%v", err) - s.loadUserGroupRatesOneByOne(ctx, users) - } else { - for i := range users { - if rates, ok := ratesByUser[users[i].ID]; ok { - users[i].GroupRates = rates - } - } - } - } else { - s.loadUserGroupRatesOneByOne(ctx, users) - } - } - return users, result.Total, nil -} - -func (s *adminServiceImpl) loadUserGroupRatesOneByOne(ctx context.Context, users []User) { - if s.userGroupRateRepo == nil { - return - } - for i := range users { - rates, err := s.userGroupRateRepo.GetByUserID(ctx, users[i].ID) - if err != nil { - logger.LegacyPrintf("service.admin", "failed to load user group rates: user_id=%d err=%v", users[i].ID, err) - continue - } - users[i].GroupRates = rates - } -} - -func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*User, error) { - user, err := s.userRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - lastUsedAt, latestErr := s.userRepo.GetLatestUsedAtByUserID(ctx, id) - if latestErr != nil { - logger.LegacyPrintf("service.admin", "failed to load user last_used_at: user_id=%d err=%v", id, latestErr) - } else { - user.LastUsedAt = lastUsedAt - } - // 加载用户专属分组倍率 - if s.userGroupRateRepo != nil { - rates, err := s.userGroupRateRepo.GetByUserID(ctx, id) - if err != nil { - logger.LegacyPrintf("service.admin", "failed to load user group rates: user_id=%d err=%v", id, err) - } else { - user.GroupRates = rates - } - } - return user, nil -} - -func (s *adminServiceImpl) GetUserIncludeDeleted(ctx context.Context, id int64) (*User, error) { - return s.userRepo.GetByIDIncludeDeleted(ctx, id) -} - -func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) { - balance := 0.0 - if input.Balance != nil { - balance = *input.Balance - } else if s.settingService != nil { - balance = s.settingService.GetDefaultBalance(ctx) - } - - user := &User{ - Email: input.Email, - Username: input.Username, - Notes: input.Notes, - Role: RoleUser, // Always create as regular user, never admin - Balance: balance, - Concurrency: input.Concurrency, - RPMLimit: input.RPMLimit, - Status: StatusActive, - AllowedGroups: input.AllowedGroups, - } - if err := user.SetPassword(input.Password); err != nil { - return nil, err - } - if err := s.userRepo.Create(ctx, user); err != nil { - return nil, err - } - s.assignDefaultSubscriptions(ctx, user.ID) - return user, nil -} - -func (s *adminServiceImpl) assignDefaultSubscriptions(ctx context.Context, userID int64) { - if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 { - return - } - items := s.settingService.GetDefaultSubscriptions(ctx) - for _, item := range items { - if _, _, err := s.defaultSubAssigner.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{ - UserID: userID, - GroupID: item.GroupID, - ValidityDays: item.ValidityDays, - Notes: "auto assigned by default user subscriptions setting", - }); err != nil { - logger.LegacyPrintf("service.admin", "failed to assign default subscription: user_id=%d group_id=%d err=%v", userID, item.GroupID, err) - } - } -} - -func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) { - // 校验用户专属分组倍率:必须 > 0(nil 合法,表示清除专属倍率) - if input.GroupRates != nil { - for groupID, rate := range input.GroupRates { - if rate != nil && *rate <= 0 { - return nil, fmt.Errorf("rate_multiplier must be > 0 (group_id=%d)", groupID) - } - } - } - - user, err := s.userRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - - // Protect admin users: cannot disable admin accounts - if user.Role == "admin" && input.Status == "disabled" { - return nil, errors.New("cannot disable admin user") - } - - oldConcurrency := user.Concurrency - oldStatus := user.Status - oldRole := user.Role - oldRPMLimit := user.RPMLimit - oldAllowedGroups := append([]int64(nil), user.AllowedGroups...) - - if input.Email != "" { - user.Email = input.Email - } - if input.Password != "" { - if err := user.SetPassword(input.Password); err != nil { - return nil, err - } - } - - if input.Username != nil { - user.Username = *input.Username - } - if input.Notes != nil { - user.Notes = *input.Notes - } - - if input.Status != "" { - user.Status = input.Status - } - - if input.Concurrency != nil { - user.Concurrency = *input.Concurrency - } - - if input.RPMLimit != nil { - user.RPMLimit = *input.RPMLimit - } - - if input.AllowedGroups != nil { - user.AllowedGroups = *input.AllowedGroups - } - - if err := s.userRepo.Update(ctx, user); err != nil { - return nil, err - } - - // 同步用户专属分组倍率 - if input.GroupRates != nil && s.userGroupRateRepo != nil { - if err := s.userGroupRateRepo.SyncUserGroupRates(ctx, user.ID, input.GroupRates); err != nil { - logger.LegacyPrintf("service.admin", "failed to sync user group rates: user_id=%d err=%v", user.ID, err) - } - } - - if s.authCacheInvalidator != nil { - // RPMLimit 直接参与 billing_cache_service.checkRPM 的三级级联, - // allowed_groups 参与 API Key 专属分组授权判断;不失效缓存会让修改在一个 L2 TTL 内失去效果。 - if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole || user.RPMLimit != oldRPMLimit || !sameInt64Set(user.AllowedGroups, oldAllowedGroups) { - s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, user.ID) - } - } - - concurrencyDiff := user.Concurrency - oldConcurrency - if concurrencyDiff != 0 { - code, err := GenerateRedeemCode() - if err != nil { - logger.LegacyPrintf("service.admin", "failed to generate adjustment redeem code: %v", err) - return user, nil - } - adjustmentRecord := &RedeemCode{ - Code: code, - Type: AdjustmentTypeAdminConcurrency, - Value: float64(concurrencyDiff), - Status: StatusUsed, - UsedBy: &user.ID, - } - now := time.Now() - adjustmentRecord.UsedAt = &now - if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil { - logger.LegacyPrintf("service.admin", "failed to create concurrency adjustment redeem code: %v", err) - } - } - - return user, nil -} - -func sameInt64Set(a, b []int64) bool { - if len(a) != len(b) { - return false - } - if len(a) == 0 { - return true - } - counts := make(map[int64]int, len(a)) - for _, v := range a { - counts[v]++ - } - for _, v := range b { - if counts[v] == 0 { - return false - } - counts[v]-- - } - return true -} - -func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error { - // Protect admin users: cannot delete admin accounts - user, err := s.userRepo.GetByID(ctx, id) - if err != nil { - return err - } - if user.Role == "admin" { - return errors.New("cannot delete admin user") - } - - apiKeys, err := s.listUserAPIKeysForDeletion(ctx, id) - if err != nil { - return err - } - - if s.entClient != nil { - tx, err := s.entClient.Tx(ctx) - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - - opCtx := dbent.NewTxContext(ctx, tx) - if err := s.deleteUserWithAPIKeys(opCtx, id, apiKeys); err != nil { - return err - } - if err := tx.Commit(); err != nil { - return err - } - } else { - if err := s.deleteUserWithAPIKeys(ctx, id, apiKeys); err != nil { - return err - } - } - - if s.authCacheInvalidator != nil { - for _, key := range apiKeys { - if keyValue := strings.TrimSpace(key.Key); keyValue != "" { - s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, keyValue) - } - } - s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, id) - } - return nil -} - -func (s *adminServiceImpl) listUserAPIKeysForDeletion(ctx context.Context, userID int64) ([]APIKey, error) { - if s.apiKeyRepo == nil { - return nil, nil - } - - const pageSize = 1000 - keys := make([]APIKey, 0) - for page := 1; ; page++ { - batch, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, pagination.PaginationParams{ - Page: page, - PageSize: pageSize, - SortBy: "id", - SortOrder: pagination.SortOrderAsc, - }, APIKeyListFilters{}) - if err != nil { - return nil, fmt.Errorf("list user api keys: %w", err) - } - keys = append(keys, batch...) - if len(batch) == 0 || len(batch) < pageSize || result == nil || int64(len(keys)) >= result.Total { - break - } - } - return keys, nil -} - -func (s *adminServiceImpl) deleteUserWithAPIKeys(ctx context.Context, userID int64, apiKeys []APIKey) error { - if s.apiKeyRepo != nil { - for _, key := range apiKeys { - if key.ID <= 0 { - continue - } - if err := s.apiKeyRepo.DeleteWithAudit(ctx, key.ID); err != nil { - logger.LegacyPrintf("service.admin", "delete user api key failed: user_id=%d api_key_id=%d err=%v", userID, key.ID, err) - return fmt.Errorf("delete user api key %d: %w", key.ID, err) - } - } - } - - if err := s.userRepo.Delete(ctx, userID); err != nil { - logger.LegacyPrintf("service.admin", "delete user failed: user_id=%d err=%v", userID, err) - return err - } - return nil -} - -func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) { - cleaned := make([]int64, 0, len(userIDs)) - for _, uid := range userIDs { - if uid > 0 { - cleaned = append(cleaned, uid) - } - } - if len(cleaned) == 0 { - return 0, nil - } - - var affected int - var err error - switch mode { - case "set": - affected, err = s.userRepo.BatchSetConcurrency(ctx, cleaned, value) - case "add": - affected, err = s.userRepo.BatchAddConcurrency(ctx, cleaned, value) - default: - return 0, errors.New("invalid mode: must be 'set' or 'add'") - } - if err != nil { - return 0, err - } - - if s.authCacheInvalidator != nil { - for _, uid := range cleaned { - s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, uid) - } - } - return affected, nil -} - -func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) { - user, err := s.userRepo.GetByID(ctx, userID) - if err != nil { - return nil, err - } - - oldBalance := user.Balance - - switch operation { - case "set": - user.Balance = balance - case "add": - user.Balance += balance - case "subtract": - user.Balance -= balance - } - - if user.Balance < 0 { - return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", oldBalance, user.Balance) - } - - if err := s.userRepo.Update(ctx, user); err != nil { - return nil, err - } - balanceDiff := user.Balance - oldBalance - if s.authCacheInvalidator != nil && balanceDiff != 0 { - s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) - } - - if s.billingCacheService != nil { - go func() { - cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := s.billingCacheService.InvalidateUserBalance(cacheCtx, userID); err != nil { - logger.LegacyPrintf("service.admin", "invalidate user balance cache failed: user_id=%d err=%v", userID, err) - } - }() - } - - if balanceDiff != 0 { - code, err := GenerateRedeemCode() - if err != nil { - logger.LegacyPrintf("service.admin", "failed to generate adjustment redeem code: %v", err) - return user, nil - } - - adjustmentRecord := &RedeemCode{ - Code: code, - Type: AdjustmentTypeAdminBalance, - Value: balanceDiff, - Status: StatusUsed, - UsedBy: &user.ID, - Notes: notes, - } - now := time.Now() - adjustmentRecord.UsedAt = &now - - if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil { - logger.LegacyPrintf("service.admin", "failed to create balance adjustment redeem code: %v", err) - } - } - - return user, nil -} - -func (s *adminServiceImpl) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - keys, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, params, APIKeyListFilters{}) - if err != nil { - return nil, 0, err - } - return keys, result.Total, nil -} - -func (s *adminServiceImpl) GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error) { - if s.userRPMCache == nil { - return nil, ErrRPMStatusUnavailable - } - - user, err := s.userRepo.GetByID(ctx, userID) - if err != nil { - return nil, err - } - - userRPMUsed, err := s.userRPMCache.GetUserRPM(ctx, userID) - if err != nil { - logger.LegacyPrintf("service.admin", "failed to get user rpm: user_id=%d err=%v", userID, err) - } - - keys, _, err := s.GetUserAPIKeys(ctx, userID, 1, 1000, "", "") - if err != nil { - return nil, err - } - - groupIDSet := make(map[int64]struct{}) - for _, key := range keys { - if key.GroupID != nil && *key.GroupID > 0 { - groupIDSet[*key.GroupID] = struct{}{} - } - } - - groupIDs := make([]int64, 0, len(groupIDSet)) - for groupID := range groupIDSet { - groupIDs = append(groupIDs, groupID) - } - sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] }) - - var perGroup []UserGroupRPMStatus - for _, groupID := range groupIDs { - used, getErr := s.userRPMCache.GetUserGroupRPM(ctx, userID, groupID) - if getErr != nil { - logger.LegacyPrintf("service.admin", "failed to get user group rpm: user_id=%d group_id=%d err=%v", userID, groupID, getErr) - } - - entry := UserGroupRPMStatus{ - GroupID: groupID, - Used: used, - } - - if s.groupRepo != nil { - if group, groupErr := s.groupRepo.GetByIDLite(ctx, groupID); groupErr == nil && group != nil { - entry.GroupName = group.Name - entry.Limit = group.RPMLimit - entry.Source = "group" - } else if groupErr != nil { - logger.LegacyPrintf("service.admin", "failed to get group rpm status metadata: group_id=%d err=%v", groupID, groupErr) - } - } - - if s.userGroupRateRepo != nil { - override, overrideErr := s.userGroupRateRepo.GetRPMOverrideByUserAndGroup(ctx, userID, groupID) - if overrideErr != nil { - logger.LegacyPrintf("service.admin", "failed to get rpm override: user_id=%d group_id=%d err=%v", userID, groupID, overrideErr) - } else if override != nil { - entry.Limit = *override - entry.Source = "override" - } - } - - perGroup = append(perGroup, entry) - } - - return &UserRPMStatus{ - UserRPMUsed: userRPMUsed, - UserRPMLimit: user.RPMLimit, - PerGroup: perGroup, - }, nil -} - -func (s *adminServiceImpl) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) { - // Return mock data for now - return map[string]any{ - "period": period, - "total_requests": 0, - "total_cost": 0.0, - "total_tokens": 0, - "avg_duration_ms": 0, - }, nil -} - -// GetUserBalanceHistory returns paginated balance/concurrency change records for a user. -func (s *adminServiceImpl) GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]RedeemCode, int64, float64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize} - if codeType == RedeemTypeAffiliateBalance { - codes, total, err := s.listAffiliateBalanceHistory(ctx, userID, params) - if err != nil { - return nil, 0, 0, err - } - totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) - if err != nil { - return nil, 0, 0, err - } - return codes, total, totalRecharged, nil - } - - if codeType == "" { - return s.getAllUserBalanceHistory(ctx, userID, params) - } - - codes, result, err := s.redeemCodeRepo.ListByUserPaginated(ctx, userID, params, codeType) - if err != nil { - return nil, 0, 0, err - } - total := result.Total - // Aggregate total recharged amount (only once, regardless of type filter) - totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) - if err != nil { - return nil, 0, 0, err - } - return codes, total, totalRecharged, nil -} - -func (s *adminServiceImpl) getAllUserBalanceHistory(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, int64, float64, error) { - needed := params.Offset() + params.Limit() - if needed < params.Limit() { - needed = params.Limit() - } - - redeemCodes, redeemTotal, err := s.listRedeemBalanceHistoryForMerge(ctx, userID, needed) - if err != nil { - return nil, 0, 0, err - } - affiliateCodes, affiliateTotal, err := s.listAffiliateBalanceHistoryForMerge(ctx, userID, needed) - if err != nil { - return nil, 0, 0, err - } - codes := mergeBalanceHistoryCodes(redeemCodes, affiliateCodes, params) - - totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) - if err != nil { - return nil, 0, 0, err - } - return codes, redeemTotal + affiliateTotal, totalRecharged, nil -} - -func (s *adminServiceImpl) listRedeemBalanceHistoryForMerge(ctx context.Context, userID int64, needed int) ([]RedeemCode, int64, error) { - if needed <= 0 { - return nil, 0, nil - } - - var ( - out []RedeemCode - total int64 - ) - for page := 1; len(out) < needed; page++ { - params := pagination.PaginationParams{Page: page, PageSize: 1000} - codes, result, err := s.redeemCodeRepo.ListByUserPaginated(ctx, userID, params, "") - if err != nil { - return nil, 0, err - } - if result != nil { - total = result.Total - } - out = append(out, codes...) - if len(codes) < params.Limit() || int64(len(out)) >= total { - break - } - } - if len(out) > needed { - out = out[:needed] - } - return out, total, nil -} - -func (s *adminServiceImpl) listAffiliateBalanceHistoryForMerge(ctx context.Context, userID int64, needed int) ([]RedeemCode, int64, error) { - if needed <= 0 { - return nil, 0, nil - } - - var ( - out []RedeemCode - total int64 - ) - for page := 1; len(out) < needed; page++ { - params := pagination.PaginationParams{Page: page, PageSize: 1000} - codes, currentTotal, err := s.listAffiliateBalanceHistory(ctx, userID, params) - if err != nil { - return nil, 0, err - } - total = currentTotal - out = append(out, codes...) - if len(codes) < params.Limit() || int64(len(out)) >= total { - break - } - } - if len(out) > needed { - out = out[:needed] - } - return out, total, nil -} - -func (s *adminServiceImpl) listAffiliateBalanceHistory(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, int64, error) { - if s == nil || s.entClient == nil || userID <= 0 { - return nil, 0, nil - } - - rows, err := s.entClient.QueryContext(ctx, ` -SELECT id, - amount::double precision, - created_at -FROM user_affiliate_ledger -WHERE user_id = $1 - AND action = 'transfer' -ORDER BY created_at DESC, id DESC -OFFSET $2 -LIMIT $3`, userID, params.Offset(), params.Limit()) - if err != nil { - return nil, 0, err - } - defer func() { _ = rows.Close() }() - - codes := make([]RedeemCode, 0, params.Limit()) - for rows.Next() { - var id int64 - var amount float64 - var createdAt time.Time - if err := rows.Scan(&id, &amount, &createdAt); err != nil { - return nil, 0, err - } - usedBy := userID - usedAt := createdAt - codes = append(codes, RedeemCode{ - ID: -id, - Code: fmt.Sprintf("AFF-%d", id), - Type: RedeemTypeAffiliateBalance, - Value: amount, - Status: StatusUsed, - UsedBy: &usedBy, - UsedAt: &usedAt, - CreatedAt: createdAt, - }) - } - if err := rows.Err(); err != nil { - return nil, 0, err - } - - total, err := countAffiliateBalanceHistory(ctx, s.entClient, userID) - if err != nil { - return nil, 0, err - } - return codes, total, nil -} - -func countAffiliateBalanceHistory(ctx context.Context, client *dbent.Client, userID int64) (int64, error) { - rows, err := client.QueryContext(ctx, ` -SELECT COUNT(*) -FROM user_affiliate_ledger -WHERE user_id = $1 - AND action = 'transfer'`, userID) - if err != nil { - return 0, err - } - defer func() { _ = rows.Close() }() - - var total sql.NullInt64 - if rows.Next() { - if err := rows.Scan(&total); err != nil { - return 0, err - } - } - if err := rows.Err(); err != nil { - return 0, err - } - if !total.Valid { - return 0, nil - } - return total.Int64, nil -} - -func mergeBalanceHistoryCodes(redeemCodes, affiliateCodes []RedeemCode, params pagination.PaginationParams) []RedeemCode { - combined := append(append([]RedeemCode{}, redeemCodes...), affiliateCodes...) - sort.SliceStable(combined, func(i, j int) bool { - return redeemCodeHistoryTime(combined[i]).After(redeemCodeHistoryTime(combined[j])) - }) - offset := params.Offset() - if offset >= len(combined) { - return []RedeemCode{} - } - end := offset + params.Limit() - if end > len(combined) { - end = len(combined) - } - return combined[offset:end] -} - -func redeemCodeHistoryTime(code RedeemCode) time.Time { - if code.UsedAt != nil { - return *code.UsedAt - } - return code.CreatedAt -} - -func (s *adminServiceImpl) BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error) { - if userID <= 0 { - return nil, infraerrors.BadRequest("INVALID_INPUT", "user_id must be greater than 0") - } - if s == nil || s.entClient == nil || s.userRepo == nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_UNAVAILABLE", "auth identity binding service is unavailable") - } - if _, err := s.userRepo.GetByID(ctx, userID); err != nil { - return nil, err - } - - providerType := normalizeAdminAuthIdentityProviderType(input.ProviderType) - providerKey := strings.TrimSpace(input.ProviderKey) - providerSubject := strings.TrimSpace(input.ProviderSubject) - if providerType == "" { - return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type must be one of email, linuxdo, oidc, wechat, or dingtalk") - } - if providerKey == "" || providerSubject == "" { - return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type, provider_key, and provider_subject are required") - } - canonicalProviderKey := canonicalAdminAuthIdentityProviderKey(providerType, "", providerKey) - compatibleProviderKeys := compatibleAdminAuthIdentityProviderKeys(providerType, providerKey) - - var issuer *string - if input.Issuer != nil { - trimmed := strings.TrimSpace(*input.Issuer) - if trimmed != "" { - issuer = &trimmed - } - } - - channelInput := normalizeAdminBindChannelInput(input.Channel) - if input.Channel != nil && channelInput == nil { - return nil, infraerrors.BadRequest("INVALID_INPUT", "channel, channel_app_id, and channel_subject are required when channel binding is provided") - } - - verifiedAt := time.Now().UTC() - tx, err := s.entClient.Tx(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_TX_FAILED", "failed to start auth identity bind transaction").WithCause(err) - } - defer func() { _ = tx.Rollback() }() - - identityRecords, err := tx.AuthIdentity.Query(). - Where( - authidentity.ProviderTypeEQ(providerType), - authidentity.ProviderKeyIn(compatibleProviderKeys...), - authidentity.ProviderSubjectEQ(providerSubject), - ). - All(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err) - } - if hasAdminAuthIdentityOwnershipConflict(identityRecords, userID) { - return nil, infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user") - } - identity := selectOwnedAdminAuthIdentity(identityRecords, userID) - - if identity == nil { - create := tx.AuthIdentity.Create(). - SetUserID(userID). - SetProviderType(providerType). - SetProviderKey(canonicalProviderKey). - SetProviderSubject(providerSubject). - SetVerifiedAt(verifiedAt) - if issuer != nil { - create = create.SetIssuer(*issuer) - } - if input.Metadata != nil { - create = create.SetMetadata(cloneAdminAuthIdentityMetadata(input.Metadata)) - } - identity, err = create.Save(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_SAVE_FAILED", "failed to save auth identity").WithCause(err) - } - } else { - update := tx.AuthIdentity.UpdateOneID(identity.ID). - SetVerifiedAt(verifiedAt). - SetProviderKey(canonicalProviderKey) - if issuer != nil { - update = update.SetIssuer(*issuer) - } - if input.Metadata != nil { - update = update.SetMetadata(cloneAdminAuthIdentityMetadata(input.Metadata)) - } - identity, err = update.Save(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_SAVE_FAILED", "failed to save auth identity").WithCause(err) - } - } - - var channel *dbent.AuthIdentityChannel - if channelInput != nil { - channelRecords, err := tx.AuthIdentityChannel.Query(). - Where( - authidentitychannel.ProviderTypeEQ(providerType), - authidentitychannel.ProviderKeyIn(compatibleProviderKeys...), - authidentitychannel.ChannelEQ(channelInput.Channel), - authidentitychannel.ChannelAppIDEQ(channelInput.ChannelAppID), - authidentitychannel.ChannelSubjectEQ(channelInput.ChannelSubject), - ). - WithIdentity(). - All(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_CHANNEL_LOOKUP_FAILED", "failed to inspect auth identity channel ownership").WithCause(err) - } - if hasAdminAuthIdentityChannelOwnershipConflict(channelRecords, userID) { - return nil, infraerrors.Conflict("AUTH_IDENTITY_CHANNEL_OWNERSHIP_CONFLICT", "auth identity channel already belongs to another user") - } - channel = selectOwnedAdminAuthIdentityChannel(channelRecords, userID) - if channel == nil { - create := tx.AuthIdentityChannel.Create(). - SetIdentityID(identity.ID). - SetProviderType(providerType). - SetProviderKey(canonicalProviderKey). - SetChannel(channelInput.Channel). - SetChannelAppID(channelInput.ChannelAppID). - SetChannelSubject(channelInput.ChannelSubject) - if channelInput.Metadata != nil { - create = create.SetMetadata(cloneAdminAuthIdentityMetadata(channelInput.Metadata)) - } - channel, err = create.Save(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_CHANNEL_SAVE_FAILED", "failed to save auth identity channel").WithCause(err) - } - } else { - update := tx.AuthIdentityChannel.UpdateOneID(channel.ID). - SetIdentityID(identity.ID). - SetProviderKey(canonicalProviderKey) - if channelInput.Metadata != nil { - update = update.SetMetadata(cloneAdminAuthIdentityMetadata(channelInput.Metadata)) - } - channel, err = update.Save(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_CHANNEL_SAVE_FAILED", "failed to save auth identity channel").WithCause(err) - } - } - } - - if err := tx.Commit(); err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_COMMIT_FAILED", "failed to commit auth identity bind").WithCause(err) - } - return buildAdminBoundAuthIdentity(identity, channel), nil -} - -func compatibleAdminAuthIdentityProviderKeys(providerType, providerKey string) []string { - providerType = strings.TrimSpace(strings.ToLower(providerType)) - providerKey = strings.TrimSpace(providerKey) - if providerKey == "" { - return []string{providerKey} - } - if providerType != "wechat" { - return []string{providerKey} - } - - keys := []string{providerKey} - if !strings.EqualFold(providerKey, "wechat-main") { - keys = append(keys, "wechat-main") - } - if !strings.EqualFold(providerKey, "wechat") { - keys = append(keys, "wechat") - } - return keys -} - -func canonicalAdminAuthIdentityProviderKey(providerType, existingKey, requestedKey string) string { - providerType = strings.TrimSpace(strings.ToLower(providerType)) - existingKey = strings.TrimSpace(existingKey) - requestedKey = strings.TrimSpace(requestedKey) - if providerType != "wechat" { - if requestedKey != "" { - return requestedKey - } - return existingKey - } - if strings.EqualFold(existingKey, "wechat") || strings.EqualFold(existingKey, "wechat-main") || strings.EqualFold(requestedKey, "wechat-main") { - return "wechat-main" - } - if requestedKey != "" { - return requestedKey - } - return existingKey -} - -func adminAuthIdentityProviderKeyRank(providerType, providerKey string) int { - providerType = strings.TrimSpace(strings.ToLower(providerType)) - providerKey = strings.TrimSpace(providerKey) - if providerType != "wechat" { - return 0 - } - switch { - case strings.EqualFold(providerKey, "wechat-main"): - return 0 - case strings.EqualFold(providerKey, "wechat"): - return 2 - default: - return 1 - } -} - -func selectOwnedAdminAuthIdentity(records []*dbent.AuthIdentity, userID int64) *dbent.AuthIdentity { - var selected *dbent.AuthIdentity - for _, record := range records { - if record.UserID != userID { - continue - } - if selected == nil || adminAuthIdentityProviderKeyRank(record.ProviderType, record.ProviderKey) < adminAuthIdentityProviderKeyRank(selected.ProviderType, selected.ProviderKey) { - selected = record - } - } - return selected -} - -func hasAdminAuthIdentityOwnershipConflict(records []*dbent.AuthIdentity, userID int64) bool { - for _, record := range records { - if record.UserID != userID { - return true - } - } - return false -} - -func selectOwnedAdminAuthIdentityChannel(records []*dbent.AuthIdentityChannel, userID int64) *dbent.AuthIdentityChannel { - var selected *dbent.AuthIdentityChannel - for _, record := range records { - if record.Edges.Identity == nil || record.Edges.Identity.UserID != userID { - continue - } - if selected == nil || adminAuthIdentityProviderKeyRank(record.ProviderType, record.ProviderKey) < adminAuthIdentityProviderKeyRank(selected.ProviderType, selected.ProviderKey) { - selected = record - } - } - return selected -} - -func hasAdminAuthIdentityChannelOwnershipConflict(records []*dbent.AuthIdentityChannel, userID int64) bool { - for _, record := range records { - if record.Edges.Identity != nil && record.Edges.Identity.UserID != userID { - return true - } - } - return false -} - -func normalizeAdminBindChannelInput(input *AdminBindAuthIdentityChannelInput) *AdminBindAuthIdentityChannelInput { - if input == nil { - return nil - } - channel := &AdminBindAuthIdentityChannelInput{ - Channel: strings.TrimSpace(input.Channel), - ChannelAppID: strings.TrimSpace(input.ChannelAppID), - ChannelSubject: strings.TrimSpace(input.ChannelSubject), - Metadata: cloneAdminAuthIdentityMetadata(input.Metadata), - } - if channel.Channel == "" || channel.ChannelAppID == "" || channel.ChannelSubject == "" { - return nil - } - return channel -} - -func normalizeAdminAuthIdentityProviderType(input string) string { - switch strings.ToLower(strings.TrimSpace(input)) { - case "email": - return "email" - case "linuxdo": - return "linuxdo" - case "oidc": - return "oidc" - case "wechat": - return "wechat" - case "dingtalk": - return "dingtalk" - default: - return "" - } -} - -func buildAdminBoundAuthIdentity(identity *dbent.AuthIdentity, channel *dbent.AuthIdentityChannel) *AdminBoundAuthIdentity { - if identity == nil { - return nil - } - result := &AdminBoundAuthIdentity{ - UserID: identity.UserID, - ProviderType: strings.TrimSpace(identity.ProviderType), - ProviderKey: strings.TrimSpace(identity.ProviderKey), - ProviderSubject: strings.TrimSpace(identity.ProviderSubject), - VerifiedAt: identity.VerifiedAt, - Issuer: identity.Issuer, - Metadata: cloneAdminAuthIdentityMetadata(identity.Metadata), - CreatedAt: identity.CreatedAt, - UpdatedAt: identity.UpdatedAt, - } - if channel != nil { - result.Channel = &AdminBoundAuthIdentityChannel{ - Channel: strings.TrimSpace(channel.Channel), - ChannelAppID: strings.TrimSpace(channel.ChannelAppID), - ChannelSubject: strings.TrimSpace(channel.ChannelSubject), - Metadata: cloneAdminAuthIdentityMetadata(channel.Metadata), - CreatedAt: channel.CreatedAt, - UpdatedAt: channel.UpdatedAt, - } - } - return result -} - -func cloneAdminAuthIdentityMetadata(input map[string]any) map[string]any { - if input == nil { - return nil - } - if len(input) == 0 { - return map[string]any{} - } - data, err := json.Marshal(input) - if err != nil { - out := make(map[string]any, len(input)) - for key, value := range input { - out[key] = value - } - return out - } - var out map[string]any - if err := json.Unmarshal(data, &out); err != nil { - out = make(map[string]any, len(input)) - for key, value := range input { - out[key] = value - } - } - return out -} - -// Group management implementations -func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - groups, result, err := s.groupRepo.ListWithFilters(ctx, params, platform, status, search, isExclusive) - if err != nil { - return nil, 0, err - } - return groups, result.Total, nil -} - -func (s *adminServiceImpl) GetAllGroups(ctx context.Context) ([]Group, error) { - return s.groupRepo.ListActive(ctx) -} - -func (s *adminServiceImpl) GetAllGroupsByPlatform(ctx context.Context, platform string) ([]Group, error) { - return s.groupRepo.ListActiveByPlatform(ctx, platform) -} - -func (s *adminServiceImpl) GetAllGroupsIncludingInactive(ctx context.Context) ([]Group, error) { - // ListWithFilters with empty status = no status filter, so active + disabled groups are returned. - // PageSize 10000 is intentionally large; group count is O(dozens) in practice. - groups, _, err := s.groupRepo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10000}, "", "", "", nil) - return groups, err -} - -func (s *adminServiceImpl) GetGroup(ctx context.Context, id int64) (*Group, error) { - return s.groupRepo.GetByID(ctx, id) -} - -func (s *adminServiceImpl) GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error) { - platform = strings.TrimSpace(platform) - if id > 0 { - group, err := s.groupRepo.GetByIDLite(ctx, id) - if err != nil { - return nil, err - } - if platform == "" { - platform = group.Platform - } - } - if platform == "" { - platform = PlatformAnthropic - } - - candidates := defaultModelsListCandidateIDs(platform) - if id <= 0 || s.accountRepo == nil { - return candidates, nil - } - - accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, id) - if err != nil { - return nil, err - } - - seen := make(map[string]struct{}, len(candidates)) - for _, model := range candidates { - seen[model] = struct{}{} - } - for _, acc := range accounts { - if acc.Platform != platform { - continue - } - for model := range acc.GetModelMapping() { - model = strings.TrimSpace(model) - if model == "" { - continue - } - if _, ok := seen[model]; ok { - continue - } - seen[model] = struct{}{} - candidates = append(candidates, model) - } - } - return candidates, nil -} - -func defaultModelsListCandidateIDs(platform string) []string { - switch platform { - case PlatformOpenAI: - return openai.DefaultModelIDs() - case PlatformGemini: - ids := make([]string, 0, len(geminicli.DefaultModels)) - for _, model := range geminicli.DefaultModels { - ids = append(ids, model.ID) - } - return ids - case PlatformAntigravity: - models := antigravity.DefaultModels() - ids := make([]string, 0, len(models)) - for _, model := range models { - ids = append(ids, model.ID) - } - return ids - case PlatformGrok: - return xai.DefaultModelIDs() - default: - ids := make([]string, 0, len(claude.DefaultModels)) - for _, model := range claude.DefaultModels { - ids = append(ids, model.ID) - } - return ids - } -} - -func defaultAllowImageGenerationForPlatform(platform string) bool { - // Grok image and video generation routes share the legacy image-generation gate. - // Older clients send the false zero value, so Grok groups must default enabled. - return platform == PlatformGrok -} - -func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) { - if input.RateMultiplier <= 0 { - return nil, errors.New("rate_multiplier must be > 0") - } - - platform := input.Platform - if platform == "" { - platform = PlatformAnthropic - } - - subscriptionType := input.SubscriptionType - if subscriptionType == "" { - subscriptionType = SubscriptionTypeStandard - } - - // 限额字段:nil/负数 表示"无限制",0 表示"不允许用量",正数表示具体限额 - dailyLimit := normalizeLimit(input.DailyLimitUSD) - weeklyLimit := normalizeLimit(input.WeeklyLimitUSD) - monthlyLimit := normalizeLimit(input.MonthlyLimitUSD) - - // 图片价格:负数表示清除(使用默认价格),0 保留(表示免费) - imagePrice1K := normalizePrice(input.ImagePrice1K) - imagePrice2K := normalizePrice(input.ImagePrice2K) - imagePrice4K := normalizePrice(input.ImagePrice4K) - imageRateMultiplier := 1.0 - if input.ImageRateMultiplier != nil { - if *input.ImageRateMultiplier < 0 { - return nil, errors.New("image_rate_multiplier must be >= 0") - } - imageRateMultiplier = *input.ImageRateMultiplier - } - batchImageDiscountMultiplier := defaultBatchImageDiscountMultiplier - if input.BatchImageDiscountMultiplier != nil { - if *input.BatchImageDiscountMultiplier < 0 { - return nil, errors.New("batch_image_discount_multiplier must be >= 0") - } - batchImageDiscountMultiplier = *input.BatchImageDiscountMultiplier - } - batchImageHoldMultiplier := defaultBatchImageHoldMultiplier - if input.BatchImageHoldMultiplier != nil { - if *input.BatchImageHoldMultiplier < 0 { - return nil, errors.New("batch_image_hold_multiplier must be >= 0") - } - batchImageHoldMultiplier = *input.BatchImageHoldMultiplier - } - // 不变式:hold 比例 >= discount 比例。否则批量任务成功率足够高时 - // 实际成本会超过冻结额,结算永远失败、用户冻结余额无法解冻。 - if batchImageHoldMultiplier < batchImageDiscountMultiplier { - return nil, errors.New("batch_image_hold_multiplier must be >= batch_image_discount_multiplier") - } - - peakRateMultiplier := 1.0 - if input.PeakRateMultiplier != nil { - peakRateMultiplier = *input.PeakRateMultiplier - } - // 先归一化(非订阅分组清空高峰配置、清洗停用状态下的脏字段)再校验,与 UpdateGroup 同一收口。 - peakRateEnabled, peakStart, peakEnd, peakRateMultiplier := NormalizePeakRateConfig(subscriptionType, input.PeakRateEnabled, input.PeakStart, input.PeakEnd, peakRateMultiplier) - if err := ValidatePeakRateConfig(subscriptionType, peakRateEnabled, peakStart, peakEnd, peakRateMultiplier); err != nil { - return nil, err - } - - // 校验降级分组 - if input.FallbackGroupID != nil { - if err := s.validateFallbackGroup(ctx, 0, *input.FallbackGroupID); err != nil { - return nil, err - } - } - fallbackOnInvalidRequest := input.FallbackGroupIDOnInvalidRequest - if fallbackOnInvalidRequest != nil && *fallbackOnInvalidRequest <= 0 { - fallbackOnInvalidRequest = nil - } - // 校验无效请求兜底分组 - if fallbackOnInvalidRequest != nil { - if err := s.validateFallbackGroupOnInvalidRequest(ctx, 0, platform, subscriptionType, *fallbackOnInvalidRequest); err != nil { - return nil, err - } - } - - // MCPXMLInject:默认为 true,仅当显式传入 false 时关闭 - mcpXMLInject := true - if input.MCPXMLInject != nil { - mcpXMLInject = *input.MCPXMLInject - } - - allowImageGeneration := input.AllowImageGeneration || defaultAllowImageGenerationForPlatform(platform) - allowBatchImageGeneration := input.AllowBatchImageGeneration && allowImageGeneration && platform == PlatformGemini - - // 如果指定了复制账号的源分组,先获取账号 ID 列表 - var accountIDsToCopy []int64 - if len(input.CopyAccountsFromGroupIDs) > 0 { - // 去重源分组 IDs - seen := make(map[int64]struct{}) - uniqueSourceGroupIDs := make([]int64, 0, len(input.CopyAccountsFromGroupIDs)) - for _, srcGroupID := range input.CopyAccountsFromGroupIDs { - if _, exists := seen[srcGroupID]; !exists { - seen[srcGroupID] = struct{}{} - uniqueSourceGroupIDs = append(uniqueSourceGroupIDs, srcGroupID) - } - } - - // 校验源分组的平台是否与新分组一致 - for _, srcGroupID := range uniqueSourceGroupIDs { - srcGroup, err := s.groupRepo.GetByIDLite(ctx, srcGroupID) - if err != nil { - return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err) - } - if srcGroup.Platform != platform { - return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, platform, srcGroup.Platform) - } - } - - // 获取所有源分组的账号(去重) - var err error - accountIDsToCopy, err = s.groupRepo.GetAccountIDsByGroupIDs(ctx, uniqueSourceGroupIDs) - if err != nil { - return nil, fmt.Errorf("failed to get accounts from source groups: %w", err) - } - } - - group := &Group{ - Name: input.Name, - Description: input.Description, - Platform: platform, - RateMultiplier: input.RateMultiplier, - IsExclusive: input.IsExclusive, - Status: StatusActive, - SubscriptionType: subscriptionType, - DailyLimitUSD: dailyLimit, - WeeklyLimitUSD: weeklyLimit, - MonthlyLimitUSD: monthlyLimit, - AllowImageGeneration: allowImageGeneration, - AllowBatchImageGeneration: allowBatchImageGeneration, - ImageRateIndependent: input.ImageRateIndependent, - ImageRateMultiplier: imageRateMultiplier, - BatchImageDiscountMultiplier: batchImageDiscountMultiplier, - BatchImageHoldMultiplier: batchImageHoldMultiplier, - PeakRateEnabled: peakRateEnabled, - PeakStart: peakStart, - PeakEnd: peakEnd, - PeakRateMultiplier: peakRateMultiplier, - ImagePrice1K: imagePrice1K, - ImagePrice2K: imagePrice2K, - ImagePrice4K: imagePrice4K, - ClaudeCodeOnly: input.ClaudeCodeOnly, - FallbackGroupID: input.FallbackGroupID, - FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest, - ModelRouting: input.ModelRouting, - MCPXMLInject: mcpXMLInject, - SupportedModelScopes: input.SupportedModelScopes, - AllowMessagesDispatch: input.AllowMessagesDispatch, - RequireOAuthOnly: input.RequireOAuthOnly, - RequirePrivacySet: input.RequirePrivacySet, - DefaultMappedModel: input.DefaultMappedModel, - MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig), - ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig), - RPMLimit: input.RPMLimit, - } - sanitizeGroupMessagesDispatchFields(group) - if err := s.groupRepo.Create(ctx, group); err != nil { - return nil, err - } - - // require_oauth_only: 过滤掉 apikey 类型账号 - if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && len(accountIDsToCopy) > 0 { - accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy) - if err != nil { - return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err) - } - oauthIDs := make(map[int64]struct{}, len(accounts)) - for _, acc := range accounts { - if acc.Type != AccountTypeAPIKey { - oauthIDs[acc.ID] = struct{}{} - } - } - var filtered []int64 - for _, aid := range accountIDsToCopy { - if _, ok := oauthIDs[aid]; ok { - filtered = append(filtered, aid) - } - } - accountIDsToCopy = filtered - } - - // 如果有需要复制的账号,绑定到新分组 - if len(accountIDsToCopy) > 0 { - if err := s.groupRepo.BindAccountsToGroup(ctx, group.ID, accountIDsToCopy); err != nil { - return nil, fmt.Errorf("failed to bind accounts to new group: %w", err) - } - group.AccountCount = int64(len(accountIDsToCopy)) - } - - return group, nil -} - -// normalizeLimit 将负数转换为 nil(表示无限制),0 保留(表示限额为零) -func normalizeLimit(limit *float64) *float64 { - if limit == nil || *limit < 0 { - return nil - } - return limit -} - -// normalizePrice 将负数转换为 nil(表示使用默认价格),0 保留(表示免费) -func normalizePrice(price *float64) *float64 { - if price == nil || *price < 0 { - return nil - } - return price -} - -// validateFallbackGroup 校验降级分组的有效性 -// currentGroupID: 当前分组 ID(新建时为 0) -// fallbackGroupID: 降级分组 ID -func (s *adminServiceImpl) validateFallbackGroup(ctx context.Context, currentGroupID, fallbackGroupID int64) error { - // 不能将自己设置为降级分组 - if currentGroupID > 0 && currentGroupID == fallbackGroupID { - return fmt.Errorf("cannot set self as fallback group") - } - - visited := map[int64]struct{}{} - nextID := fallbackGroupID - for { - if _, seen := visited[nextID]; seen { - return fmt.Errorf("fallback group cycle detected") - } - visited[nextID] = struct{}{} - if currentGroupID > 0 && nextID == currentGroupID { - return fmt.Errorf("fallback group cycle detected") - } - - // 检查降级分组是否存在 - fallbackGroup, err := s.groupRepo.GetByIDLite(ctx, nextID) - if err != nil { - return fmt.Errorf("fallback group not found: %w", err) - } - - // 降级分组不能启用 claude_code_only,否则会造成死循环 - if nextID == fallbackGroupID && fallbackGroup.ClaudeCodeOnly { - return fmt.Errorf("fallback group cannot have claude_code_only enabled") - } - - if fallbackGroup.FallbackGroupID == nil { - return nil - } - nextID = *fallbackGroup.FallbackGroupID - } -} - -// validateFallbackGroupOnInvalidRequest 校验无效请求兜底分组的有效性 -// currentGroupID: 当前分组 ID(新建时为 0) -// platform/subscriptionType: 当前分组的有效平台/订阅类型 -// fallbackGroupID: 兜底分组 ID -func (s *adminServiceImpl) validateFallbackGroupOnInvalidRequest(ctx context.Context, currentGroupID int64, platform, subscriptionType string, fallbackGroupID int64) error { - if platform != PlatformAnthropic && platform != PlatformAntigravity { - return fmt.Errorf("invalid request fallback only supported for anthropic or antigravity groups") - } - if subscriptionType == SubscriptionTypeSubscription { - return fmt.Errorf("subscription groups cannot set invalid request fallback") - } - if currentGroupID > 0 && currentGroupID == fallbackGroupID { - return fmt.Errorf("cannot set self as invalid request fallback group") - } - - fallbackGroup, err := s.groupRepo.GetByIDLite(ctx, fallbackGroupID) - if err != nil { - return fmt.Errorf("fallback group not found: %w", err) - } - if fallbackGroup.Platform != PlatformAnthropic { - return fmt.Errorf("fallback group must be anthropic platform") - } - if fallbackGroup.SubscriptionType == SubscriptionTypeSubscription { - return fmt.Errorf("fallback group cannot be subscription type") - } - if fallbackGroup.FallbackGroupIDOnInvalidRequest != nil { - return fmt.Errorf("fallback group cannot have invalid request fallback configured") - } - return nil -} - -func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) { - group, err := s.groupRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - - if input.Name != "" { - group.Name = input.Name - } - if input.Description != nil { - group.Description = *input.Description - } - if input.Platform != "" { - group.Platform = input.Platform - } - if input.RateMultiplier != nil { - if *input.RateMultiplier <= 0 { - return nil, errors.New("rate_multiplier must be > 0") - } - group.RateMultiplier = *input.RateMultiplier - } - if input.IsExclusive != nil { - group.IsExclusive = *input.IsExclusive - } - if input.Status != "" { - group.Status = input.Status - } - - // 订阅相关字段 - if input.SubscriptionType != "" { - group.SubscriptionType = input.SubscriptionType - } - // 限额字段:nil/负数 表示"无限制",0 表示"不允许用量",正数表示具体限额 - // 前端始终发送这三个字段,无需 nil 守卫 - group.DailyLimitUSD = normalizeLimit(input.DailyLimitUSD) - group.WeeklyLimitUSD = normalizeLimit(input.WeeklyLimitUSD) - group.MonthlyLimitUSD = normalizeLimit(input.MonthlyLimitUSD) - // 图片生成计费配置:负数表示清除(使用默认价格) - if input.AllowImageGeneration != nil { - group.AllowImageGeneration = *input.AllowImageGeneration - } - if input.AllowBatchImageGeneration != nil { - group.AllowBatchImageGeneration = *input.AllowBatchImageGeneration - } - if !group.AllowImageGeneration || group.Platform != PlatformGemini { - group.AllowBatchImageGeneration = false - } - if input.ImageRateIndependent != nil { - group.ImageRateIndependent = *input.ImageRateIndependent - } - if input.ImageRateMultiplier != nil { - if *input.ImageRateMultiplier < 0 { - return nil, errors.New("image_rate_multiplier must be >= 0") - } - group.ImageRateMultiplier = *input.ImageRateMultiplier - } - if input.BatchImageDiscountMultiplier != nil { - if *input.BatchImageDiscountMultiplier < 0 { - return nil, errors.New("batch_image_discount_multiplier must be >= 0") - } - group.BatchImageDiscountMultiplier = *input.BatchImageDiscountMultiplier - } - if input.BatchImageHoldMultiplier != nil { - if *input.BatchImageHoldMultiplier < 0 { - return nil, errors.New("batch_image_hold_multiplier must be >= 0") - } - group.BatchImageHoldMultiplier = *input.BatchImageHoldMultiplier - } - // 仅在本次更新显式触碰任一比例时校验合并后的不变式(hold >= discount), - // 避免存量脏数据阻塞其他字段的正常更新(提交侧另有钳制兜底)。 - if (input.BatchImageDiscountMultiplier != nil || input.BatchImageHoldMultiplier != nil) && - group.BatchImageHoldMultiplier < group.BatchImageDiscountMultiplier { - return nil, errors.New("batch_image_hold_multiplier must be >= batch_image_discount_multiplier") - } - if input.PeakRateEnabled != nil { - group.PeakRateEnabled = *input.PeakRateEnabled - } - if input.PeakStart != nil { - group.PeakStart = *input.PeakStart - } - if input.PeakEnd != nil { - group.PeakEnd = *input.PeakEnd - } - if input.PeakRateMultiplier != nil { - group.PeakRateMultiplier = *input.PeakRateMultiplier - } - // 先归一化(非订阅分组——含本次更新转为非订阅——静默清空高峰配置,清洗停用状态下的脏字段), - // 再收敛校验:Update 可能只传部分 peak 字段,需对合并后的最终配置统一校验, - // 防止单独修改 start/end 导致最终 start>=end 等非法配置入库。与 CreateGroup 同一收口。 - group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier = NormalizePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier) - if err := ValidatePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier); err != nil { - return nil, err - } - if input.ImagePrice1K != nil { - group.ImagePrice1K = normalizePrice(input.ImagePrice1K) - } - if input.ImagePrice2K != nil { - group.ImagePrice2K = normalizePrice(input.ImagePrice2K) - } - if input.ImagePrice4K != nil { - group.ImagePrice4K = normalizePrice(input.ImagePrice4K) - } - - // Claude Code 客户端限制 - if input.ClaudeCodeOnly != nil { - group.ClaudeCodeOnly = *input.ClaudeCodeOnly - } - if input.FallbackGroupID != nil { - // 校验降级分组 - if *input.FallbackGroupID > 0 { - if err := s.validateFallbackGroup(ctx, id, *input.FallbackGroupID); err != nil { - return nil, err - } - group.FallbackGroupID = input.FallbackGroupID - } else { - // 传入 0 或负数表示清除降级分组 - group.FallbackGroupID = nil - } - } - fallbackOnInvalidRequest := group.FallbackGroupIDOnInvalidRequest - if input.FallbackGroupIDOnInvalidRequest != nil { - if *input.FallbackGroupIDOnInvalidRequest > 0 { - fallbackOnInvalidRequest = input.FallbackGroupIDOnInvalidRequest - } else { - fallbackOnInvalidRequest = nil - } - } - if fallbackOnInvalidRequest != nil { - if err := s.validateFallbackGroupOnInvalidRequest(ctx, id, group.Platform, group.SubscriptionType, *fallbackOnInvalidRequest); err != nil { - return nil, err - } - } - group.FallbackGroupIDOnInvalidRequest = fallbackOnInvalidRequest - - // 模型路由配置 - if input.ModelRouting != nil { - group.ModelRouting = input.ModelRouting - } - if input.ModelRoutingEnabled != nil { - group.ModelRoutingEnabled = *input.ModelRoutingEnabled - } - if input.MCPXMLInject != nil { - group.MCPXMLInject = *input.MCPXMLInject - } - - // 支持的模型系列(仅 antigravity 平台使用) - if input.SupportedModelScopes != nil { - group.SupportedModelScopes = *input.SupportedModelScopes - } - - // OpenAI Messages 调度配置 - if input.AllowMessagesDispatch != nil { - group.AllowMessagesDispatch = *input.AllowMessagesDispatch - } - if input.RequireOAuthOnly != nil { - group.RequireOAuthOnly = *input.RequireOAuthOnly - } - if input.RequirePrivacySet != nil { - group.RequirePrivacySet = *input.RequirePrivacySet - } - if input.DefaultMappedModel != nil { - group.DefaultMappedModel = *input.DefaultMappedModel - } - if input.MessagesDispatchModelConfig != nil { - group.MessagesDispatchModelConfig = normalizeOpenAIMessagesDispatchModelConfig(*input.MessagesDispatchModelConfig) - } - if input.ModelsListConfig != nil { - group.ModelsListConfig = normalizeGroupModelsListConfig(*input.ModelsListConfig) - } - if input.RPMLimit != nil { - group.RPMLimit = *input.RPMLimit - } - sanitizeGroupMessagesDispatchFields(group) - - if err := s.groupRepo.Update(ctx, group); err != nil { - return nil, err - } - - if s.authCacheInvalidator != nil { - s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id) - } - - // 如果指定了复制账号的源分组,同步绑定(替换当前分组的账号) - if len(input.CopyAccountsFromGroupIDs) > 0 { - // 去重源分组 IDs - seen := make(map[int64]struct{}) - uniqueSourceGroupIDs := make([]int64, 0, len(input.CopyAccountsFromGroupIDs)) - for _, srcGroupID := range input.CopyAccountsFromGroupIDs { - // 校验:源分组不能是自身 - if srcGroupID == id { - return nil, fmt.Errorf("cannot copy accounts from self") - } - // 去重 - if _, exists := seen[srcGroupID]; !exists { - seen[srcGroupID] = struct{}{} - uniqueSourceGroupIDs = append(uniqueSourceGroupIDs, srcGroupID) - } - } - - // 校验源分组的平台是否与当前分组一致 - for _, srcGroupID := range uniqueSourceGroupIDs { - srcGroup, err := s.groupRepo.GetByIDLite(ctx, srcGroupID) - if err != nil { - return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err) - } - if srcGroup.Platform != group.Platform { - return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, group.Platform, srcGroup.Platform) - } - } - - // 获取所有源分组的账号(去重) - accountIDsToCopy, err := s.groupRepo.GetAccountIDsByGroupIDs(ctx, uniqueSourceGroupIDs) - if err != nil { - return nil, fmt.Errorf("failed to get accounts from source groups: %w", err) - } - - // 先清空当前分组的所有账号绑定 - if _, err := s.groupRepo.DeleteAccountGroupsByGroupID(ctx, id); err != nil { - return nil, fmt.Errorf("failed to clear existing account bindings: %w", err) - } - - // require_oauth_only: 过滤掉 apikey 类型账号 - if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && len(accountIDsToCopy) > 0 { - accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy) - if err != nil { - return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err) - } - oauthIDs := make(map[int64]struct{}, len(accounts)) - for _, acc := range accounts { - if acc.Type != AccountTypeAPIKey { - oauthIDs[acc.ID] = struct{}{} - } - } - var filtered []int64 - for _, aid := range accountIDsToCopy { - if _, ok := oauthIDs[aid]; ok { - filtered = append(filtered, aid) - } - } - accountIDsToCopy = filtered - } - - // 再绑定源分组的账号 - if len(accountIDsToCopy) > 0 { - if err := s.groupRepo.BindAccountsToGroup(ctx, id, accountIDsToCopy); err != nil { - return nil, fmt.Errorf("failed to bind accounts to group: %w", err) - } - } - } - - return group, nil -} - -func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error { - var groupKeys []string - if s.authCacheInvalidator != nil { - keys, err := s.apiKeyRepo.ListKeysByGroupID(ctx, id) - if err == nil { - groupKeys = keys - } - } - - affectedUserIDs, err := s.groupRepo.DeleteCascade(ctx, id) - if err != nil { - return err - } - // 注意:user_group_rate_multipliers 表通过外键 ON DELETE CASCADE 自动清理 - - // 事务成功后,异步失效受影响用户的订阅缓存 - if len(affectedUserIDs) > 0 && s.billingCacheService != nil { - groupID := id - go func() { - cacheCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - for _, userID := range affectedUserIDs { - if err := s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID); err != nil { - logger.LegacyPrintf("service.admin", "invalidate subscription cache failed: user_id=%d group_id=%d err=%v", userID, groupID, err) - } - } - }() - } - if s.authCacheInvalidator != nil { - for _, key := range groupKeys { - s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, key) - } - } - - return nil -} - -func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize} - keys, result, err := s.apiKeyRepo.ListByGroupID(ctx, groupID, params) - if err != nil { - return nil, 0, err - } - return keys, result.Total, nil -} - -func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error) { - if s.userGroupRateRepo == nil { - return nil, nil - } - return s.userGroupRateRepo.GetByGroupID(ctx, groupID) -} - -func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupID int64) error { - if s.userGroupRateRepo == nil { - return nil - } - return s.userGroupRateRepo.DeleteByGroupID(ctx, groupID) -} - -func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error { - if s.userGroupRateRepo == nil { - return nil - } - for _, e := range entries { - if e.RateMultiplier <= 0 { - return fmt.Errorf("rate_multiplier must be > 0 (user_id=%d)", e.UserID) - } - } - return s.userGroupRateRepo.SyncGroupRateMultipliers(ctx, groupID, entries) -} - -func (s *adminServiceImpl) ClearGroupRPMOverrides(ctx context.Context, groupID int64) error { - if s.userGroupRateRepo == nil { - return nil - } - if err := s.userGroupRateRepo.ClearGroupRPMOverrides(ctx, groupID); err != nil { - return err - } - // RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。 - if s.authCacheInvalidator != nil { - s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, groupID) - } - return nil -} - -func (s *adminServiceImpl) BatchSetGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error { - if s.userGroupRateRepo == nil { - return nil - } - for _, e := range entries { - if e.RPMOverride != nil && *e.RPMOverride < 0 { - return infraerrors.BadRequest("INVALID_RPM_OVERRIDE", fmt.Sprintf("rpm_override must be >= 0 (user_id=%d)", e.UserID)) - } - } - if err := s.userGroupRateRepo.SyncGroupRPMOverrides(ctx, groupID, entries); err != nil { - return err - } - // RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。 - if s.authCacheInvalidator != nil { - s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, groupID) - } - return nil -} - -func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error { - return s.groupRepo.UpdateSortOrders(ctx, updates) -} - -// AdminUpdateAPIKeyGroupID 管理员修改 API Key 分组绑定 -// groupID: nil=不修改, 指向0=解绑, 指向正整数=绑定到目标分组 -func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*AdminUpdateAPIKeyGroupIDResult, error) { - apiKey, err := s.apiKeyRepo.GetByID(ctx, keyID) - if err != nil { - return nil, err - } - - if groupID == nil { - // nil 表示不修改,直接返回 - return &AdminUpdateAPIKeyGroupIDResult{APIKey: apiKey}, nil - } - - if *groupID < 0 { - return nil, infraerrors.BadRequest("INVALID_GROUP_ID", "group_id must be non-negative") - } - - result := &AdminUpdateAPIKeyGroupIDResult{} - - if *groupID == 0 { - // 0 表示解绑分组(不修改 user_allowed_groups,避免影响用户其他 Key) - apiKey.GroupID = nil - apiKey.Group = nil - } else { - // 验证目标分组存在且状态为 active - group, err := s.groupRepo.GetByID(ctx, *groupID) - if err != nil { - return nil, err - } - if group.Status != StatusActive { - return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active") - } - // 订阅类型分组:用户须持有该分组的有效订阅才可绑定 - if group.IsSubscriptionType() { - if s.userSubRepo == nil { - return nil, infraerrors.InternalServer("SUBSCRIPTION_REPOSITORY_UNAVAILABLE", "subscription repository is not configured") - } - if _, err := s.userSubRepo.GetActiveByUserIDAndGroupID(ctx, apiKey.UserID, *groupID); err != nil { - if errors.Is(err, ErrSubscriptionNotFound) { - return nil, infraerrors.BadRequest("SUBSCRIPTION_REQUIRED", "user does not have an active subscription for this group") - } - return nil, err - } - } - - gid := *groupID - apiKey.GroupID = &gid - apiKey.Group = group - - // 专属标准分组:使用事务保证「添加分组权限」与「更新 API Key」的原子性 - if group.IsExclusive && !group.IsSubscriptionType() { - opCtx := ctx - var tx *dbent.Tx - if s.entClient == nil { - logger.LegacyPrintf("service.admin", "Warning: entClient is nil, skipping transaction protection for exclusive group binding") - } else { - var txErr error - tx, txErr = s.entClient.Tx(ctx) - if txErr != nil { - return nil, fmt.Errorf("begin transaction: %w", txErr) - } - defer func() { _ = tx.Rollback() }() - opCtx = dbent.NewTxContext(ctx, tx) - } - - if addErr := s.userRepo.AddGroupToAllowedGroups(opCtx, apiKey.UserID, gid); addErr != nil { - return nil, fmt.Errorf("add group to user allowed groups: %w", addErr) - } - if err := s.apiKeyRepo.Update(opCtx, apiKey); err != nil { - return nil, fmt.Errorf("update api key: %w", err) - } - if tx != nil { - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit transaction: %w", err) - } - } - - result.AutoGrantedGroupAccess = true - result.GrantedGroupID = &gid - result.GrantedGroupName = group.Name - - // 失效认证缓存(在事务提交后执行) - if s.authCacheInvalidator != nil { - s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key) - } - - result.APIKey = apiKey - return result, nil - } - } - - // 非专属分组 / 解绑:无需事务,单步更新即可 - if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil { - return nil, fmt.Errorf("update api key: %w", err) - } - - // 失效认证缓存 - if s.authCacheInvalidator != nil { - s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key) - } - - result.APIKey = apiKey - return result, nil -} - -// AdminResetAPIKeyRateLimitUsage resets all API key rate-limit usage windows. -func (s *adminServiceImpl) AdminResetAPIKeyRateLimitUsage(ctx context.Context, keyID int64) (*APIKey, error) { - apiKey, err := s.apiKeyRepo.GetByID(ctx, keyID) - if err != nil { - return nil, err - } - apiKey.Usage5h = 0 - apiKey.Usage1d = 0 - apiKey.Usage7d = 0 - apiKey.Window5hStart = nil - apiKey.Window1dStart = nil - apiKey.Window7dStart = nil - if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil { - return nil, fmt.Errorf("reset api key rate limit usage: %w", err) - } - if s.authCacheInvalidator != nil { - s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key) - } - if s.billingCacheService != nil { - _ = s.billingCacheService.InvalidateAPIKeyRateLimit(ctx, apiKey.ID) - } - return apiKey, nil -} - -// ReplaceUserGroup 替换用户的专属分组 -func (s *adminServiceImpl) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*ReplaceUserGroupResult, error) { - if oldGroupID == newGroupID { - return nil, infraerrors.BadRequest("SAME_GROUP", "old and new group must be different") - } - - // 验证新分组存在且为活跃的专属标准分组 - newGroup, err := s.groupRepo.GetByID(ctx, newGroupID) - if err != nil { - return nil, err - } - if newGroup.Status != StatusActive { - return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active") - } - if !newGroup.IsExclusive { - return nil, infraerrors.BadRequest("GROUP_NOT_EXCLUSIVE", "target group is not exclusive") - } - if newGroup.IsSubscriptionType() { - return nil, infraerrors.BadRequest("GROUP_IS_SUBSCRIPTION", "subscription groups are not supported for replacement") - } - - // 事务保证原子性 - if s.entClient == nil { - return nil, fmt.Errorf("entClient is nil, cannot perform group replacement") - } - tx, err := s.entClient.Tx(ctx) - if err != nil { - return nil, fmt.Errorf("begin transaction: %w", err) - } - defer func() { _ = tx.Rollback() }() - opCtx := dbent.NewTxContext(ctx, tx) - - // 1. 授予新分组权限 - if err := s.userRepo.AddGroupToAllowedGroups(opCtx, userID, newGroupID); err != nil { - return nil, fmt.Errorf("add new group to allowed groups: %w", err) - } - - // 2. 迁移绑定旧分组的 Key 到新分组 - migrated, err := s.apiKeyRepo.UpdateGroupIDByUserAndGroup(opCtx, userID, oldGroupID, newGroupID) - if err != nil { - return nil, fmt.Errorf("migrate api keys: %w", err) - } - - // 3. 移除旧分组权限 - if err := s.userRepo.RemoveGroupFromUserAllowedGroups(opCtx, userID, oldGroupID); err != nil { - return nil, fmt.Errorf("remove old group from allowed groups: %w", err) - } - - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit transaction: %w", err) - } - - // 失效该用户所有 Key 的认证缓存 - if s.authCacheInvalidator != nil { - keys, keyErr := s.apiKeyRepo.ListKeysByUserID(ctx, userID) - if keyErr == nil { - for _, k := range keys { - s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, k) - } - } - } - - return &ReplaceUserGroupResult{MigratedKeys: migrated}, nil -} - -// Account management implementations -func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]Account, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID, privacyMode) - if err != nil { - return nil, 0, err - } - return accounts, result.Total, nil -} - -func (s *adminServiceImpl) ListAccountsForSchedulerScoreFilter(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error) { - if s == nil || s.accountRepo == nil { - return nil, nil - } - return s.accountRepo.ListAllWithFilters(ctx, platform, accountType, status, search, groupID, privacyMode) -} - -func (s *adminServiceImpl) ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]Account, error) { - if s == nil || s.accountRepo == nil { - return nil, nil - } - if groupID != nil { - return s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI) - } - return s.accountRepo.ListSchedulableUngroupedByPlatform(ctx, PlatformOpenAI) -} - -func (s *adminServiceImpl) GetAccount(ctx context.Context, id int64) (*Account, error) { - return s.accountRepo.GetByID(ctx, id) -} - -func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error) { - if len(ids) == 0 { - return []*Account{}, nil - } - - accounts, err := s.accountRepo.GetByIDs(ctx, ids) - if err != nil { - return nil, fmt.Errorf("failed to get accounts by IDs: %w", err) - } - - return accounts, nil -} - -func normalizeAccountConcurrency(platform, accountType string, concurrency int) int { - if platform == PlatformGrok && accountType == AccountTypeOAuth { - if concurrency <= 0 { - return 1 - } - } - return concurrency -} - -func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) { - // 绑定分组 - groupIDs := input.GroupIDs - // 如果没有指定分组,自动绑定对应平台的默认分组 - if len(groupIDs) == 0 && !input.SkipDefaultGroupBind { - defaultGroupName := input.Platform + "-default" - groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform) - if err == nil { - for _, g := range groups { - if g.Name == defaultGroupName { - groupIDs = []int64{g.ID} - break - } - } - } - } - - // 检查混合渠道风险(除非用户已确认) - if len(groupIDs) > 0 && !input.SkipMixedChannelCheck { - if err := s.checkMixedChannelRisk(ctx, 0, input.Platform, groupIDs); err != nil { - return nil, err - } - } - - // 校验并规范化请求头覆写配置(header 名小写化、格式检查) - if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { - return nil, err - } - - account := &Account{ - Name: input.Name, - Notes: normalizeAccountNotes(input.Notes), - Platform: input.Platform, - Type: input.Type, - Credentials: input.Credentials, - Extra: input.Extra, - ProxyID: input.ProxyID, - Concurrency: normalizeAccountConcurrency(input.Platform, input.Type, input.Concurrency), - Priority: input.Priority, - Status: StatusActive, - Schedulable: true, - } - // 预计算固定时间重置的下次重置时间 - if account.Extra != nil { - if err := ValidateQuotaResetConfig(account.Extra); err != nil { - return nil, err - } - ComputeQuotaResetAt(account.Extra) - NormalizeFixedQuotaWindows(account.Extra) - } - if input.ExpiresAt != nil && *input.ExpiresAt > 0 { - expiresAt := time.Unix(*input.ExpiresAt, 0) - account.ExpiresAt = &expiresAt - } - if input.AutoPauseOnExpired != nil { - account.AutoPauseOnExpired = *input.AutoPauseOnExpired - } else { - account.AutoPauseOnExpired = true - } - if input.RateMultiplier != nil { - if *input.RateMultiplier < 0 { - return nil, errors.New("rate_multiplier must be >= 0") - } - account.RateMultiplier = input.RateMultiplier - } - if input.LoadFactor != nil && *input.LoadFactor > 0 { - if *input.LoadFactor > 10000 { - return nil, errors.New("load_factor must be <= 10000") - } - account.LoadFactor = input.LoadFactor - } - if err := s.accountRepo.Create(ctx, account); err != nil { - return nil, err - } - - // 绑定分组 - if len(groupIDs) > 0 { - if err := s.accountRepo.BindGroups(ctx, account.ID, groupIDs); err != nil { - return nil, err - } - } - - // OAuth 账号:创建后异步设置隐私。 - // 使用 Ensure(幂等)而非 Force:新建账号 Extra 为空时效果相同,但更安全。 - if account.Type == AccountTypeOAuth { - switch account.Platform { - case PlatformOpenAI: - go func() { - defer func() { - if r := recover(); r != nil { - slog.Error("create_account_openai_privacy_panic", "account_id", account.ID, "recover", r) - } - }() - s.EnsureOpenAIPrivacy(context.Background(), account) - }() - case PlatformAntigravity: - go func() { - defer func() { - if r := recover(); r != nil { - slog.Error("create_account_antigravity_privacy_panic", "account_id", account.ID, "recover", r) - } - }() - s.EnsureAntigravityPrivacy(context.Background(), account) - }() - } - } - - return account, nil -} - -func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error) { - account, err := s.accountRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - // 安全/身份不变量(影子账号):通用更新路径被 edit/re-auth/refresh/batch 共用, - // 必须在此守住,否则仅在创建时的保证可被这些路径绕过。 - if account.IsCredentialShadow() { - // 影子绝不持有凭据(凭据只在母账号)——外审 F5。 - if !isAllowedSparkShadowCredentialsUpdate(input.Credentials) { - return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS", - "spark shadow accounts do not hold auth credentials; only model mapping can be configured on the shadow account") - } - // 影子 type 不可变——很多上游逻辑按 account.Type 分支(OAuth transform / ChatGPT - // header 注入 / WS OAuth 决策),改成 apikey 会让 spark 影子被选中后按错误协议转发(外审 G7)。 - if input.Type != "" && input.Type != account.Type { - return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_IMMUTABLE_TYPE", - "spark shadow account type cannot be changed; it must remain an OpenAI OAuth shadow") - } - } else if input.Type != "" && input.Type != account.Type && input.Type != AccountTypeOAuth { - // 母账号守卫(外审 D/P1):有 spark 影子的账号不能把 type 改出 OpenAI OAuth——影子读透母 - // 凭据,母变成 apikey/setup_token 会让影子被调度后按错协议失败(resolveCredentialAccount - // 必报错)。须先删影子再改 type。 - shadows, serr := s.accountRepo.ListShadowsByParent(ctx, id) - if serr != nil { - return nil, serr - } - if len(shadows) > 0 { - return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IMMUTABLE_TYPE", - "cannot change account type while it has a spark shadow; delete the shadow first") - } - } - wasOveragesEnabled := account.IsOveragesEnabled() - - if input.Name != "" { - account.Name = input.Name - } - if input.Type != "" { - account.Type = input.Type - } - if input.Notes != nil { - account.Notes = normalizeAccountNotes(input.Notes) - } - if account.IsCredentialShadow() && input.Credentials != nil { - account.Credentials = sanitizeSparkShadowCredentials(input.Credentials) - } else if len(input.Credentials) > 0 { - // 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏, - // 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。 - account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials) - // 校验并规范化请求头覆写配置(header 名小写化、格式检查) - if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil { - return nil, err - } - } - // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。 - // 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。 - if input.Extra != nil { - // 保留配额用量字段,防止编辑账号时意外重置 - for _, key := range []string{"quota_used", "quota_daily_used", "quota_daily_start", "quota_weekly_used", "quota_weekly_start"} { - if v, ok := account.Extra[key]; ok { - input.Extra[key] = v - } - } - account.Extra = input.Extra - if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() { - delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态 - // 清除 AICredits 限流 key - if rawLimits, ok := account.Extra[modelRateLimitsKey].(map[string]any); ok { - delete(rawLimits, creditsExhaustedKey) - } - } - if account.Platform == PlatformAntigravity && !wasOveragesEnabled && account.IsOveragesEnabled() { - delete(account.Extra, modelRateLimitsKey) - delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态 - } - // 校验并预计算固定时间重置的下次重置时间 - if err := ValidateQuotaResetConfig(account.Extra); err != nil { - return nil, err - } - ComputeQuotaResetAt(account.Extra) - NormalizeFixedQuotaWindows(account.Extra) - } - // 影子代理恒继承母账号(由 propagateProxyToShadows 同步),不接受独立编辑——外审 B/P1; - // 否则要等母账号下次改 proxy 才被覆盖,期间影子会出现"有时继承、有时独立"的漂移。 - if input.ProxyID != nil && !account.IsCredentialShadow() { - // 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图) - if *input.ProxyID == 0 { - account.ProxyID = nil - } else { - account.ProxyID = input.ProxyID - } - account.Proxy = nil // 清除关联对象,防止 GORM Save 时根据 Proxy.ID 覆盖 ProxyID - } - // 只在指针非 nil 时更新 Concurrency(支持设置为 0) - if input.Concurrency != nil { - account.Concurrency = normalizeAccountConcurrency(account.Platform, account.Type, *input.Concurrency) - } - // 只在指针非 nil 时更新 Priority(支持设置为 0) - if input.Priority != nil { - account.Priority = *input.Priority - } - if input.RateMultiplier != nil { - if *input.RateMultiplier < 0 { - return nil, errors.New("rate_multiplier must be >= 0") - } - account.RateMultiplier = input.RateMultiplier - } - if input.LoadFactor != nil { - if *input.LoadFactor <= 0 { - account.LoadFactor = nil // 0 或负数表示清除 - } else if *input.LoadFactor > 10000 { - return nil, errors.New("load_factor must be <= 10000") - } else { - account.LoadFactor = input.LoadFactor - } - } - if input.Status != "" { - account.Status = input.Status - } - if input.ExpiresAt != nil { - if *input.ExpiresAt <= 0 { - account.ExpiresAt = nil - } else { - expiresAt := time.Unix(*input.ExpiresAt, 0) - account.ExpiresAt = &expiresAt - } - } - if input.AutoPauseOnExpired != nil { - account.AutoPauseOnExpired = *input.AutoPauseOnExpired - } - - // 先验证分组是否存在(在任何写操作之前) - if input.GroupIDs != nil { - if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil { - return nil, err - } - - // 检查混合渠道风险(除非用户已确认) - if !input.SkipMixedChannelCheck { - if err := s.checkMixedChannelRisk(ctx, account.ID, account.Platform, *input.GroupIDs); err != nil { - return nil, err - } - } - } - - if err := s.accountRepo.Update(ctx, account); err != nil { - return nil, err - } - - // 将 proxy 变更传播到 spark 影子账号(同步;Update 内部已触发调度快照)。 - // 影子自身 proxy 不可独立编辑(见上),故对影子的更新不触发传播。 - if input.ProxyID != nil && !account.IsCredentialShadow() { - if err := s.propagateProxyToShadows(ctx, id, account.ProxyID); err != nil { - return nil, err - } - } - - // 绑定分组 - if input.GroupIDs != nil { - if err := s.accountRepo.BindGroups(ctx, account.ID, *input.GroupIDs); err != nil { - return nil, err - } - } - - // 重新查询以确保返回完整数据(包括正确的 Proxy 关联对象) - updated, err := s.accountRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - return updated, nil -} - -// UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键 -// (如 model_rate_limits / passive_usage_* 等)。 -func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error { - if len(updates) == 0 { - return nil - } - return s.accountRepo.UpdateExtra(ctx, id, updates) -} - -// BulkUpdateAccounts updates multiple accounts in one request. -// It merges credentials/extra keys instead of overwriting the whole object. -func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) { - if len(input.AccountIDs) == 0 && input.Filters != nil { - accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters) - if err != nil { - return nil, err - } - input.AccountIDs = accountIDs - } - - result := &BulkUpdateAccountsResult{ - SuccessIDs: make([]int64, 0, len(input.AccountIDs)), - FailedIDs: make([]int64, 0, len(input.AccountIDs)), - Results: make([]BulkUpdateAccountResult, 0, len(input.AccountIDs)), - } - - if len(input.AccountIDs) == 0 { - return result, nil - } - if input.GroupIDs != nil { - if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil { - return nil, err - } - } - - needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck - - // 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。 - var cachedTargets []*Account - if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck { - loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs) - if err != nil { - return nil, err - } - cachedTargets = loaded - } - - // 影子账号绝不持有凭据:批量更新携带凭据时,目标中不得含影子(外审 G5,与单账号 - // UpdateAccount 守卫对齐)。覆盖显式 IDs 与 filter 解析出的 IDs(此处 AccountIDs 已解析完成)。 - if len(input.Credentials) > 0 { - for _, acc := range cachedTargets { - if acc != nil && acc.IsCredentialShadow() { - return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS", - "spark shadow account %d cannot hold credentials; manage credentials on the parent account", acc.ID) - } - } - } - - // 影子账号 proxy 恒继承母账号(与单账号 UpdateAccount 守卫对齐——外审第4轮 P1):批量携带 proxy - // 时目标不得含影子,否则影子会获得独立 proxy、破坏继承不变量(网关按所选影子自身 proxy 出站, - // 要等母账号下次改 proxy 才覆盖→漂移)。含影子即整体拒绝,提示从选择中剔除影子。 - if input.ProxyID != nil { - for _, acc := range cachedTargets { - if acc != nil && acc.IsCredentialShadow() { - return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_PROXY_INHERITED", - "spark shadow account %d proxy is inherited from its parent and cannot be set in bulk; manage it on the parent account", acc.ID) - } - } - } - - // 预加载账号平台信息(混合渠道检查需要)。 - platformByID := map[int64]string{} - if needMixedChannelCheck { - for _, account := range cachedTargets { - if account != nil { - platformByID[account.ID] = account.Platform - } - } - } - - // 预检查混合渠道风险:在任何写操作之前,若发现风险立即返回错误。 - if needMixedChannelCheck { - for _, accountID := range input.AccountIDs { - platform := platformByID[accountID] - if platform == "" { - continue - } - if err := s.checkMixedChannelRisk(ctx, accountID, platform, *input.GroupIDs); err != nil { - return nil, err - } - } - } - - if input.RateMultiplier != nil { - if *input.RateMultiplier < 0 { - return nil, errors.New("rate_multiplier must be >= 0") - } - } - - // 校验并规范化请求头覆写配置(批量路径为 JSONB 顶层 key 合并,直接校验增量即可) - if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { - return nil, err - } - - // Prepare bulk updates for columns and JSONB fields. - repoUpdates := AccountBulkUpdate{ - Credentials: input.Credentials, - Extra: input.Extra, - } - if input.Name != "" { - repoUpdates.Name = &input.Name - } - if input.ProxyID != nil { - repoUpdates.ProxyID = input.ProxyID - } - if input.Concurrency != nil { - repoUpdates.Concurrency = input.Concurrency - } - if input.Priority != nil { - repoUpdates.Priority = input.Priority - } - if input.RateMultiplier != nil { - repoUpdates.RateMultiplier = input.RateMultiplier - } - if input.LoadFactor != nil { - if *input.LoadFactor <= 0 { - repoUpdates.LoadFactor = nil // 0 或负数表示清除 - } else if *input.LoadFactor > 10000 { - return nil, errors.New("load_factor must be <= 10000") - } else { - repoUpdates.LoadFactor = input.LoadFactor - } - } - if input.Status != "" { - repoUpdates.Status = &input.Status - } - if input.Schedulable != nil { - repoUpdates.Schedulable = input.Schedulable - } - - // Run bulk update for column/jsonb fields first. - if _, err := s.accountRepo.BulkUpdate(ctx, input.AccountIDs, repoUpdates); err != nil { - return nil, err - } - - // 将 proxy 变更传播到每个目标账号的 spark 影子账号 - if repoUpdates.ProxyID != nil { - var effectiveProxyID *int64 - if *repoUpdates.ProxyID != 0 { - effectiveProxyID = repoUpdates.ProxyID - } - for _, accountID := range input.AccountIDs { - if err := s.propagateProxyToShadows(ctx, accountID, effectiveProxyID); err != nil { - return nil, err - } - } - } - - // Handle group bindings per account (requires individual operations). - for _, accountID := range input.AccountIDs { - entry := BulkUpdateAccountResult{AccountID: accountID} - - if input.GroupIDs != nil { - if err := s.accountRepo.BindGroups(ctx, accountID, *input.GroupIDs); err != nil { - entry.Success = false - entry.Error = err.Error() - result.Failed++ - result.FailedIDs = append(result.FailedIDs, accountID) - result.Results = append(result.Results, entry) - continue - } - } - - entry.Success = true - result.Success++ - result.SuccessIDs = append(result.SuccessIDs, accountID) - result.Results = append(result.Results, entry) - } - - return result, nil -} - -func (s *adminServiceImpl) resolveBulkUpdateTargetIDs(ctx context.Context, filters *BulkUpdateAccountFilters) ([]int64, error) { - if filters == nil { - return nil, nil - } - - groupID := int64(0) - switch strings.TrimSpace(filters.Group) { - case "": - case "ungrouped": - groupID = AccountListGroupUngrouped - default: - parsedGroupID, err := strconv.ParseInt(strings.TrimSpace(filters.Group), 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid group filter: %w", err) - } - groupID = parsedGroupID - } - - const pageSize = 500 - page := 1 - accountIDs := make([]int64, 0, pageSize) - - for { - accounts, total, err := s.ListAccounts( - ctx, - page, - pageSize, - filters.Platform, - filters.Type, - filters.Status, - filters.Search, - groupID, - filters.PrivacyMode, - "", - "", - ) - if err != nil { - return nil, err - } - for _, account := range accounts { - accountIDs = append(accountIDs, account.ID) - } - if int64(len(accountIDs)) >= total || len(accounts) == 0 { - return accountIDs, nil - } - page++ - } -} - -func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error { - // 级联删除 spark 影子账号(先删影子,再删母账号) - shadows, err := s.accountRepo.ListShadowsByParent(ctx, id) - if err != nil { - return fmt.Errorf("list spark shadows for cascade delete: %w", err) - } - for _, shadow := range shadows { - if err := s.accountRepo.Delete(ctx, shadow.ID); err != nil { - return fmt.Errorf("cascade delete spark shadow %d: %w", shadow.ID, err) - } - } - if err := s.accountRepo.Delete(ctx, id); err != nil { - return err - } - return nil -} - -func (s *adminServiceImpl) RefreshAccountCredentials(ctx context.Context, id int64) (*Account, error) { - account, err := s.accountRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - // TODO: Implement refresh logic - return account, nil -} - -func (s *adminServiceImpl) ClearAccountError(ctx context.Context, id int64) (*Account, error) { - if err := s.accountRepo.ClearError(ctx, id); err != nil { - return nil, err - } - if err := s.accountRepo.ClearRateLimit(ctx, id); err != nil { - return nil, err - } - if err := s.accountRepo.ClearAntigravityQuotaScopes(ctx, id); err != nil { - return nil, err - } - if err := s.accountRepo.ClearModelRateLimits(ctx, id); err != nil { - return nil, err - } - if err := s.accountRepo.ClearTempUnschedulable(ctx, id); err != nil { - return nil, err - } - if s.runtimeBlocker != nil { - s.runtimeBlocker.ClearAccountSchedulingBlock(id) - } - return s.accountRepo.GetByID(ctx, id) -} - -func (s *adminServiceImpl) SetAccountError(ctx context.Context, id int64, errorMsg string) error { - return s.accountRepo.SetError(ctx, id, errorMsg) -} - -func (s *adminServiceImpl) SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error) { - if err := s.accountRepo.SetSchedulable(ctx, id, schedulable); err != nil { - return nil, err - } - updated, err := s.accountRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - return updated, nil -} - -func (s *adminServiceImpl) RevertAccountProxyFallback(ctx context.Context, id int64) error { - if err := s.accountRepo.RevertProxyFallback(ctx, id); err != nil { - return err - } - // 加载回退后的账号以获取实际 ProxyID,再传播到影子账号 - account, err := s.accountRepo.GetByID(ctx, id) - if err != nil { - return fmt.Errorf("get account after proxy revert: %w", err) - } - return s.propagateProxyToShadows(ctx, id, account.ProxyID) -} - -// CreateShadow 为指定 OpenAI OAuth 母账号创建 spark 维度影子账号(一母一影)。 -// 安全不变量:Credentials 恒不含 auth token(仅 model_mapping,守卫 isAllowedSparkShadowCredentialsUpdate 放行)。 -func (s *adminServiceImpl) CreateShadow(ctx context.Context, parentID int64, opts ShadowOptions) (*Account, error) { - // 1. 加载母账号并校验平台/类型 - parent, err := s.accountRepo.GetByID(ctx, parentID) - if err != nil { - return nil, fmt.Errorf("get parent account: %w", err) - } - if !parent.IsOpenAIOAuth() { - return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_INVALID_PARENT", - "spark shadow requires an OpenAI OAuth parent account") - } - // G6:母账号本身不能是影子,否则会建出二级影子——resolveCredentialAccount 只解一层, - // 会解析到无凭据的一级影子,进入坏调度/上游失败。 - if parent.IsCredentialShadow() { - return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IS_SHADOW", - "spark shadow parent must be a real account, not another spark shadow") - } - - // 2. 一母一影校验 - shadows, err := s.accountRepo.ListShadowsByParent(ctx, parentID) - if err != nil { - return nil, fmt.Errorf("check existing spark shadows: %w", err) - } - if len(shadows) > 0 { - return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS", - "parent account already has a spark shadow account") - } - - // 3. 解析分组。未指定 GroupIDs 时:优先**继承母账号当前分组**(影子与母同路由域,母在自定义 - // 组时该组的 spark 请求也能选到影子;G1 决策);母无分组再回落 openai-default(F4)。 - // 显式指定 GroupIDs 时,与 UpdateAccount 对齐先校验存在性(创建前),避免建出影子后再因无效组 - // 失败而留下孤儿影子(一母一影唯一索引会挡住重试)——外审 C/P1。 - groupIDs := opts.GroupIDs - if len(groupIDs) > 0 { - if s.groupRepo != nil { - if err := s.validateGroupIDsExist(ctx, groupIDs); err != nil { - return nil, err - } - } - } else if len(parent.GroupIDs) > 0 { - groupIDs = append([]int64(nil), parent.GroupIDs...) - } else if s.groupRepo != nil { - defaultGroupName := PlatformOpenAI + "-default" - if groups, gerr := s.groupRepo.ListActiveByPlatform(ctx, PlatformOpenAI); gerr == nil { - for _, g := range groups { - if g.Name == defaultGroupName { - groupIDs = []int64{g.ID} - break - } - } - } - } - - // 4. 构造影子账号(安全不变量:Credentials 恒不含 auth token,仅含 model_mapping)。 - // name 为空时默认 "<母账号名> (Spark)"——否则空 name 会在 ent(name NotEmpty)处变成裸 500 - // (外审 E/P2);并 rune 安全截断到 ent MaxLen(100)。 - name := strings.TrimSpace(opts.Name) - if name == "" { - name = parent.Name + " (Spark)" - } - if runes := []rune(name); len(runes) > 100 { - name = string(runes[:100]) - } - // 并发未指定(<=0)时继承母账号,避免 0 被限流器解读为"无限并发"(外审 F3)。 - concurrency := opts.Concurrency - if concurrency <= 0 { - concurrency = parent.Concurrency - } - // 优先级未指定(<=0)时继承母账号——前端一键创建只传 name,opts.Priority 省略即 0,而调度 - // 比较是「数值越小越优先」(openai_account_scheduler.isOpenAIAccountCandidateBetter),且 repo - // 显式 SetPriority 会绕过 ent 默认 50,直写 0 会让影子意外抢到最高优先级(外审第5轮 P1)。 - // 与上方 Concurrency 一致采用「省略继承母账号」语义(影子的 proxy/分组/并发亦全部继承母账号)。 - priority := opts.Priority - if priority <= 0 { - priority = parent.Priority - } - shadow := &Account{ - Name: name, - Platform: PlatformOpenAI, - Type: AccountTypeOAuth, - Status: StatusActive, - Credentials: map[string]any{"model_mapping": defaultSparkShadowModelMapping()}, - ParentAccountID: &parentID, - QuotaDimension: QuotaDimensionSpark, - ProxyID: parent.ProxyID, - Priority: priority, - Concurrency: concurrency, - Schedulable: true, - } - - // 5. 持久化(Create 填充 shadow.ID)。并发竞态:预查(步骤2)放行后另一请求抢先建成,本次会撞 - // 一母一影唯一索引。复查确认确为"已存在"竞态时返回结构化 409 而非裸 500——外审 A/P1。 - if err := s.accountRepo.Create(ctx, shadow); err != nil { - if existing, qerr := s.accountRepo.ListShadowsByParent(ctx, parentID); qerr == nil && len(existing) > 0 { - return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS", - "parent account already has a spark shadow account") - } - return nil, fmt.Errorf("create spark shadow: %w", err) - } - - // 6. 绑定分组。注意:create+bind 非单一 DB 事务(通用 Create 走 r.client、outbox 走 r.sql, - // 无现成共享事务路径),故绑组失败时做 best-effort 补偿删除刚建的影子,避免半成品影子(否则 - // 一母一影唯一索引会挡住重试)——外审 C/P1。补偿删除用 detached ctx,即便请求 ctx 已取消/超时 - // 仍能完成清理(外审第4轮);进程崩溃这种极端仍可能残留,属已知权衡。 - if len(groupIDs) > 0 { - if err := s.accountRepo.BindGroups(ctx, shadow.ID, groupIDs); err != nil { - if delErr := s.accountRepo.Delete(context.WithoutCancel(ctx), shadow.ID); delErr != nil { - slog.Error("spark_shadow_bind_groups_rollback_failed", - "shadow_id", shadow.ID, "parent_id", parentID, "delete_err", delErr) - } - return nil, fmt.Errorf("bind groups for spark shadow: %w", err) - } - shadow.GroupIDs = groupIDs - } - - return shadow, nil -} - -// propagateProxyToShadows syncs proxyID to all spark shadow accounts of parentID. -// It is called synchronously so that proxy changes are immediately consistent; -// accountRepo.Update triggers the scheduler outbox + cache propagation internally. -// Calling this for a non-parent account is a harmless no-op. -func (s *adminServiceImpl) propagateProxyToShadows(ctx context.Context, parentID int64, proxyID *int64) error { - return propagateAccountProxyToShadows(ctx, s.accountRepo, parentID, proxyID) -} - -// propagateAccountProxyToShadows 把母账号的 proxy 同步到其所有 spark 影子(影子 proxy 恒继承母账号)。 -// 供 AdminService 编辑路径与 CRS 同步路径共用——后者改动母账号 proxy 后必须同样传播,否则影子保留 -// 旧 proxy 出现出站漂移(外审第8轮)。 -func propagateAccountProxyToShadows(ctx context.Context, repo AccountRepository, parentID int64, proxyID *int64) error { - shadows, err := repo.ListShadowsByParent(ctx, parentID) - if err != nil { - return fmt.Errorf("list spark shadows for proxy propagation: %w", err) - } - for _, shadow := range shadows { - shadow.ProxyID = proxyID - if err := repo.Update(ctx, shadow); err != nil { - return fmt.Errorf("update spark shadow %d proxy: %w", shadow.ID, err) - } - } - return nil -} - -// Proxy management implementations -func (s *adminServiceImpl) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]Proxy, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - proxies, result, err := s.proxyRepo.ListWithFilters(ctx, params, protocol, status, search) - if err != nil { - return nil, 0, err - } - return proxies, result.Total, nil -} - -func (s *adminServiceImpl) ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]ProxyWithAccountCount, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - proxies, result, err := s.proxyRepo.ListWithFiltersAndAccountCount(ctx, params, protocol, status, search) - if err != nil { - return nil, 0, err - } - s.attachProxyLatency(ctx, proxies) - return proxies, result.Total, nil -} - -func (s *adminServiceImpl) GetAllProxies(ctx context.Context) ([]Proxy, error) { - return s.proxyRepo.ListActive(ctx) -} - -func (s *adminServiceImpl) GetAllProxiesWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) { - proxies, err := s.proxyRepo.ListActiveWithAccountCount(ctx) - if err != nil { - return nil, err - } - s.attachProxyLatency(ctx, proxies) - return proxies, nil -} - -func (s *adminServiceImpl) GetProxy(ctx context.Context, id int64) (*Proxy, error) { - return s.proxyRepo.GetByID(ctx, id) -} - -func (s *adminServiceImpl) GetProxiesByIDs(ctx context.Context, ids []int64) ([]Proxy, error) { - return s.proxyRepo.ListByIDs(ctx, ids) -} - -func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error) { - // 规范化 fallback_mode - mode := input.FallbackMode - if mode == "" { - mode = FallbackModeNone - } - // 校验:mode=proxy 必须有 backup - if mode == FallbackModeProxy && input.BackupProxyID == nil { - return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy") - } - if input.ExpiryWarnDays < 0 { - return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0") - } - - proxy := &Proxy{ - Name: input.Name, - Protocol: input.Protocol, - Host: input.Host, - Port: input.Port, - Username: input.Username, - Password: input.Password, - Status: StatusActive, - ExpiresAt: input.ExpiresAt, - FallbackMode: mode, - BackupProxyID: input.BackupProxyID, - ExpiryWarnDays: input.ExpiryWarnDays, - } - if err := s.proxyRepo.Create(ctx, proxy); err != nil { - return nil, err - } - // Probe latency asynchronously so creation isn't blocked by network timeout. - go s.probeProxyLatency(context.Background(), proxy) - return proxy, nil -} - -func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *UpdateProxyInput) (*Proxy, error) { - // 校验:backup_proxy_id 不能是自身 - if input.BackupProxyID != nil && *input.BackupProxyID == id { - return nil, infraerrors.BadRequest("PROXY_BACKUP_SELF", "backup proxy cannot be itself") - } - // 规范化 fallback_mode - mode := input.FallbackMode - if mode == "" { - mode = FallbackModeNone - } - // 校验:mode=proxy 必须有 backup - if mode == FallbackModeProxy && input.BackupProxyID == nil { - return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy") - } - if input.ExpiryWarnDays < 0 { - return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0") - } - - proxy, err := s.proxyRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - - if input.Name != "" { - proxy.Name = input.Name - } - if input.Protocol != "" { - proxy.Protocol = input.Protocol - } - if input.Host != "" { - proxy.Host = input.Host - } - if input.Port != 0 { - proxy.Port = input.Port - } - if input.Username != "" { - proxy.Username = input.Username - } - if input.Password != "" { - proxy.Password = input.Password - } - if input.Status != "" { - proxy.Status = input.Status - } - // 透传有效期与回退字段 - proxy.ExpiresAt = input.ExpiresAt - proxy.FallbackMode = mode - proxy.BackupProxyID = input.BackupProxyID - proxy.ExpiryWarnDays = input.ExpiryWarnDays - - if err := s.proxyRepo.Update(ctx, proxy); err != nil { - return nil, err - } - return proxy, nil -} - -func (s *adminServiceImpl) DeleteProxy(ctx context.Context, id int64) error { - count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id) - if err != nil { - return err - } - if count > 0 { - return ErrProxyInUse - } - return s.proxyRepo.Delete(ctx, id) -} - -func (s *adminServiceImpl) BatchDeleteProxies(ctx context.Context, ids []int64) (*ProxyBatchDeleteResult, error) { - result := &ProxyBatchDeleteResult{} - if len(ids) == 0 { - return result, nil - } - - for _, id := range ids { - count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id) - if err != nil { - result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ - ID: id, - Reason: err.Error(), - }) - continue - } - if count > 0 { - result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ - ID: id, - Reason: ErrProxyInUse.Error(), - }) - continue - } - if err := s.proxyRepo.Delete(ctx, id); err != nil { - result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ - ID: id, - Reason: err.Error(), - }) - continue - } - result.DeletedIDs = append(result.DeletedIDs, id) - } - - return result, nil -} - -func (s *adminServiceImpl) GetProxyAccounts(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) { - return s.proxyRepo.ListAccountSummariesByProxyID(ctx, proxyID) -} - -func (s *adminServiceImpl) CheckProxyExists(ctx context.Context, host string, port int, username, password string) (bool, error) { - return s.proxyRepo.ExistsByHostPortAuth(ctx, host, port, username, password) -} - -// Redeem code management implementations -func (s *adminServiceImpl) ListRedeemCodes(ctx context.Context, page, pageSize int, codeType, status, search string, sortBy, sortOrder string) ([]RedeemCode, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - codes, result, err := s.redeemCodeRepo.ListWithFilters(ctx, params, codeType, status, search) - if err != nil { - return nil, 0, err - } - return codes, result.Total, nil -} - -func (s *adminServiceImpl) GetRedeemCode(ctx context.Context, id int64) (*RedeemCode, error) { - return s.redeemCodeRepo.GetByID(ctx, id) -} - -func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *GenerateRedeemCodesInput) ([]RedeemCode, error) { - if input.ExpiresAt != nil && !input.ExpiresAt.After(time.Now()) { - return nil, ErrRedeemCodeExpired - } - - // 如果是订阅类型,验证必须有 GroupID - if input.Type == RedeemTypeSubscription { - if input.GroupID == nil { - return nil, errors.New("group_id is required for subscription type") - } - // 验证分组存在且为订阅类型 - group, err := s.groupRepo.GetByID(ctx, *input.GroupID) - if err != nil { - return nil, fmt.Errorf("group not found: %w", err) - } - if !group.IsSubscriptionType() { - return nil, errors.New("group must be subscription type") - } - } - - codes := make([]RedeemCode, 0, input.Count) - for i := 0; i < input.Count; i++ { - codeValue, err := GenerateRedeemCode() - if err != nil { - return nil, err - } - code := RedeemCode{ - Code: codeValue, - Type: input.Type, - Value: input.Value, - Status: StatusUnused, - ExpiresAt: input.ExpiresAt, - } - // 订阅类型专用字段 - if input.Type == RedeemTypeSubscription { - code.GroupID = input.GroupID - code.ValidityDays = input.ValidityDays - if code.ValidityDays <= 0 { - code.ValidityDays = 30 // 默认30天 - } - } - if err := s.redeemCodeRepo.Create(ctx, &code); err != nil { - return nil, err - } - codes = append(codes, code) - } - return codes, nil -} - -func (s *adminServiceImpl) DeleteRedeemCode(ctx context.Context, id int64) error { - return s.redeemCodeRepo.Delete(ctx, id) -} - -func (s *adminServiceImpl) BatchDeleteRedeemCodes(ctx context.Context, ids []int64) (int64, error) { - var deleted int64 - for _, id := range ids { - if err := s.redeemCodeRepo.Delete(ctx, id); err == nil { - deleted++ - } - } - return deleted, nil -} - -func (s *adminServiceImpl) ExpireRedeemCode(ctx context.Context, id int64) (*RedeemCode, error) { - code, err := s.redeemCodeRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - code.Status = StatusExpired - if err := s.redeemCodeRepo.Update(ctx, code); err != nil { - return nil, err - } - return code, nil -} - -func (s *adminServiceImpl) TestProxy(ctx context.Context, id int64) (*ProxyTestResult, error) { - proxy, err := s.proxyRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - - proxyURL := proxy.URL() - exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxyURL) - if err != nil { - s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{ - Success: false, - Message: err.Error(), - UpdatedAt: time.Now(), - }) - return &ProxyTestResult{ - Success: false, - Message: err.Error(), - }, nil - } - - latency := latencyMs - s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{ - Success: true, - LatencyMs: &latency, - Message: "Proxy is accessible", - IPAddress: exitInfo.IP, - Country: exitInfo.Country, - CountryCode: exitInfo.CountryCode, - Region: exitInfo.Region, - City: exitInfo.City, - UpdatedAt: time.Now(), - }) - return &ProxyTestResult{ - Success: true, - Message: "Proxy is accessible", - LatencyMs: latencyMs, - IPAddress: exitInfo.IP, - City: exitInfo.City, - Region: exitInfo.Region, - Country: exitInfo.Country, - CountryCode: exitInfo.CountryCode, - }, nil -} - -func (s *adminServiceImpl) CheckProxyQuality(ctx context.Context, id int64) (*ProxyQualityCheckResult, error) { - proxy, err := s.proxyRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - - result := &ProxyQualityCheckResult{ - ProxyID: id, - Score: 100, - Grade: "A", - CheckedAt: time.Now().Unix(), - Items: make([]ProxyQualityCheckItem, 0, len(proxyQualityTargets)+1), - } - - proxyURL := proxy.URL() - if s.proxyProber == nil { - result.Items = append(result.Items, ProxyQualityCheckItem{ - Target: "base_connectivity", - Status: "fail", - Message: "代理探测服务未配置", - }) - result.FailedCount++ - finalizeProxyQualityResult(result) - s.saveProxyQualitySnapshot(ctx, id, result, nil) - return result, nil - } - - exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxyURL) - if err != nil { - result.Items = append(result.Items, ProxyQualityCheckItem{ - Target: "base_connectivity", - Status: "fail", - LatencyMs: latencyMs, - Message: err.Error(), - }) - result.FailedCount++ - finalizeProxyQualityResult(result) - s.saveProxyQualitySnapshot(ctx, id, result, nil) - return result, nil - } - - result.ExitIP = exitInfo.IP - result.Country = exitInfo.Country - result.CountryCode = exitInfo.CountryCode - result.BaseLatencyMs = latencyMs - result.Items = append(result.Items, ProxyQualityCheckItem{ - Target: "base_connectivity", - Status: "pass", - LatencyMs: latencyMs, - Message: "代理出口连通正常", - }) - result.PassedCount++ - - client, err := httpclient.GetClient(httpclient.Options{ - ProxyURL: proxyURL, - Timeout: proxyQualityRequestTimeout, - ResponseHeaderTimeout: proxyQualityResponseHeaderTimeout, - }) - if err != nil { - result.Items = append(result.Items, ProxyQualityCheckItem{ - Target: "http_client", - Status: "fail", - Message: fmt.Sprintf("创建检测客户端失败: %v", err), - }) - result.FailedCount++ - finalizeProxyQualityResult(result) - s.saveProxyQualitySnapshot(ctx, id, result, exitInfo) - return result, nil - } - - for _, target := range proxyQualityTargets { - item := runProxyQualityTarget(ctx, client, target) - result.Items = append(result.Items, item) - switch item.Status { - case "pass": - result.PassedCount++ - case "warn": - result.WarnCount++ - case "challenge": - result.ChallengeCount++ - default: - result.FailedCount++ - } - } - - finalizeProxyQualityResult(result) - s.saveProxyQualitySnapshot(ctx, id, result, exitInfo) - return result, nil -} - -func runProxyQualityTarget(ctx context.Context, client *http.Client, target proxyQualityTarget) ProxyQualityCheckItem { - item := ProxyQualityCheckItem{ - Target: target.Target, - } - - req, err := http.NewRequestWithContext(ctx, target.Method, target.URL, nil) - if err != nil { - item.Status = "fail" - item.Message = fmt.Sprintf("构建请求失败: %v", err) - return item - } - req.Header.Set("Accept", "application/json,text/html,*/*") - req.Header.Set("User-Agent", proxyQualityClientUserAgent) - - start := time.Now() - resp, err := client.Do(req) - if err != nil { - item.Status = "fail" - item.LatencyMs = time.Since(start).Milliseconds() - item.Message = fmt.Sprintf("请求失败: %v", err) - return item - } - defer func() { _ = resp.Body.Close() }() - item.LatencyMs = time.Since(start).Milliseconds() - item.HTTPStatus = resp.StatusCode - - body, readErr := io.ReadAll(io.LimitReader(resp.Body, proxyQualityMaxBodyBytes+1)) - if readErr != nil { - item.Status = "fail" - item.Message = fmt.Sprintf("读取响应失败: %v", readErr) - return item - } - if int64(len(body)) > proxyQualityMaxBodyBytes { - body = body[:proxyQualityMaxBodyBytes] - } - - // Cloudflare challenge 检测 - if httputil.IsCloudflareChallengeResponse(resp.StatusCode, resp.Header, body) { - item.Status = "challenge" - item.CFRay = httputil.ExtractCloudflareRayID(resp.Header, body) - item.Message = "命中 Cloudflare challenge" - return item - } - - if _, ok := target.AllowedStatuses[resp.StatusCode]; ok { - // 白名单内的状态码均代表目标可达:2xx 表示接口直接可用, - // 401/405 等是无鉴权探测的预期结果,同样视为连通正常,不再扣分。 - item.Status = "pass" - if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { - item.Message = fmt.Sprintf("HTTP %d", resp.StatusCode) - } else { - item.Message = fmt.Sprintf("HTTP %d(目标可达)", resp.StatusCode) - } - return item - } - - if resp.StatusCode == http.StatusTooManyRequests { - item.Status = "warn" - item.Message = "目标返回 429,可能存在频控" - return item - } - - item.Status = "fail" - item.Message = fmt.Sprintf("非预期状态码: %d", resp.StatusCode) - return item -} - -func finalizeProxyQualityResult(result *ProxyQualityCheckResult) { - if result == nil { - return - } - score := 100 - result.WarnCount*10 - result.FailedCount*22 - result.ChallengeCount*30 - if score < 0 { - score = 0 - } - result.Score = score - result.Grade = proxyQualityGrade(score) - result.Summary = fmt.Sprintf( - "通过 %d 项,告警 %d 项,失败 %d 项,挑战 %d 项", - result.PassedCount, - result.WarnCount, - result.FailedCount, - result.ChallengeCount, - ) -} - -func proxyQualityGrade(score int) string { - switch { - case score >= 90: - return "A" - case score >= 75: - return "B" - case score >= 60: - return "C" - case score >= 40: - return "D" - default: - return "F" - } -} - -func proxyQualityOverallStatus(result *ProxyQualityCheckResult) string { - if result == nil { - return "" - } - if result.ChallengeCount > 0 { - return "challenge" - } - if result.FailedCount > 0 { - return "failed" - } - if result.WarnCount > 0 { - return "warn" - } - if result.PassedCount > 0 { - return "healthy" - } - return "failed" -} - -func proxyQualityFirstCFRay(result *ProxyQualityCheckResult) string { - if result == nil { - return "" - } - for _, item := range result.Items { - if item.CFRay != "" { - return item.CFRay - } - } - return "" -} - -func proxyQualityBaseConnectivityPass(result *ProxyQualityCheckResult) bool { - if result == nil { - return false - } - for _, item := range result.Items { - if item.Target == "base_connectivity" { - return item.Status == "pass" - } - } - return false -} - -func (s *adminServiceImpl) saveProxyQualitySnapshot(ctx context.Context, proxyID int64, result *ProxyQualityCheckResult, exitInfo *ProxyExitInfo) { - if result == nil { - return - } - score := result.Score - checkedAt := result.CheckedAt - info := &ProxyLatencyInfo{ - Success: proxyQualityBaseConnectivityPass(result), - Message: result.Summary, - QualityStatus: proxyQualityOverallStatus(result), - QualityScore: &score, - QualityGrade: result.Grade, - QualitySummary: result.Summary, - QualityCheckedAt: &checkedAt, - QualityCFRay: proxyQualityFirstCFRay(result), - UpdatedAt: time.Now(), - } - if result.BaseLatencyMs > 0 { - latency := result.BaseLatencyMs - info.LatencyMs = &latency - } - if exitInfo != nil { - info.IPAddress = exitInfo.IP - info.Country = exitInfo.Country - info.CountryCode = exitInfo.CountryCode - info.Region = exitInfo.Region - info.City = exitInfo.City - } - s.saveProxyLatency(ctx, proxyID, info) -} - -func (s *adminServiceImpl) probeProxyLatency(ctx context.Context, proxy *Proxy) { - if s.proxyProber == nil || proxy == nil { - return - } - exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxy.URL()) - if err != nil { - s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{ - Success: false, - Message: err.Error(), - UpdatedAt: time.Now(), - }) - return - } - - latency := latencyMs - s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{ - Success: true, - LatencyMs: &latency, - Message: "Proxy is accessible", - IPAddress: exitInfo.IP, - Country: exitInfo.Country, - CountryCode: exitInfo.CountryCode, - Region: exitInfo.Region, - City: exitInfo.City, - UpdatedAt: time.Now(), - }) -} - -// checkMixedChannelRisk 检查分组中是否存在混合渠道(Antigravity + Anthropic) -// 如果存在混合,返回错误提示用户确认 -func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { - // 判断当前账号的渠道类型(基于 platform 字段,而不是 type 字段) - currentPlatform := getAccountPlatform(currentAccountPlatform) - if currentPlatform == "" { - // 不是 Antigravity 或 Anthropic,无需检查 - return nil - } - - // 检查每个分组中的其他账号 - for _, groupID := range groupIDs { - accounts, err := s.accountRepo.ListByGroup(ctx, groupID) - if err != nil { - return fmt.Errorf("get accounts in group %d: %w", groupID, err) - } - - // 检查是否存在不同渠道的账号 - for _, account := range accounts { - if currentAccountID > 0 && account.ID == currentAccountID { - continue // 跳过当前账号 - } - - otherPlatform := getAccountPlatform(account.Platform) - if otherPlatform == "" { - continue // 不是 Antigravity 或 Anthropic,跳过 - } - - // 检测混合渠道 - if currentPlatform != otherPlatform { - group, _ := s.groupRepo.GetByID(ctx, groupID) - groupName := fmt.Sprintf("Group %d", groupID) - if group != nil { - groupName = group.Name - } - - return &MixedChannelError{ - GroupID: groupID, - GroupName: groupName, - CurrentPlatform: currentPlatform, - OtherPlatform: otherPlatform, - } - } - } - } - - return nil -} - -func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs []int64) error { - if len(groupIDs) == 0 { - return nil - } - if s.groupRepo == nil { - return errors.New("group repository not configured") - } - - if batchReader, ok := s.groupRepo.(groupExistenceBatchReader); ok { - existsByID, err := batchReader.ExistsByIDs(ctx, groupIDs) - if err != nil { - return fmt.Errorf("check groups exists: %w", err) - } - for _, groupID := range groupIDs { - if groupID <= 0 || !existsByID[groupID] { - return fmt.Errorf("get group: %w", ErrGroupNotFound) - } - } - return nil - } - - for _, groupID := range groupIDs { - if _, err := s.groupRepo.GetByID(ctx, groupID); err != nil { - return fmt.Errorf("get group: %w", err) - } - } - return nil -} - -// CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform. -func (s *adminServiceImpl) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { - return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs) -} - -func (s *adminServiceImpl) attachProxyLatency(ctx context.Context, proxies []ProxyWithAccountCount) { - if s.proxyLatencyCache == nil || len(proxies) == 0 { - return - } - - ids := make([]int64, 0, len(proxies)) - for i := range proxies { - ids = append(ids, proxies[i].ID) - } - - latencies, err := s.proxyLatencyCache.GetProxyLatencies(ctx, ids) - if err != nil { - logger.LegacyPrintf("service.admin", "Warning: load proxy latency cache failed: %v", err) - return - } - - for i := range proxies { - info := latencies[proxies[i].ID] - if info == nil { - continue - } - if info.Success { - proxies[i].LatencyStatus = "success" - proxies[i].LatencyMs = info.LatencyMs - } else { - proxies[i].LatencyStatus = "failed" - } - proxies[i].LatencyMessage = info.Message - proxies[i].IPAddress = info.IPAddress - proxies[i].Country = info.Country - proxies[i].CountryCode = info.CountryCode - proxies[i].Region = info.Region - proxies[i].City = info.City - proxies[i].QualityStatus = info.QualityStatus - proxies[i].QualityScore = info.QualityScore - proxies[i].QualityGrade = info.QualityGrade - proxies[i].QualitySummary = info.QualitySummary - proxies[i].QualityChecked = info.QualityCheckedAt - } -} - -func (s *adminServiceImpl) saveProxyLatency(ctx context.Context, proxyID int64, info *ProxyLatencyInfo) { - if s.proxyLatencyCache == nil || info == nil { - return - } - - merged := *info - if latencies, err := s.proxyLatencyCache.GetProxyLatencies(ctx, []int64{proxyID}); err == nil { - if existing := latencies[proxyID]; existing != nil { - if merged.QualityCheckedAt == nil && - merged.QualityScore == nil && - merged.QualityGrade == "" && - merged.QualityStatus == "" && - merged.QualitySummary == "" && - merged.QualityCFRay == "" { - merged.QualityStatus = existing.QualityStatus - merged.QualityScore = existing.QualityScore - merged.QualityGrade = existing.QualityGrade - merged.QualitySummary = existing.QualitySummary - merged.QualityCheckedAt = existing.QualityCheckedAt - merged.QualityCFRay = existing.QualityCFRay - } - } - } - - if err := s.proxyLatencyCache.SetProxyLatency(ctx, proxyID, &merged); err != nil { - logger.LegacyPrintf("service.admin", "Warning: store proxy latency cache failed: %v", err) - } -} - -// getAccountPlatform 根据账号 platform 判断混合渠道检查用的平台标识 -func getAccountPlatform(accountPlatform string) string { - switch strings.ToLower(strings.TrimSpace(accountPlatform)) { - case PlatformAntigravity: - return "Antigravity" - case PlatformAnthropic, "claude": - return "Anthropic" - default: - return "" - } -} - -// MixedChannelError 混合渠道错误 -type MixedChannelError struct { - GroupID int64 - GroupName string - CurrentPlatform string - OtherPlatform string -} - -func (e *MixedChannelError) Error() string { - return fmt.Sprintf("mixed_channel_warning: Group '%s' contains both %s and %s accounts. Using mixed channels in the same context may cause thinking block signature validation issues, which will fallback to non-thinking mode for historical messages.", - e.GroupName, e.CurrentPlatform, e.OtherPlatform) -} - -func (s *adminServiceImpl) ResetAccountQuota(ctx context.Context, id int64) error { - account, err := s.accountRepo.GetByID(ctx, id) - if err != nil { - return err - } - // spark 影子账号不持自有配额(凭据透传母账号、spark 用量走独立 codex_* 维度由 QueryUsage 维护), - // 通用 quota 重置对其无意义且语义不一致——明确 400 拒绝(与 OpenAI reset-credit 对影子一致)(外审第7轮 P2)。 - if account.IsCredentialShadow() { - return infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_NO_QUOTA_RESET", - "cannot reset quota for a spark shadow account; manage it on the parent account") - } - return s.accountRepo.ResetQuotaUsed(ctx, id) -} - -// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号是否已设置 privacy_mode, -// 未设置则调用 disableOpenAITraining 并持久化到 Extra,返回设置的 mode 值。 -func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Account) string { - // 影子账号不持凭据,隐私设置由母账号管理,直接跳过。 - if account.IsCredentialShadow() { - return "" - } - if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { - return "" - } - if s.privacyClientFactory == nil { - return "" - } - if shouldSkipOpenAIPrivacyEnsure(account.Extra) { - return "" - } - - token, _ := account.Credentials["access_token"].(string) - if token == "" { - return "" - } - - var proxyURL string - if account.ProxyID != nil { - if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { - proxyURL = p.URL() - } - } - - mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL) - if mode == "" { - return "" - } - - _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}) - return mode -} - -// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。 -func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Account) string { - // 影子账号不持凭据,隐私由母账号管理,直接跳过(与 EnsureOpenAIPrivacy 一致——外审第4轮)。 - if account.IsCredentialShadow() { - return "" - } - if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { - return "" - } - if s.privacyClientFactory == nil { - return "" - } - - token, _ := account.Credentials["access_token"].(string) - if token == "" { - return "" - } - - var proxyURL string - if account.ProxyID != nil { - if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { - proxyURL = p.URL() - } - } - - mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL) - if mode == "" { - return "" - } - - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { - logger.LegacyPrintf("service.admin", "force_update_openai_privacy_mode_failed: account_id=%d err=%v", account.ID, err) - return mode - } - if account.Extra == nil { - account.Extra = make(map[string]any) - } - account.Extra["privacy_mode"] = mode - return mode -} - -// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。 -// 仅当 privacy_mode 已成功设置("privacy_set")时跳过; -// 未设置或之前失败("privacy_set_failed")均会重试。 -func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account *Account) string { - if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth { - return "" - } - if account.Extra != nil { - if existing, ok := account.Extra["privacy_mode"].(string); ok && existing == AntigravityPrivacySet { - return existing - } - } - - token, _ := account.Credentials["access_token"].(string) - if token == "" { - return "" - } - - projectID, _ := account.Credentials["project_id"].(string) - - var proxyURL string - if account.ProxyID != nil { - if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { - proxyURL = p.URL() - } - } - - mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL) - if mode == "" { - return "" - } - - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { - logger.LegacyPrintf("service.admin", "update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err) - return mode - } - applyAntigravityPrivacyMode(account, mode) - return mode -} - -// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。 -func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account *Account) string { - if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth { - return "" - } - - token, _ := account.Credentials["access_token"].(string) - if token == "" { - return "" - } - - projectID, _ := account.Credentials["project_id"].(string) - - var proxyURL string - if account.ProxyID != nil { - if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { - proxyURL = p.URL() - } - } - - mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL) - if mode == "" { - return "" - } - - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { - logger.LegacyPrintf("service.admin", "force_update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err) - return mode - } - applyAntigravityPrivacyMode(account, mode) - return mode -} diff --git a/backend/internal/service/admin_user.go b/backend/internal/service/admin_user.go new file mode 100644 index 0000000000..e95ed3d92e --- /dev/null +++ b/backend/internal/service/admin_user.go @@ -0,0 +1,1188 @@ +package service + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/authidentity" + "github.com/Wei-Shaw/sub2api/ent/authidentitychannel" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +// User management implementations +func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, filters UserListFilters, sortBy, sortOrder string) ([]User, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} + users, result, err := s.userRepo.ListWithFilters(ctx, params, filters) + if err != nil { + return nil, 0, err + } + if len(users) > 0 { + userIDs := make([]int64, 0, len(users)) + for i := range users { + userIDs = append(userIDs, users[i].ID) + } + lastUsedByUserID, latestErr := s.userRepo.GetLatestUsedAtByUserIDs(ctx, userIDs) + if latestErr != nil { + logger.LegacyPrintf("service.admin", "failed to load user last_used_at in batch: err=%v", latestErr) + } else { + for i := range users { + users[i].LastUsedAt = lastUsedByUserID[users[i].ID] + } + } + } + // 批量加载用户专属分组倍率 + if s.userGroupRateRepo != nil && len(users) > 0 { + if batchRepo, ok := s.userGroupRateRepo.(userGroupRateBatchReader); ok { + userIDs := make([]int64, 0, len(users)) + for i := range users { + userIDs = append(userIDs, users[i].ID) + } + ratesByUser, err := batchRepo.GetByUserIDs(ctx, userIDs) + if err != nil { + logger.LegacyPrintf("service.admin", "failed to load user group rates in batch: err=%v", err) + s.loadUserGroupRatesOneByOne(ctx, users) + } else { + for i := range users { + if rates, ok := ratesByUser[users[i].ID]; ok { + users[i].GroupRates = rates + } + } + } + } else { + s.loadUserGroupRatesOneByOne(ctx, users) + } + } + return users, result.Total, nil +} + +func (s *adminServiceImpl) loadUserGroupRatesOneByOne(ctx context.Context, users []User) { + if s.userGroupRateRepo == nil { + return + } + for i := range users { + rates, err := s.userGroupRateRepo.GetByUserID(ctx, users[i].ID) + if err != nil { + logger.LegacyPrintf("service.admin", "failed to load user group rates: user_id=%d err=%v", users[i].ID, err) + continue + } + users[i].GroupRates = rates + } +} + +func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*User, error) { + user, err := s.userRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + lastUsedAt, latestErr := s.userRepo.GetLatestUsedAtByUserID(ctx, id) + if latestErr != nil { + logger.LegacyPrintf("service.admin", "failed to load user last_used_at: user_id=%d err=%v", id, latestErr) + } else { + user.LastUsedAt = lastUsedAt + } + // 加载用户专属分组倍率 + if s.userGroupRateRepo != nil { + rates, err := s.userGroupRateRepo.GetByUserID(ctx, id) + if err != nil { + logger.LegacyPrintf("service.admin", "failed to load user group rates: user_id=%d err=%v", id, err) + } else { + user.GroupRates = rates + } + } + return user, nil +} + +func (s *adminServiceImpl) GetUserIncludeDeleted(ctx context.Context, id int64) (*User, error) { + return s.userRepo.GetByIDIncludeDeleted(ctx, id) +} + +func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) { + balance := 0.0 + if input.Balance != nil { + balance = *input.Balance + } else if s.settingService != nil { + balance = s.settingService.GetDefaultBalance(ctx) + } + + user := &User{ + Email: input.Email, + Username: input.Username, + Notes: input.Notes, + Role: RoleUser, // Always create as regular user, never admin + Balance: balance, + Concurrency: input.Concurrency, + RPMLimit: input.RPMLimit, + Status: StatusActive, + AllowedGroups: input.AllowedGroups, + } + if err := user.SetPassword(input.Password); err != nil { + return nil, err + } + if err := s.userRepo.Create(ctx, user); err != nil { + return nil, err + } + s.assignDefaultSubscriptions(ctx, user.ID) + return user, nil +} + +func (s *adminServiceImpl) assignDefaultSubscriptions(ctx context.Context, userID int64) { + if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 { + return + } + items := s.settingService.GetDefaultSubscriptions(ctx) + for _, item := range items { + if _, _, err := s.defaultSubAssigner.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{ + UserID: userID, + GroupID: item.GroupID, + ValidityDays: item.ValidityDays, + Notes: "auto assigned by default user subscriptions setting", + }); err != nil { + logger.LegacyPrintf("service.admin", "failed to assign default subscription: user_id=%d group_id=%d err=%v", userID, item.GroupID, err) + } + } +} + +func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) { + // 校验用户专属分组倍率:必须 > 0(nil 合法,表示清除专属倍率) + if input.GroupRates != nil { + for groupID, rate := range input.GroupRates { + if rate != nil && *rate <= 0 { + return nil, fmt.Errorf("rate_multiplier must be > 0 (group_id=%d)", groupID) + } + } + } + + user, err := s.userRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + // Protect admin users: cannot disable admin accounts + if user.Role == "admin" && input.Status == "disabled" { + return nil, errors.New("cannot disable admin user") + } + + oldConcurrency := user.Concurrency + oldStatus := user.Status + oldRole := user.Role + oldRPMLimit := user.RPMLimit + oldAllowedGroups := append([]int64(nil), user.AllowedGroups...) + + if input.Email != "" { + user.Email = input.Email + } + if input.Password != "" { + if err := user.SetPassword(input.Password); err != nil { + return nil, err + } + } + + if input.Username != nil { + user.Username = *input.Username + } + if input.Notes != nil { + user.Notes = *input.Notes + } + + if input.Status != "" { + user.Status = input.Status + } + + if input.Concurrency != nil { + user.Concurrency = *input.Concurrency + } + + if input.RPMLimit != nil { + user.RPMLimit = *input.RPMLimit + } + + if input.AllowedGroups != nil { + user.AllowedGroups = *input.AllowedGroups + } + + if err := s.userRepo.Update(ctx, user); err != nil { + return nil, err + } + + // 同步用户专属分组倍率 + if input.GroupRates != nil && s.userGroupRateRepo != nil { + if err := s.userGroupRateRepo.SyncUserGroupRates(ctx, user.ID, input.GroupRates); err != nil { + logger.LegacyPrintf("service.admin", "failed to sync user group rates: user_id=%d err=%v", user.ID, err) + } + } + + if s.authCacheInvalidator != nil { + // RPMLimit 直接参与 billing_cache_service.checkRPM 的三级级联, + // allowed_groups 参与 API Key 专属分组授权判断;不失效缓存会让修改在一个 L2 TTL 内失去效果。 + if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole || user.RPMLimit != oldRPMLimit || !sameInt64Set(user.AllowedGroups, oldAllowedGroups) { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, user.ID) + } + } + + concurrencyDiff := user.Concurrency - oldConcurrency + if concurrencyDiff != 0 { + code, err := GenerateRedeemCode() + if err != nil { + logger.LegacyPrintf("service.admin", "failed to generate adjustment redeem code: %v", err) + return user, nil + } + adjustmentRecord := &RedeemCode{ + Code: code, + Type: AdjustmentTypeAdminConcurrency, + Value: float64(concurrencyDiff), + Status: StatusUsed, + UsedBy: &user.ID, + } + now := time.Now() + adjustmentRecord.UsedAt = &now + if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil { + logger.LegacyPrintf("service.admin", "failed to create concurrency adjustment redeem code: %v", err) + } + } + + return user, nil +} + +func sameInt64Set(a, b []int64) bool { + if len(a) != len(b) { + return false + } + if len(a) == 0 { + return true + } + counts := make(map[int64]int, len(a)) + for _, v := range a { + counts[v]++ + } + for _, v := range b { + if counts[v] == 0 { + return false + } + counts[v]-- + } + return true +} + +func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error { + // Protect admin users: cannot delete admin accounts + user, err := s.userRepo.GetByID(ctx, id) + if err != nil { + return err + } + if user.Role == "admin" { + return errors.New("cannot delete admin user") + } + + apiKeys, err := s.listUserAPIKeysForDeletion(ctx, id) + if err != nil { + return err + } + + if s.entClient != nil { + tx, err := s.entClient.Tx(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + opCtx := dbent.NewTxContext(ctx, tx) + if err := s.deleteUserWithAPIKeys(opCtx, id, apiKeys); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + } else { + if err := s.deleteUserWithAPIKeys(ctx, id, apiKeys); err != nil { + return err + } + } + + if s.authCacheInvalidator != nil { + for _, key := range apiKeys { + if keyValue := strings.TrimSpace(key.Key); keyValue != "" { + s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, keyValue) + } + } + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, id) + } + return nil +} + +func (s *adminServiceImpl) listUserAPIKeysForDeletion(ctx context.Context, userID int64) ([]APIKey, error) { + if s.apiKeyRepo == nil { + return nil, nil + } + + const pageSize = 1000 + keys := make([]APIKey, 0) + for page := 1; ; page++ { + batch, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, pagination.PaginationParams{ + Page: page, + PageSize: pageSize, + SortBy: "id", + SortOrder: pagination.SortOrderAsc, + }, APIKeyListFilters{}) + if err != nil { + return nil, fmt.Errorf("list user api keys: %w", err) + } + keys = append(keys, batch...) + if len(batch) == 0 || len(batch) < pageSize || result == nil || int64(len(keys)) >= result.Total { + break + } + } + return keys, nil +} + +func (s *adminServiceImpl) deleteUserWithAPIKeys(ctx context.Context, userID int64, apiKeys []APIKey) error { + if s.apiKeyRepo != nil { + for _, key := range apiKeys { + if key.ID <= 0 { + continue + } + if err := s.apiKeyRepo.DeleteWithAudit(ctx, key.ID); err != nil { + logger.LegacyPrintf("service.admin", "delete user api key failed: user_id=%d api_key_id=%d err=%v", userID, key.ID, err) + return fmt.Errorf("delete user api key %d: %w", key.ID, err) + } + } + } + + if err := s.userRepo.Delete(ctx, userID); err != nil { + logger.LegacyPrintf("service.admin", "delete user failed: user_id=%d err=%v", userID, err) + return err + } + return nil +} + +func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) { + cleaned := make([]int64, 0, len(userIDs)) + for _, uid := range userIDs { + if uid > 0 { + cleaned = append(cleaned, uid) + } + } + if len(cleaned) == 0 { + return 0, nil + } + + var affected int + var err error + switch mode { + case "set": + affected, err = s.userRepo.BatchSetConcurrency(ctx, cleaned, value) + case "add": + affected, err = s.userRepo.BatchAddConcurrency(ctx, cleaned, value) + default: + return 0, errors.New("invalid mode: must be 'set' or 'add'") + } + if err != nil { + return 0, err + } + + if s.authCacheInvalidator != nil { + for _, uid := range cleaned { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, uid) + } + } + return affected, nil +} + +func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) { + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + oldBalance := user.Balance + + switch operation { + case "set": + user.Balance = balance + case "add": + user.Balance += balance + case "subtract": + user.Balance -= balance + } + + if user.Balance < 0 { + return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", oldBalance, user.Balance) + } + + if err := s.userRepo.Update(ctx, user); err != nil { + return nil, err + } + balanceDiff := user.Balance - oldBalance + if s.authCacheInvalidator != nil && balanceDiff != 0 { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) + } + + if s.billingCacheService != nil { + go func() { + cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.billingCacheService.InvalidateUserBalance(cacheCtx, userID); err != nil { + logger.LegacyPrintf("service.admin", "invalidate user balance cache failed: user_id=%d err=%v", userID, err) + } + }() + } + + if balanceDiff != 0 { + code, err := GenerateRedeemCode() + if err != nil { + logger.LegacyPrintf("service.admin", "failed to generate adjustment redeem code: %v", err) + return user, nil + } + + adjustmentRecord := &RedeemCode{ + Code: code, + Type: AdjustmentTypeAdminBalance, + Value: balanceDiff, + Status: StatusUsed, + UsedBy: &user.ID, + Notes: notes, + } + now := time.Now() + adjustmentRecord.UsedAt = &now + + if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil { + logger.LegacyPrintf("service.admin", "failed to create balance adjustment redeem code: %v", err) + } + } + + return user, nil +} + +func (s *adminServiceImpl) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} + keys, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, params, APIKeyListFilters{}) + if err != nil { + return nil, 0, err + } + return keys, result.Total, nil +} + +func (s *adminServiceImpl) GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error) { + if s.userRPMCache == nil { + return nil, ErrRPMStatusUnavailable + } + + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + userRPMUsed, err := s.userRPMCache.GetUserRPM(ctx, userID) + if err != nil { + logger.LegacyPrintf("service.admin", "failed to get user rpm: user_id=%d err=%v", userID, err) + } + + keys, _, err := s.GetUserAPIKeys(ctx, userID, 1, 1000, "", "") + if err != nil { + return nil, err + } + + groupIDSet := make(map[int64]struct{}) + for _, key := range keys { + if key.GroupID != nil && *key.GroupID > 0 { + groupIDSet[*key.GroupID] = struct{}{} + } + } + + groupIDs := make([]int64, 0, len(groupIDSet)) + for groupID := range groupIDSet { + groupIDs = append(groupIDs, groupID) + } + sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] }) + + var perGroup []UserGroupRPMStatus + for _, groupID := range groupIDs { + used, getErr := s.userRPMCache.GetUserGroupRPM(ctx, userID, groupID) + if getErr != nil { + logger.LegacyPrintf("service.admin", "failed to get user group rpm: user_id=%d group_id=%d err=%v", userID, groupID, getErr) + } + + entry := UserGroupRPMStatus{ + GroupID: groupID, + Used: used, + } + + if s.groupRepo != nil { + if group, groupErr := s.groupRepo.GetByIDLite(ctx, groupID); groupErr == nil && group != nil { + entry.GroupName = group.Name + entry.Limit = group.RPMLimit + entry.Source = "group" + } else if groupErr != nil { + logger.LegacyPrintf("service.admin", "failed to get group rpm status metadata: group_id=%d err=%v", groupID, groupErr) + } + } + + if s.userGroupRateRepo != nil { + override, overrideErr := s.userGroupRateRepo.GetRPMOverrideByUserAndGroup(ctx, userID, groupID) + if overrideErr != nil { + logger.LegacyPrintf("service.admin", "failed to get rpm override: user_id=%d group_id=%d err=%v", userID, groupID, overrideErr) + } else if override != nil { + entry.Limit = *override + entry.Source = "override" + } + } + + perGroup = append(perGroup, entry) + } + + return &UserRPMStatus{ + UserRPMUsed: userRPMUsed, + UserRPMLimit: user.RPMLimit, + PerGroup: perGroup, + }, nil +} + +func (s *adminServiceImpl) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) { + // Return mock data for now + return map[string]any{ + "period": period, + "total_requests": 0, + "total_cost": 0.0, + "total_tokens": 0, + "avg_duration_ms": 0, + }, nil +} + +// GetUserBalanceHistory returns paginated balance/concurrency change records for a user. +func (s *adminServiceImpl) GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]RedeemCode, int64, float64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize} + if codeType == RedeemTypeAffiliateBalance { + codes, total, err := s.listAffiliateBalanceHistory(ctx, userID, params) + if err != nil { + return nil, 0, 0, err + } + totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) + if err != nil { + return nil, 0, 0, err + } + return codes, total, totalRecharged, nil + } + + if codeType == "" { + return s.getAllUserBalanceHistory(ctx, userID, params) + } + + codes, result, err := s.redeemCodeRepo.ListByUserPaginated(ctx, userID, params, codeType) + if err != nil { + return nil, 0, 0, err + } + total := result.Total + // Aggregate total recharged amount (only once, regardless of type filter) + totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) + if err != nil { + return nil, 0, 0, err + } + return codes, total, totalRecharged, nil +} + +func (s *adminServiceImpl) getAllUserBalanceHistory(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, int64, float64, error) { + needed := params.Offset() + params.Limit() + if needed < params.Limit() { + needed = params.Limit() + } + + redeemCodes, redeemTotal, err := s.listRedeemBalanceHistoryForMerge(ctx, userID, needed) + if err != nil { + return nil, 0, 0, err + } + affiliateCodes, affiliateTotal, err := s.listAffiliateBalanceHistoryForMerge(ctx, userID, needed) + if err != nil { + return nil, 0, 0, err + } + codes := mergeBalanceHistoryCodes(redeemCodes, affiliateCodes, params) + + totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) + if err != nil { + return nil, 0, 0, err + } + return codes, redeemTotal + affiliateTotal, totalRecharged, nil +} + +func (s *adminServiceImpl) listRedeemBalanceHistoryForMerge(ctx context.Context, userID int64, needed int) ([]RedeemCode, int64, error) { + if needed <= 0 { + return nil, 0, nil + } + + var ( + out []RedeemCode + total int64 + ) + for page := 1; len(out) < needed; page++ { + params := pagination.PaginationParams{Page: page, PageSize: 1000} + codes, result, err := s.redeemCodeRepo.ListByUserPaginated(ctx, userID, params, "") + if err != nil { + return nil, 0, err + } + if result != nil { + total = result.Total + } + out = append(out, codes...) + if len(codes) < params.Limit() || int64(len(out)) >= total { + break + } + } + if len(out) > needed { + out = out[:needed] + } + return out, total, nil +} + +func (s *adminServiceImpl) listAffiliateBalanceHistoryForMerge(ctx context.Context, userID int64, needed int) ([]RedeemCode, int64, error) { + if needed <= 0 { + return nil, 0, nil + } + + var ( + out []RedeemCode + total int64 + ) + for page := 1; len(out) < needed; page++ { + params := pagination.PaginationParams{Page: page, PageSize: 1000} + codes, currentTotal, err := s.listAffiliateBalanceHistory(ctx, userID, params) + if err != nil { + return nil, 0, err + } + total = currentTotal + out = append(out, codes...) + if len(codes) < params.Limit() || int64(len(out)) >= total { + break + } + } + if len(out) > needed { + out = out[:needed] + } + return out, total, nil +} + +func (s *adminServiceImpl) listAffiliateBalanceHistory(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, int64, error) { + if s == nil || s.entClient == nil || userID <= 0 { + return nil, 0, nil + } + + rows, err := s.entClient.QueryContext(ctx, ` +SELECT id, + amount::double precision, + created_at +FROM user_affiliate_ledger +WHERE user_id = $1 + AND action = 'transfer' +ORDER BY created_at DESC, id DESC +OFFSET $2 +LIMIT $3`, userID, params.Offset(), params.Limit()) + if err != nil { + return nil, 0, err + } + defer func() { _ = rows.Close() }() + + codes := make([]RedeemCode, 0, params.Limit()) + for rows.Next() { + var id int64 + var amount float64 + var createdAt time.Time + if err := rows.Scan(&id, &amount, &createdAt); err != nil { + return nil, 0, err + } + usedBy := userID + usedAt := createdAt + codes = append(codes, RedeemCode{ + ID: -id, + Code: fmt.Sprintf("AFF-%d", id), + Type: RedeemTypeAffiliateBalance, + Value: amount, + Status: StatusUsed, + UsedBy: &usedBy, + UsedAt: &usedAt, + CreatedAt: createdAt, + }) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + + total, err := countAffiliateBalanceHistory(ctx, s.entClient, userID) + if err != nil { + return nil, 0, err + } + return codes, total, nil +} + +func countAffiliateBalanceHistory(ctx context.Context, client *dbent.Client, userID int64) (int64, error) { + rows, err := client.QueryContext(ctx, ` +SELECT COUNT(*) +FROM user_affiliate_ledger +WHERE user_id = $1 + AND action = 'transfer'`, userID) + if err != nil { + return 0, err + } + defer func() { _ = rows.Close() }() + + var total sql.NullInt64 + if rows.Next() { + if err := rows.Scan(&total); err != nil { + return 0, err + } + } + if err := rows.Err(); err != nil { + return 0, err + } + if !total.Valid { + return 0, nil + } + return total.Int64, nil +} + +func mergeBalanceHistoryCodes(redeemCodes, affiliateCodes []RedeemCode, params pagination.PaginationParams) []RedeemCode { + combined := append(append([]RedeemCode{}, redeemCodes...), affiliateCodes...) + sort.SliceStable(combined, func(i, j int) bool { + return redeemCodeHistoryTime(combined[i]).After(redeemCodeHistoryTime(combined[j])) + }) + offset := params.Offset() + if offset >= len(combined) { + return []RedeemCode{} + } + end := offset + params.Limit() + if end > len(combined) { + end = len(combined) + } + return combined[offset:end] +} + +func redeemCodeHistoryTime(code RedeemCode) time.Time { + if code.UsedAt != nil { + return *code.UsedAt + } + return code.CreatedAt +} + +func (s *adminServiceImpl) BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error) { + if userID <= 0 { + return nil, infraerrors.BadRequest("INVALID_INPUT", "user_id must be greater than 0") + } + if s == nil || s.entClient == nil || s.userRepo == nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_UNAVAILABLE", "auth identity binding service is unavailable") + } + if _, err := s.userRepo.GetByID(ctx, userID); err != nil { + return nil, err + } + + providerType := normalizeAdminAuthIdentityProviderType(input.ProviderType) + providerKey := strings.TrimSpace(input.ProviderKey) + providerSubject := strings.TrimSpace(input.ProviderSubject) + if providerType == "" { + return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type must be one of email, linuxdo, oidc, wechat, or dingtalk") + } + if providerKey == "" || providerSubject == "" { + return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type, provider_key, and provider_subject are required") + } + canonicalProviderKey := canonicalAdminAuthIdentityProviderKey(providerType, "", providerKey) + compatibleProviderKeys := compatibleAdminAuthIdentityProviderKeys(providerType, providerKey) + + var issuer *string + if input.Issuer != nil { + trimmed := strings.TrimSpace(*input.Issuer) + if trimmed != "" { + issuer = &trimmed + } + } + + channelInput := normalizeAdminBindChannelInput(input.Channel) + if input.Channel != nil && channelInput == nil { + return nil, infraerrors.BadRequest("INVALID_INPUT", "channel, channel_app_id, and channel_subject are required when channel binding is provided") + } + + verifiedAt := time.Now().UTC() + tx, err := s.entClient.Tx(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_TX_FAILED", "failed to start auth identity bind transaction").WithCause(err) + } + defer func() { _ = tx.Rollback() }() + + identityRecords, err := tx.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ(providerType), + authidentity.ProviderKeyIn(compatibleProviderKeys...), + authidentity.ProviderSubjectEQ(providerSubject), + ). + All(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err) + } + if hasAdminAuthIdentityOwnershipConflict(identityRecords, userID) { + return nil, infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user") + } + identity := selectOwnedAdminAuthIdentity(identityRecords, userID) + + if identity == nil { + create := tx.AuthIdentity.Create(). + SetUserID(userID). + SetProviderType(providerType). + SetProviderKey(canonicalProviderKey). + SetProviderSubject(providerSubject). + SetVerifiedAt(verifiedAt) + if issuer != nil { + create = create.SetIssuer(*issuer) + } + if input.Metadata != nil { + create = create.SetMetadata(cloneAdminAuthIdentityMetadata(input.Metadata)) + } + identity, err = create.Save(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_SAVE_FAILED", "failed to save auth identity").WithCause(err) + } + } else { + update := tx.AuthIdentity.UpdateOneID(identity.ID). + SetVerifiedAt(verifiedAt). + SetProviderKey(canonicalProviderKey) + if issuer != nil { + update = update.SetIssuer(*issuer) + } + if input.Metadata != nil { + update = update.SetMetadata(cloneAdminAuthIdentityMetadata(input.Metadata)) + } + identity, err = update.Save(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_SAVE_FAILED", "failed to save auth identity").WithCause(err) + } + } + + var channel *dbent.AuthIdentityChannel + if channelInput != nil { + channelRecords, err := tx.AuthIdentityChannel.Query(). + Where( + authidentitychannel.ProviderTypeEQ(providerType), + authidentitychannel.ProviderKeyIn(compatibleProviderKeys...), + authidentitychannel.ChannelEQ(channelInput.Channel), + authidentitychannel.ChannelAppIDEQ(channelInput.ChannelAppID), + authidentitychannel.ChannelSubjectEQ(channelInput.ChannelSubject), + ). + WithIdentity(). + All(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_CHANNEL_LOOKUP_FAILED", "failed to inspect auth identity channel ownership").WithCause(err) + } + if hasAdminAuthIdentityChannelOwnershipConflict(channelRecords, userID) { + return nil, infraerrors.Conflict("AUTH_IDENTITY_CHANNEL_OWNERSHIP_CONFLICT", "auth identity channel already belongs to another user") + } + channel = selectOwnedAdminAuthIdentityChannel(channelRecords, userID) + if channel == nil { + create := tx.AuthIdentityChannel.Create(). + SetIdentityID(identity.ID). + SetProviderType(providerType). + SetProviderKey(canonicalProviderKey). + SetChannel(channelInput.Channel). + SetChannelAppID(channelInput.ChannelAppID). + SetChannelSubject(channelInput.ChannelSubject) + if channelInput.Metadata != nil { + create = create.SetMetadata(cloneAdminAuthIdentityMetadata(channelInput.Metadata)) + } + channel, err = create.Save(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_CHANNEL_SAVE_FAILED", "failed to save auth identity channel").WithCause(err) + } + } else { + update := tx.AuthIdentityChannel.UpdateOneID(channel.ID). + SetIdentityID(identity.ID). + SetProviderKey(canonicalProviderKey) + if channelInput.Metadata != nil { + update = update.SetMetadata(cloneAdminAuthIdentityMetadata(channelInput.Metadata)) + } + channel, err = update.Save(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_CHANNEL_SAVE_FAILED", "failed to save auth identity channel").WithCause(err) + } + } + } + + if err := tx.Commit(); err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_COMMIT_FAILED", "failed to commit auth identity bind").WithCause(err) + } + return buildAdminBoundAuthIdentity(identity, channel), nil +} + +func compatibleAdminAuthIdentityProviderKeys(providerType, providerKey string) []string { + providerType = strings.TrimSpace(strings.ToLower(providerType)) + providerKey = strings.TrimSpace(providerKey) + if providerKey == "" { + return []string{providerKey} + } + if providerType != "wechat" { + return []string{providerKey} + } + + keys := []string{providerKey} + if !strings.EqualFold(providerKey, "wechat-main") { + keys = append(keys, "wechat-main") + } + if !strings.EqualFold(providerKey, "wechat") { + keys = append(keys, "wechat") + } + return keys +} + +func canonicalAdminAuthIdentityProviderKey(providerType, existingKey, requestedKey string) string { + providerType = strings.TrimSpace(strings.ToLower(providerType)) + existingKey = strings.TrimSpace(existingKey) + requestedKey = strings.TrimSpace(requestedKey) + if providerType != "wechat" { + if requestedKey != "" { + return requestedKey + } + return existingKey + } + if strings.EqualFold(existingKey, "wechat") || strings.EqualFold(existingKey, "wechat-main") || strings.EqualFold(requestedKey, "wechat-main") { + return "wechat-main" + } + if requestedKey != "" { + return requestedKey + } + return existingKey +} + +func adminAuthIdentityProviderKeyRank(providerType, providerKey string) int { + providerType = strings.TrimSpace(strings.ToLower(providerType)) + providerKey = strings.TrimSpace(providerKey) + if providerType != "wechat" { + return 0 + } + switch { + case strings.EqualFold(providerKey, "wechat-main"): + return 0 + case strings.EqualFold(providerKey, "wechat"): + return 2 + default: + return 1 + } +} + +func selectOwnedAdminAuthIdentity(records []*dbent.AuthIdentity, userID int64) *dbent.AuthIdentity { + var selected *dbent.AuthIdentity + for _, record := range records { + if record.UserID != userID { + continue + } + if selected == nil || adminAuthIdentityProviderKeyRank(record.ProviderType, record.ProviderKey) < adminAuthIdentityProviderKeyRank(selected.ProviderType, selected.ProviderKey) { + selected = record + } + } + return selected +} + +func hasAdminAuthIdentityOwnershipConflict(records []*dbent.AuthIdentity, userID int64) bool { + for _, record := range records { + if record.UserID != userID { + return true + } + } + return false +} + +func selectOwnedAdminAuthIdentityChannel(records []*dbent.AuthIdentityChannel, userID int64) *dbent.AuthIdentityChannel { + var selected *dbent.AuthIdentityChannel + for _, record := range records { + if record.Edges.Identity == nil || record.Edges.Identity.UserID != userID { + continue + } + if selected == nil || adminAuthIdentityProviderKeyRank(record.ProviderType, record.ProviderKey) < adminAuthIdentityProviderKeyRank(selected.ProviderType, selected.ProviderKey) { + selected = record + } + } + return selected +} + +func hasAdminAuthIdentityChannelOwnershipConflict(records []*dbent.AuthIdentityChannel, userID int64) bool { + for _, record := range records { + if record.Edges.Identity != nil && record.Edges.Identity.UserID != userID { + return true + } + } + return false +} + +func normalizeAdminBindChannelInput(input *AdminBindAuthIdentityChannelInput) *AdminBindAuthIdentityChannelInput { + if input == nil { + return nil + } + channel := &AdminBindAuthIdentityChannelInput{ + Channel: strings.TrimSpace(input.Channel), + ChannelAppID: strings.TrimSpace(input.ChannelAppID), + ChannelSubject: strings.TrimSpace(input.ChannelSubject), + Metadata: cloneAdminAuthIdentityMetadata(input.Metadata), + } + if channel.Channel == "" || channel.ChannelAppID == "" || channel.ChannelSubject == "" { + return nil + } + return channel +} + +func normalizeAdminAuthIdentityProviderType(input string) string { + switch strings.ToLower(strings.TrimSpace(input)) { + case "email": + return "email" + case "linuxdo": + return "linuxdo" + case "oidc": + return "oidc" + case "wechat": + return "wechat" + case "dingtalk": + return "dingtalk" + default: + return "" + } +} + +func buildAdminBoundAuthIdentity(identity *dbent.AuthIdentity, channel *dbent.AuthIdentityChannel) *AdminBoundAuthIdentity { + if identity == nil { + return nil + } + result := &AdminBoundAuthIdentity{ + UserID: identity.UserID, + ProviderType: strings.TrimSpace(identity.ProviderType), + ProviderKey: strings.TrimSpace(identity.ProviderKey), + ProviderSubject: strings.TrimSpace(identity.ProviderSubject), + VerifiedAt: identity.VerifiedAt, + Issuer: identity.Issuer, + Metadata: cloneAdminAuthIdentityMetadata(identity.Metadata), + CreatedAt: identity.CreatedAt, + UpdatedAt: identity.UpdatedAt, + } + if channel != nil { + result.Channel = &AdminBoundAuthIdentityChannel{ + Channel: strings.TrimSpace(channel.Channel), + ChannelAppID: strings.TrimSpace(channel.ChannelAppID), + ChannelSubject: strings.TrimSpace(channel.ChannelSubject), + Metadata: cloneAdminAuthIdentityMetadata(channel.Metadata), + CreatedAt: channel.CreatedAt, + UpdatedAt: channel.UpdatedAt, + } + } + return result +} + +func cloneAdminAuthIdentityMetadata(input map[string]any) map[string]any { + if input == nil { + return nil + } + if len(input) == 0 { + return map[string]any{} + } + data, err := json.Marshal(input) + if err != nil { + out := make(map[string]any, len(input)) + for key, value := range input { + out[key] = value + } + return out + } + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + out = make(map[string]any, len(input)) + for key, value := range input { + out[key] = value + } + } + return out +} + +// Redeem code management implementations +func (s *adminServiceImpl) ListRedeemCodes(ctx context.Context, page, pageSize int, codeType, status, search string, sortBy, sortOrder string) ([]RedeemCode, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} + codes, result, err := s.redeemCodeRepo.ListWithFilters(ctx, params, codeType, status, search) + if err != nil { + return nil, 0, err + } + return codes, result.Total, nil +} + +func (s *adminServiceImpl) GetRedeemCode(ctx context.Context, id int64) (*RedeemCode, error) { + return s.redeemCodeRepo.GetByID(ctx, id) +} + +func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *GenerateRedeemCodesInput) ([]RedeemCode, error) { + if input.ExpiresAt != nil && !input.ExpiresAt.After(time.Now()) { + return nil, ErrRedeemCodeExpired + } + + // 如果是订阅类型,验证必须有 GroupID + if input.Type == RedeemTypeSubscription { + if input.GroupID == nil { + return nil, errors.New("group_id is required for subscription type") + } + // 验证分组存在且为订阅类型 + group, err := s.groupRepo.GetByID(ctx, *input.GroupID) + if err != nil { + return nil, fmt.Errorf("group not found: %w", err) + } + if !group.IsSubscriptionType() { + return nil, errors.New("group must be subscription type") + } + } + + codes := make([]RedeemCode, 0, input.Count) + for i := 0; i < input.Count; i++ { + codeValue, err := GenerateRedeemCode() + if err != nil { + return nil, err + } + code := RedeemCode{ + Code: codeValue, + Type: input.Type, + Value: input.Value, + Status: StatusUnused, + ExpiresAt: input.ExpiresAt, + } + // 订阅类型专用字段 + if input.Type == RedeemTypeSubscription { + code.GroupID = input.GroupID + code.ValidityDays = input.ValidityDays + if code.ValidityDays <= 0 { + code.ValidityDays = 30 // 默认30天 + } + } + if err := s.redeemCodeRepo.Create(ctx, &code); err != nil { + return nil, err + } + codes = append(codes, code) + } + return codes, nil +} + +func (s *adminServiceImpl) DeleteRedeemCode(ctx context.Context, id int64) error { + return s.redeemCodeRepo.Delete(ctx, id) +} + +func (s *adminServiceImpl) BatchDeleteRedeemCodes(ctx context.Context, ids []int64) (int64, error) { + var deleted int64 + for _, id := range ids { + if err := s.redeemCodeRepo.Delete(ctx, id); err == nil { + deleted++ + } + } + return deleted, nil +} + +func (s *adminServiceImpl) ExpireRedeemCode(ctx context.Context, id int64) (*RedeemCode, error) { + code, err := s.redeemCodeRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + code.Status = StatusExpired + if err := s.redeemCodeRepo.Update(ctx, code); err != nil { + return nil, err + } + return code, nil +} diff --git a/backend/internal/service/antigravity_gateway_claude.go b/backend/internal/service/antigravity_gateway_claude.go new file mode 100644 index 0000000000..2011625984 --- /dev/null +++ b/backend/internal/service/antigravity_gateway_claude.go @@ -0,0 +1,754 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/gin-gonic/gin" +) + +// Forward 转发 Claude 协议请求(Claude → Gemini 转换) +// +// 限流处理流程: +// +// 请求 → antigravityRetryLoop → 预检查(remaining>0? → 切换账号) → 发送上游 +// ├─ 成功 → 正常返回 +// └─ 429/503 → handleSmartRetry +// ├─ retryDelay >= 7s → 设置模型限流 + 清除粘性绑定 → 切换账号 +// └─ retryDelay < 7s → 等待后重试 1 次 +// ├─ 成功 → 正常返回 +// └─ 失败 → 设置模型限流 + 清除粘性绑定 → 切换账号 +func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte, isStickySession bool) (*ForwardResult, error) { + // 上游透传账号直接转发,不走 OAuth token 刷新 + if account.Type == AccountTypeUpstream { + return s.ForwardUpstream(ctx, c, account, body) + } + + startTime := time.Now() + + sessionID := getSessionID(c) + prefix := logPrefix(sessionID, account.Name) + + // 解析 Claude 请求 + var claudeReq antigravity.ClaudeRequest + if err := json.Unmarshal(body, &claudeReq); err != nil { + return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Invalid request body") + } + if strings.TrimSpace(claudeReq.Model) == "" { + return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Missing model") + } + + originalModel := claudeReq.Model + mappedModel := s.getMappedModel(account, claudeReq.Model) + if mappedModel == "" { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) + return nil, s.writeClaudeError(c, http.StatusForbidden, "permission_error", fmt.Sprintf("model %s not in whitelist", claudeReq.Model)) + } + // 应用 thinking 模式自动后缀:如果 thinking 开启且目标是 claude-sonnet-4-5,自动改为 thinking 版本 + thinkingEnabled := claudeReq.Thinking != nil && (claudeReq.Thinking.Type == "enabled" || claudeReq.Thinking.Type == "adaptive") + mappedModel = applyThinkingModelSuffix(mappedModel, thinkingEnabled) + billingModel := mappedModel + + // 获取 access_token + if s.tokenProvider == nil { + return nil, s.writeClaudeError(c, http.StatusBadGateway, "api_error", "Antigravity token provider not configured") + } + accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) + if err != nil { + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: []byte(`{"error":{"type":"authentication_error","message":"Failed to get upstream access token"},"type":"error"}`), + } + } + + projectID, err := resolveAntigravityProjectID(account) + if err != nil { + _ = s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + + // 代理 URL + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + // 获取转换选项 + // Antigravity 上游要求必须包含身份提示词,否则会返回 429 + transformOpts := s.getClaudeTransformOptions(ctx) + transformOpts.EnableIdentityPatch = true // 强制启用,Antigravity 上游必需 + + // 转换 Claude 请求为 Gemini 格式 + geminiBody, err := antigravity.TransformClaudeToGeminiWithOptions(&claudeReq, projectID, mappedModel, transformOpts) + if err != nil { + return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Invalid request") + } + + // Antigravity 上游只支持流式请求,统一使用 streamGenerateContent + // 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后转换返回 + action := "streamGenerateContent" + + // 执行带重试的请求 + result, err := s.antigravityRetryLoop(antigravityRetryLoopParams{ + ctx: ctx, + prefix: prefix, + account: account, + proxyURL: proxyURL, + accessToken: accessToken, + action: action, + body: geminiBody, + c: c, + httpUpstream: s.httpUpstream, + settingService: s.settingService, + accountRepo: s.accountRepo, + handleError: s.handleUpstreamError, + requestedModel: originalModel, + isStickySession: isStickySession, // Forward 由上层判断粘性会话 + groupID: 0, // Forward 方法没有 groupID,由上层处理粘性会话清除 + sessionHash: "", // Forward 方法没有 sessionHash,由上层处理粘性会话清除 + }) + if err != nil { + // 检查是否是账号切换信号,转换为 UpstreamFailoverError 让 Handler 切换账号 + if switchErr, ok := IsAntigravityAccountSwitchError(err); ok { + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusServiceUnavailable, + ForceCacheBilling: switchErr.IsStickySession, + } + } + // 区分客户端取消和真正的上游失败,返回更准确的错误消息 + if c.Request.Context().Err() != nil { + return nil, s.writeClaudeError(c, http.StatusBadGateway, "client_disconnected", "Client disconnected before upstream response") + } + return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries") + } + resp := result.resp + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + respBody := s.readUpstreamErrorBody(resp) + + // 优先检测 thinking block 的 signature 相关错误(400)并重试一次: + // Antigravity /v1internal 链路在部分场景会对 thought/thinking signature 做严格校验, + // 当历史消息携带的 signature 不合法时会直接 400;去除 thinking 后可继续完成请求。 + if resp.StatusCode == http.StatusBadRequest && isSignatureRelatedError(respBody) && s.settingService.IsSignatureRectifierEnabled(ctx) { + upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + logBody, maxBytes := s.getLogConfig() + upstreamDetail := s.getUpstreamErrorDetail(respBody) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "signature_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + + // Conservative two-stage fallback: + // 1) Disable top-level thinking + thinking->text + // 2) Only if still signature-related 400: also downgrade tool_use/tool_result to text. + + retryStages := []struct { + name string + strip func(*antigravity.ClaudeRequest) (bool, error) + }{ + {name: "thinking-only", strip: stripThinkingFromClaudeRequest}, + {name: "thinking+tools", strip: stripSignatureSensitiveBlocksFromClaudeRequest}, + } + + for _, stage := range retryStages { + retryClaudeReq := claudeReq + retryClaudeReq.Messages = append([]antigravity.ClaudeMessage(nil), claudeReq.Messages...) + + stripped, stripErr := stage.strip(&retryClaudeReq) + if stripErr != nil || !stripped { + continue + } + + logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: detected signature-related 400, retrying once (%s)", account.ID, stage.name) + + retryGeminiBody, txErr := antigravity.TransformClaudeToGeminiWithOptions(&retryClaudeReq, projectID, mappedModel, s.getClaudeTransformOptions(ctx)) + if txErr != nil { + continue + } + retryResult, retryErr := s.antigravityRetryLoop(antigravityRetryLoopParams{ + ctx: ctx, + prefix: prefix, + account: account, + proxyURL: proxyURL, + accessToken: accessToken, + action: action, + body: retryGeminiBody, + c: c, + httpUpstream: s.httpUpstream, + settingService: s.settingService, + accountRepo: s.accountRepo, + handleError: s.handleUpstreamError, + requestedModel: originalModel, + isStickySession: isStickySession, + groupID: 0, // Forward 方法没有 groupID,由上层处理粘性会话清除 + sessionHash: "", // Forward 方法没有 sessionHash,由上层处理粘性会话清除 + }) + if retryErr != nil { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: 0, + Kind: "signature_retry_request_error", + Message: sanitizeUpstreamErrorMessage(retryErr.Error()), + }) + logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: signature retry request failed (%s): %v", account.ID, stage.name, retryErr) + continue + } + + retryResp := retryResult.resp + if retryResp.StatusCode < 400 { + _ = resp.Body.Close() + resp = retryResp + respBody = nil + break + } + + retryBody, _ := io.ReadAll(io.LimitReader(retryResp.Body, 8<<10)) + _ = retryResp.Body.Close() + if retryResp.StatusCode == http.StatusTooManyRequests { + retryBaseURL := "" + if retryResp.Request != nil && retryResp.Request.URL != nil { + retryBaseURL = retryResp.Request.URL.Scheme + "://" + retryResp.Request.URL.Host + } + logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 rate_limited base_url=%s retry_stage=%s body=%s", prefix, retryBaseURL, stage.name, truncateForLog(retryBody, 200)) + } + kind := "signature_retry" + if strings.TrimSpace(stage.name) != "" { + kind = "signature_retry_" + strings.ReplaceAll(stage.name, "+", "_") + } + retryUpstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(retryBody)) + retryUpstreamMsg = sanitizeUpstreamErrorMessage(retryUpstreamMsg) + retryUpstreamDetail := "" + if logBody { + retryUpstreamDetail = truncateString(string(retryBody), maxBytes) + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: retryResp.StatusCode, + UpstreamRequestID: retryResp.Header.Get("x-request-id"), + Kind: kind, + Message: retryUpstreamMsg, + Detail: retryUpstreamDetail, + }) + + // If this stage fixed the signature issue, we stop; otherwise we may try the next stage. + if retryResp.StatusCode != http.StatusBadRequest || !isSignatureRelatedError(retryBody) { + respBody = retryBody + resp = &http.Response{ + StatusCode: retryResp.StatusCode, + Header: retryResp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(retryBody)), + } + break + } + + // Still signature-related; capture context and allow next stage. + respBody = retryBody + resp = &http.Response{ + StatusCode: retryResp.StatusCode, + Header: retryResp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(retryBody)), + } + } + } + + // Budget 整流:检测 budget_tokens 约束错误并自动修正重试 + if resp.StatusCode == http.StatusBadRequest && respBody != nil && !isSignatureRelatedError(respBody) { + errMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody)) + if isThinkingBudgetConstraintError(errMsg) && s.settingService.IsBudgetRectifierEnabled(ctx) { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "budget_constraint_error", + Message: errMsg, + Detail: s.getUpstreamErrorDetail(respBody), + }) + + // 修正 claudeReq 的 thinking 参数(adaptive 模式不修正) + if claudeReq.Thinking == nil || claudeReq.Thinking.Type != "adaptive" { + retryClaudeReq := claudeReq + retryClaudeReq.Messages = append([]antigravity.ClaudeMessage(nil), claudeReq.Messages...) + // 创建新的 ThinkingConfig 避免修改原始 claudeReq.Thinking 指针 + retryClaudeReq.Thinking = &antigravity.ThinkingConfig{ + Type: "enabled", + BudgetTokens: BudgetRectifyBudgetTokens, + } + if retryClaudeReq.MaxTokens < BudgetRectifyMinMaxTokens { + retryClaudeReq.MaxTokens = BudgetRectifyMaxTokens + } + + logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: detected budget_tokens constraint error, retrying with rectified budget (budget_tokens=%d, max_tokens=%d)", account.ID, BudgetRectifyBudgetTokens, BudgetRectifyMaxTokens) + + retryGeminiBody, txErr := antigravity.TransformClaudeToGeminiWithOptions(&retryClaudeReq, projectID, mappedModel, transformOpts) + if txErr == nil { + retryResult, retryErr := s.antigravityRetryLoop(antigravityRetryLoopParams{ + ctx: ctx, + prefix: prefix, + account: account, + proxyURL: proxyURL, + accessToken: accessToken, + action: action, + body: retryGeminiBody, + c: c, + httpUpstream: s.httpUpstream, + settingService: s.settingService, + accountRepo: s.accountRepo, + handleError: s.handleUpstreamError, + requestedModel: originalModel, + isStickySession: isStickySession, + groupID: 0, + sessionHash: "", + }) + if retryErr == nil { + retryResp := retryResult.resp + if retryResp.StatusCode < 400 { + _ = resp.Body.Close() + resp = retryResp + respBody = nil + } else { + retryBody := s.readUpstreamErrorBody(retryResp) + _ = retryResp.Body.Close() + respBody = retryBody + resp = &http.Response{ + StatusCode: retryResp.StatusCode, + Header: retryResp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(retryBody)), + } + } + } else { + logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: budget rectifier retry failed: %v", account.ID, retryErr) + } + } + } + } + } + + // 处理错误响应(重试后仍失败或不触发重试) + if resp.StatusCode >= 400 { + // 检测 prompt too long 错误,返回特殊错误类型供上层 fallback + if resp.StatusCode == http.StatusBadRequest && isPromptTooLongError(respBody) { + upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + upstreamDetail := s.getUpstreamErrorDetail(respBody) + logBody, maxBytes := s.getLogConfig() + if logBody { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=400 prompt_too_long=true upstream_message=%q request_id=%s body=%s", prefix, upstreamMsg, resp.Header.Get("x-request-id"), truncateForLog(respBody, maxBytes)) + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "prompt_too_long", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + return nil, &PromptTooLongError{ + StatusCode: resp.StatusCode, + RequestID: resp.Header.Get("x-request-id"), + Body: respBody, + } + } + + s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, 0, "", isStickySession) + + // 精确匹配服务端配置类 400 错误,触发同账号重试 + failover + if resp.StatusCode == http.StatusBadRequest { + msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody))) + if isGoogleProjectConfigError(msg) { + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(respBody))) + upstreamDetail := s.getUpstreamErrorDetail(respBody) + log.Printf("%s status=400 google_config_error failover=true upstream_message=%q account=%d", prefix, upstreamMsg, account.ID) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "failover", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: true} + } + } + + if s.shouldFailoverUpstreamError(resp.StatusCode) { + upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + upstreamDetail := s.getUpstreamErrorDetail(respBody) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "failover", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody} + } + + return nil, s.writeMappedClaudeError(c, account, resp.StatusCode, resp.Header.Get("x-request-id"), respBody) + } + } + + requestID := resp.Header.Get("x-request-id") + if requestID != "" { + c.Header("x-request-id", requestID) + } + + var usage *ClaudeUsage + var firstTokenMs *int + var clientDisconnect bool + if claudeReq.Stream { + // 客户端要求流式,直接透传转换 + streamRes, err := s.handleClaudeStreamingResponse(c, resp, startTime, originalModel) + if err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_error error=%v", prefix, err) + return nil, err + } + usage = streamRes.usage + firstTokenMs = streamRes.firstTokenMs + clientDisconnect = streamRes.clientDisconnect + } else { + // 客户端要求非流式,收集流式响应后转换返回 + streamRes, err := s.handleClaudeStreamToNonStreaming(c, resp, startTime, originalModel) + if err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_collect_error error=%v", prefix, err) + return nil, err + } + usage = streamRes.usage + firstTokenMs = streamRes.firstTokenMs + } + + return &ForwardResult{ + RequestID: requestID, + Usage: *usage, + Model: originalModel, + UpstreamModel: billingModel, + Stream: claudeReq.Stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, + }, nil +} + +func isSignatureRelatedError(respBody []byte) bool { + msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody))) + if msg == "" { + // Fallback: best-effort scan of the raw payload. + msg = strings.ToLower(string(respBody)) + } + + // Keep this intentionally broad: different upstreams may use "signature" or "thought_signature". + if strings.Contains(msg, "thought_signature") || strings.Contains(msg, "signature") { + return true + } + + // Also detect thinking block structural errors: + // "Expected `thinking` or `redacted_thinking`, but found `text`" + if strings.Contains(msg, "expected") && (strings.Contains(msg, "thinking") || strings.Contains(msg, "redacted_thinking")) { + return true + } + + return false +} + +// isPromptTooLongError 检测是否为 prompt too long 错误 +func isPromptTooLongError(respBody []byte) bool { + msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody))) + if msg == "" { + msg = strings.ToLower(string(respBody)) + } + return strings.Contains(msg, "prompt is too long") || + strings.Contains(msg, "request is too long") || + strings.Contains(msg, "context length exceeded") || + strings.Contains(msg, "max_tokens") +} + +// isPassthroughErrorMessage 检查错误消息是否在透传白名单中 +func isPassthroughErrorMessage(msg string) bool { + lower := strings.ToLower(msg) + for _, pattern := range antigravityPassthroughErrorMessages { + if strings.Contains(lower, pattern) { + return true + } + } + return false +} + +// getPassthroughOrDefault 若消息在白名单内则返回原始消息,否则返回默认消息 +func getPassthroughOrDefault(upstreamMsg, defaultMsg string) string { + if isPassthroughErrorMessage(upstreamMsg) { + return upstreamMsg + } + return defaultMsg +} + +func extractAntigravityErrorMessage(body []byte) string { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + + // Google-style: {"error": {"message": "..."}} + if errObj, ok := payload["error"].(map[string]any); ok { + if msg, ok := errObj["message"].(string); ok && strings.TrimSpace(msg) != "" { + return msg + } + } + + // Fallback: top-level message + if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" { + return msg + } + + return "" +} + +// stripThinkingFromClaudeRequest converts thinking blocks to text blocks in a Claude Messages request. +// This preserves the thinking content while avoiding signature validation errors. +// Note: redacted_thinking blocks are removed because they cannot be converted to text. +// It also disables top-level `thinking` to avoid upstream structural constraints for thinking mode. +func stripThinkingFromClaudeRequest(req *antigravity.ClaudeRequest) (bool, error) { + if req == nil { + return false, nil + } + + changed := false + if req.Thinking != nil { + req.Thinking = nil + changed = true + } + + for i := range req.Messages { + raw := req.Messages[i].Content + if len(raw) == 0 { + continue + } + + // If content is a string, nothing to strip. + var str string + if json.Unmarshal(raw, &str) == nil { + continue + } + + // Otherwise treat as an array of blocks and convert thinking blocks to text. + var blocks []map[string]any + if err := json.Unmarshal(raw, &blocks); err != nil { + continue + } + + filtered := make([]map[string]any, 0, len(blocks)) + modifiedAny := false + for _, block := range blocks { + t, _ := block["type"].(string) + switch t { + case "thinking": + thinkingText, _ := block["thinking"].(string) + if thinkingText != "" { + filtered = append(filtered, map[string]any{ + "type": "text", + "text": thinkingText, + }) + } + modifiedAny = true + case "redacted_thinking": + modifiedAny = true + case "": + if thinkingText, hasThinking := block["thinking"].(string); hasThinking { + if thinkingText != "" { + filtered = append(filtered, map[string]any{ + "type": "text", + "text": thinkingText, + }) + } + modifiedAny = true + } else { + filtered = append(filtered, block) + } + default: + filtered = append(filtered, block) + } + } + + if !modifiedAny { + continue + } + + if len(filtered) == 0 { + filtered = append(filtered, map[string]any{ + "type": "text", + "text": "(content removed)", + }) + } + + newRaw, err := json.Marshal(filtered) + if err != nil { + return changed, err + } + req.Messages[i].Content = newRaw + changed = true + } + + return changed, nil +} + +// stripSignatureSensitiveBlocksFromClaudeRequest is a stronger retry degradation that additionally converts +// tool blocks to plain text. Use this only after a thinking-only retry still fails with signature errors. +func stripSignatureSensitiveBlocksFromClaudeRequest(req *antigravity.ClaudeRequest) (bool, error) { + if req == nil { + return false, nil + } + + changed := false + if req.Thinking != nil { + req.Thinking = nil + changed = true + } + + for i := range req.Messages { + raw := req.Messages[i].Content + if len(raw) == 0 { + continue + } + + // If content is a string, nothing to strip. + var str string + if json.Unmarshal(raw, &str) == nil { + continue + } + + // Otherwise treat as an array of blocks and convert signature-sensitive blocks to text. + var blocks []map[string]any + if err := json.Unmarshal(raw, &blocks); err != nil { + continue + } + + filtered := make([]map[string]any, 0, len(blocks)) + modifiedAny := false + for _, block := range blocks { + t, _ := block["type"].(string) + switch t { + case "thinking": + // Convert thinking to text, skip if empty + thinkingText, _ := block["thinking"].(string) + if thinkingText != "" { + filtered = append(filtered, map[string]any{ + "type": "text", + "text": thinkingText, + }) + } + modifiedAny = true + case "redacted_thinking": + // Remove redacted_thinking (cannot convert encrypted content) + modifiedAny = true + case "tool_use": + // Convert tool_use to text to avoid upstream signature/thought_signature validation errors. + // This is a retry-only degradation path, so we prioritise request validity over tool semantics. + name, _ := block["name"].(string) + id, _ := block["id"].(string) + input := block["input"] + inputJSON, _ := json.Marshal(input) + text := "(tool_use)" + if name != "" { + text += " name=" + name + } + if id != "" { + text += " id=" + id + } + if len(inputJSON) > 0 && string(inputJSON) != "null" { + text += " input=" + string(inputJSON) + } + filtered = append(filtered, map[string]any{ + "type": "text", + "text": text, + }) + modifiedAny = true + case "tool_result": + // Convert tool_result to text so it stays consistent when tool_use is downgraded. + toolUseID, _ := block["tool_use_id"].(string) + isError, _ := block["is_error"].(bool) + content := block["content"] + contentJSON, _ := json.Marshal(content) + text := "(tool_result)" + if toolUseID != "" { + text += " tool_use_id=" + toolUseID + } + if isError { + text += " is_error=true" + } + if len(contentJSON) > 0 && string(contentJSON) != "null" { + text += "\n" + string(contentJSON) + } + filtered = append(filtered, map[string]any{ + "type": "text", + "text": text, + }) + modifiedAny = true + case "": + // Handle untyped block with "thinking" field + if thinkingText, hasThinking := block["thinking"].(string); hasThinking { + if thinkingText != "" { + filtered = append(filtered, map[string]any{ + "type": "text", + "text": thinkingText, + }) + } + modifiedAny = true + } else { + filtered = append(filtered, block) + } + default: + filtered = append(filtered, block) + } + } + + if !modifiedAny { + continue + } + + if len(filtered) == 0 { + // Keep request valid: upstream rejects empty content arrays. + filtered = append(filtered, map[string]any{ + "type": "text", + "text": "(content removed)", + }) + } + + newRaw, err := json.Marshal(filtered) + if err != nil { + return changed, err + } + req.Messages[i].Content = newRaw + changed = true + } + + return changed, nil +} diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go new file mode 100644 index 0000000000..f0d20244a8 --- /dev/null +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -0,0 +1,550 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/gin-gonic/gin" +) + +// ForwardGemini 转发 Gemini 协议请求 +// +// 限流处理流程: +// +// 请求 → antigravityRetryLoop → 预检查(remaining>0? → 切换账号) → 发送上游 +// ├─ 成功 → 正常返回 +// └─ 429/503 → handleSmartRetry +// ├─ retryDelay >= 7s → 设置模型限流 + 清除粘性绑定 → 切换账号 +// └─ retryDelay < 7s → 等待后重试 1 次 +// ├─ 成功 → 正常返回 +// └─ 失败 → 设置模型限流 + 清除粘性绑定 → 切换账号 +type ForwardGeminiOption func(*forwardGeminiOptions) + +type forwardGeminiOptions struct { + groupID int64 + sessionHash string +} + +func WithForwardGeminiSession(groupID int64, sessionHash string) ForwardGeminiOption { + return func(opts *forwardGeminiOptions) { + opts.groupID = groupID + opts.sessionHash = sessionHash + } +} + +func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte, isStickySession bool, options ...ForwardGeminiOption) (*ForwardResult, error) { + startTime := time.Now() + forwardOpts := forwardGeminiOptions{} + for _, apply := range options { + if apply != nil { + apply(&forwardOpts) + } + } + + sessionID := getSessionID(c) + prefix := logPrefix(sessionID, account.Name) + + if strings.TrimSpace(originalModel) == "" { + return nil, s.writeGoogleError(c, http.StatusBadRequest, "Missing model in URL") + } + if strings.TrimSpace(action) == "" { + return nil, s.writeGoogleError(c, http.StatusBadRequest, "Missing action in URL") + } + if len(body) == 0 { + return nil, s.writeGoogleError(c, http.StatusBadRequest, "Request body is empty") + } + + // 解析请求以获取 image_size(用于图片计费) + imageInputSize := s.extractImageInputSize(body) + imageSize := normalizeOpenAIImageSizeTier(imageInputSize) + + switch action { + case "generateContent", "streamGenerateContent": + // ok + case "countTokens": + // 直接返回空值,不透传上游 + c.JSON(http.StatusOK, map[string]any{"totalTokens": 0}) + return &ForwardResult{ + RequestID: "", + Usage: ClaudeUsage{}, + Model: originalModel, + Stream: false, + Duration: time.Since(startTime), + FirstTokenMs: nil, + }, nil + default: + return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action) + } + + mappedModel := s.getMappedModel(account, originalModel) + if mappedModel == "" { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) + return nil, s.writeGoogleError(c, http.StatusForbidden, fmt.Sprintf("model %s not in whitelist", originalModel)) + } + billingModel := mappedModel + + // 获取 access_token + if s.tokenProvider == nil { + return nil, s.writeGoogleError(c, http.StatusBadGateway, "Antigravity token provider not configured") + } + accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) + if err != nil { + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: []byte(`{"error":{"message":"Failed to get upstream access token","status":"UNAVAILABLE"}}`), + } + } + + projectID, err := resolveAntigravityProjectID(account) + if err != nil { + _ = s.writeGoogleError(c, http.StatusBadRequest, err.Error()) + return nil, err + } + + // 代理 URL + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + // Antigravity 上游要求必须包含身份提示词,注入到请求中 + injectedBody, err := injectIdentityPatchToGeminiRequest(body) + if err != nil { + return nil, s.writeGoogleError(c, http.StatusBadRequest, "Invalid request body") + } + + // 清理 Schema + if cleanedBody, err := cleanGeminiRequest(injectedBody); err == nil { + injectedBody = cleanedBody + logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Cleaned request schema in forwarded request for account %s", account.Name) + } else { + logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Failed to clean schema: %v", err) + } + + // 包装请求 + wrappedBody, err := s.wrapV1InternalRequest(projectID, mappedModel, injectedBody) + if err != nil { + return nil, s.writeGoogleError(c, http.StatusInternalServerError, "Failed to build upstream request") + } + + // Antigravity 上游只支持流式请求,统一使用 streamGenerateContent + // 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后返回 + upstreamAction := "streamGenerateContent" + + // 执行带重试的请求 + result, err := s.antigravityRetryLoop(antigravityRetryLoopParams{ + ctx: ctx, + prefix: prefix, + account: account, + proxyURL: proxyURL, + accessToken: accessToken, + action: upstreamAction, + body: wrappedBody, + c: c, + httpUpstream: s.httpUpstream, + settingService: s.settingService, + accountRepo: s.accountRepo, + handleError: s.handleUpstreamError, + requestedModel: originalModel, + isStickySession: isStickySession, // ForwardGemini 由上层判断粘性会话 + groupID: forwardOpts.groupID, + sessionHash: forwardOpts.sessionHash, + }) + if err != nil { + // 检查是否是账号切换信号,转换为 UpstreamFailoverError 让 Handler 切换账号 + if switchErr, ok := IsAntigravityAccountSwitchError(err); ok { + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusServiceUnavailable, + ForceCacheBilling: switchErr.IsStickySession, + } + } + // 区分客户端取消和真正的上游失败,返回更准确的错误消息 + if c.Request.Context().Err() != nil { + return nil, s.writeGoogleError(c, http.StatusBadGateway, "Client disconnected before upstream response") + } + return nil, s.writeGoogleError(c, http.StatusBadGateway, "Upstream request failed after retries") + } + resp := result.resp + defer func() { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + }() + + // 处理错误响应 + if resp.StatusCode >= 400 { + respBody := s.readUpstreamErrorBody(resp) + contentType := resp.Header.Get("Content-Type") + // 尽早关闭原始响应体,释放连接;后续逻辑仍可能需要读取 body,因此用内存副本重新包装。 + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + + // 模型兜底:模型不存在且开启 fallback 时,自动用 fallback 模型重试一次 + if s.settingService != nil && s.settingService.IsModelFallbackEnabled(ctx) && + isModelNotFoundError(resp.StatusCode, respBody) { + fallbackModel := s.settingService.GetFallbackModel(ctx, PlatformAntigravity) + if fallbackModel != "" && fallbackModel != mappedModel { + logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Model not found (%s), retrying with fallback model %s (account: %s)", mappedModel, fallbackModel, account.Name) + + fallbackWrapped, err := s.wrapV1InternalRequest(projectID, fallbackModel, injectedBody) + if err == nil { + fallbackReq, err := antigravity.NewAPIRequest(ctx, upstreamAction, accessToken, fallbackWrapped) + if err == nil { + fallbackResp, err := s.httpUpstream.Do(fallbackReq, proxyURL, account.ID, account.Concurrency) + if err == nil && fallbackResp.StatusCode < 400 { + _ = resp.Body.Close() + resp = fallbackResp + } else if fallbackResp != nil { + _ = fallbackResp.Body.Close() + } + } + } + } + } + + // Gemini 原生请求中的 thoughtSignature 可能来自旧上下文/旧账号,触发上游严格校验后返回 + // "Corrupted thought signature."。检测到此类 400 时,将 thoughtSignature 清理为 dummy 值后重试一次。 + signatureCheckBody := respBody + if unwrapped, unwrapErr := s.unwrapV1InternalResponse(respBody); unwrapErr == nil && len(unwrapped) > 0 { + signatureCheckBody = unwrapped + } + if resp.StatusCode == http.StatusBadRequest && + s.settingService != nil && + s.settingService.IsSignatureRectifierEnabled(ctx) && + isSignatureRelatedError(signatureCheckBody) && + bytes.Contains(injectedBody, []byte(`"thoughtSignature"`)) { + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(signatureCheckBody))) + upstreamDetail := s.getUpstreamErrorDetail(signatureCheckBody) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "signature_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + + logger.LegacyPrintf("service.antigravity_gateway", "Antigravity Gemini account %d: detected signature-related 400, retrying with cleaned thought signatures", account.ID) + + cleanedInjectedBody := CleanGeminiNativeThoughtSignatures(injectedBody) + retryWrappedBody, wrapErr := s.wrapV1InternalRequest(projectID, mappedModel, cleanedInjectedBody) + if wrapErr == nil { + retryResult, retryErr := s.antigravityRetryLoop(antigravityRetryLoopParams{ + ctx: ctx, + prefix: prefix, + account: account, + proxyURL: proxyURL, + accessToken: accessToken, + action: upstreamAction, + body: retryWrappedBody, + c: c, + httpUpstream: s.httpUpstream, + settingService: s.settingService, + accountRepo: s.accountRepo, + handleError: s.handleUpstreamError, + requestedModel: originalModel, + isStickySession: isStickySession, + groupID: forwardOpts.groupID, + sessionHash: forwardOpts.sessionHash, + }) + if retryErr == nil { + retryResp := retryResult.resp + if retryResp.StatusCode < 400 { + resp = retryResp + } else { + retryRespBody := s.readUpstreamErrorBody(retryResp) + _ = retryResp.Body.Close() + retryOpsBody := retryRespBody + if retryUnwrapped, unwrapErr := s.unwrapV1InternalResponse(retryRespBody); unwrapErr == nil && len(retryUnwrapped) > 0 { + retryOpsBody = retryUnwrapped + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: retryResp.StatusCode, + UpstreamRequestID: retryResp.Header.Get("x-request-id"), + Kind: "signature_retry", + Message: sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(retryOpsBody))), + Detail: s.getUpstreamErrorDetail(retryOpsBody), + }) + respBody = retryRespBody + resp = &http.Response{ + StatusCode: retryResp.StatusCode, + Header: retryResp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(retryRespBody)), + } + contentType = resp.Header.Get("Content-Type") + } + } else { + if switchErr, ok := IsAntigravityAccountSwitchError(retryErr); ok { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: http.StatusServiceUnavailable, + Kind: "failover", + Message: sanitizeUpstreamErrorMessage(retryErr.Error()), + }) + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusServiceUnavailable, + ForceCacheBilling: switchErr.IsStickySession, + } + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: 0, + Kind: "signature_retry_request_error", + Message: sanitizeUpstreamErrorMessage(retryErr.Error()), + }) + logger.LegacyPrintf("service.antigravity_gateway", "Antigravity Gemini account %d: signature retry request failed: %v", account.ID, retryErr) + } + } else { + logger.LegacyPrintf("service.antigravity_gateway", "Antigravity Gemini account %d: signature retry wrap failed: %v", account.ID, wrapErr) + } + } + + // fallback 成功:继续按正常响应处理 + if resp.StatusCode < 400 { + goto handleSuccess + } + + requestID := resp.Header.Get("x-request-id") + if requestID != "" { + c.Header("x-request-id", requestID) + } + + unwrapped, unwrapErr := s.unwrapV1InternalResponse(respBody) + unwrappedForOps := unwrapped + if unwrapErr != nil || len(unwrappedForOps) == 0 { + unwrappedForOps = respBody + } + s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, forwardOpts.groupID, forwardOpts.sessionHash, isStickySession) + upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(unwrappedForOps)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + upstreamDetail := s.getUpstreamErrorDetail(unwrappedForOps) + + // Always record upstream context for Ops error logs, even when we will failover. + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + + // 精确匹配服务端配置类 400 错误,触发同账号重试 + failover + if resp.StatusCode == http.StatusBadRequest && isGoogleProjectConfigError(strings.ToLower(upstreamMsg)) { + log.Printf("%s status=400 google_config_error failover=true upstream_message=%q account=%d", prefix, upstreamMsg, account.ID) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: requestID, + Kind: "failover", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: unwrappedForOps, RetryableOnSameAccount: true} + } + + if s.shouldFailoverUpstreamError(resp.StatusCode) { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: requestID, + Kind: "failover", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: unwrappedForOps} + } + if contentType == "" { + contentType = "application/json" + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: requestID, + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] upstream error status=%d body=%s", resp.StatusCode, truncateForLog(unwrappedForOps, 500)) + MarkResponseCommitted(c) + c.Data(resp.StatusCode, contentType, unwrappedForOps) + return nil, fmt.Errorf("antigravity upstream error: %d", resp.StatusCode) + } + +handleSuccess: + requestID := resp.Header.Get("x-request-id") + if requestID != "" { + c.Header("x-request-id", requestID) + } + + var usage *ClaudeUsage + var firstTokenMs *int + var clientDisconnect bool + + if stream { + // 客户端要求流式,直接透传 + streamRes, err := s.handleGeminiStreamingResponse(c, resp, startTime) + if err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_error error=%v", prefix, err) + return nil, err + } + usage = streamRes.usage + firstTokenMs = streamRes.firstTokenMs + clientDisconnect = streamRes.clientDisconnect + } else { + // 客户端要求非流式,收集流式响应后返回 + streamRes, err := s.handleGeminiStreamToNonStreaming(c, resp, startTime) + if err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_collect_error error=%v", prefix, err) + return nil, err + } + usage = streamRes.usage + firstTokenMs = streamRes.firstTokenMs + } + + if usage == nil { + usage = &ClaudeUsage{} + } + + // 判断是否为图片生成模型 + imageCount := 0 + if isImageGenerationModel(mappedModel) { + // Gemini 图片生成 API 每次请求只生成一张图片(API 限制) + imageCount = 1 + } + + return &ForwardResult{ + RequestID: requestID, + Usage: *usage, + Model: originalModel, + UpstreamModel: billingModel, + Stream: stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, + ImageCount: imageCount, + ImageSize: imageSize, + ImageInputSize: imageInputSize, + }, nil +} + +// cleanGeminiRequest 清理 Gemini 请求体中的 Schema +func cleanGeminiRequest(body []byte) ([]byte, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + + modified := false + + // 1. 清理 Tools + if tools, ok := payload["tools"].([]any); ok && len(tools) > 0 { + for _, t := range tools { + toolMap, ok := t.(map[string]any) + if !ok { + continue + } + + // function_declarations (snake_case) or functionDeclarations (camelCase) + var funcs []any + if f, ok := toolMap["functionDeclarations"].([]any); ok { + funcs = f + } else if f, ok := toolMap["function_declarations"].([]any); ok { + funcs = f + } + + if len(funcs) == 0 { + continue + } + + for _, f := range funcs { + funcMap, ok := f.(map[string]any) + if !ok { + continue + } + + if params, ok := funcMap["parameters"].(map[string]any); ok { + antigravity.DeepCleanUndefined(params) + cleaned := antigravity.CleanJSONSchema(params) + funcMap["parameters"] = cleaned + modified = true + } + } + } + } + + if !modified { + return body, nil + } + + return json.Marshal(payload) +} + +// filterEmptyPartsFromGeminiRequest 过滤掉 parts 为空的消息 +// Gemini API 不接受空 parts,需要在请求前过滤 +func filterEmptyPartsFromGeminiRequest(body []byte) ([]byte, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + + contents, ok := payload["contents"].([]any) + if !ok || len(contents) == 0 { + return body, nil + } + + filtered := make([]any, 0, len(contents)) + modified := false + + for _, c := range contents { + contentMap, ok := c.(map[string]any) + if !ok { + filtered = append(filtered, c) + continue + } + + parts, hasParts := contentMap["parts"] + if !hasParts { + filtered = append(filtered, c) + continue + } + + partsSlice, ok := parts.([]any) + if !ok { + filtered = append(filtered, c) + continue + } + + // 跳过 parts 为空数组的消息 + if len(partsSlice) == 0 { + modified = true + continue + } + + filtered = append(filtered, c) + } + + if !modified { + return body, nil + } + + payload["contents"] = filtered + return json.Marshal(payload) +} diff --git a/backend/internal/service/antigravity_gateway_retry.go b/backend/internal/service/antigravity_gateway_retry.go new file mode 100644 index 0000000000..f2f5d44f84 --- /dev/null +++ b/backend/internal/service/antigravity_gateway_retry.go @@ -0,0 +1,1279 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + mathrand "math/rand" + "net" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/gin-gonic/gin" +) + +// antigravityRetryLoopParams 重试循环的参数 +type antigravityRetryLoopParams struct { + ctx context.Context + prefix string + account *Account + proxyURL string + accessToken string + action string + body []byte + c *gin.Context + httpUpstream HTTPUpstream + settingService *SettingService + accountRepo AccountRepository // 用于智能重试的模型级别限流 + handleError func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult + requestedModel string // 用于限流检查的原始请求模型 + isStickySession bool // 是否为粘性会话(用于账号切换时的缓存计费判断) + groupID int64 // 用于模型级限流时清除粘性会话 + sessionHash string // 用于模型级限流时清除粘性会话 +} + +// antigravityRetryLoopResult 重试循环的结果 +type antigravityRetryLoopResult struct { + resp *http.Response +} + +// resolveAntigravityForwardBaseURL 解析转发用 base URL。 +// +// 默认使用生产端点 cloudcode-pa.googleapis.com(antigravity.BaseURLs 的首个地址, +// 与账号 OAuth 登录/测试连接所用的 antigravity.BaseURL 一致)。 +// +// 历史上这里改用 ForwardBaseURLs()(把 daily/sandbox 排到首位)并默认取首个地址, +// 导致网关把带生产 OAuth token 的请求发到 daily-cloudcode-pa.sandbox.googleapis.com, +// 上游拒绝 → 账号被 401「Invalid bearer token」/502 打入临时不可调度且无法恢复 +// (见 #3611 / #2962)。后台「测试连接」用的是生产端点,所以「测试成功但网关 401」。 +// +// daily/sandbox 端点仅供内部联调,需显式设置 +// GATEWAY_ANTIGRAVITY_FORWARD_BASE_URL=daily(或 sandbox)才启用。 +func resolveAntigravityForwardBaseURL() string { + baseURLs := antigravity.BaseURLs + if len(baseURLs) == 0 { + return "" + } + mode := strings.ToLower(strings.TrimSpace(os.Getenv(antigravityForwardBaseURLEnv))) + if (mode == "daily" || mode == "sandbox") && len(baseURLs) > 1 { + return baseURLs[1] + } + return baseURLs[0] +} + +// smartRetryAction 智能重试的处理结果 +type smartRetryAction int + +const ( + smartRetryActionContinue smartRetryAction = iota // 继续默认重试逻辑 + smartRetryActionBreakWithResp // 结束循环并返回 resp + smartRetryActionContinueURL // 继续 URL fallback 循环 +) + +// smartRetryResult 智能重试的结果 +type smartRetryResult struct { + action smartRetryAction + resp *http.Response + err error + switchError *AntigravityAccountSwitchError // 模型限流时返回账号切换信号 +} + +// handleSmartRetry 处理 OAuth 账号的智能重试逻辑 +// 将 429/503 限流处理逻辑抽取为独立函数,减少 antigravityRetryLoop 的复杂度 +func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParams, resp *http.Response, respBody []byte, baseURL string, urlIdx int, availableURLs []string) *smartRetryResult { + // "Resource has been exhausted" 是 URL 级别限流,切换 URL(仅 429) + if resp.StatusCode == http.StatusTooManyRequests && isURLLevelRateLimit(respBody) && urlIdx < len(availableURLs)-1 { + logger.LegacyPrintf("service.antigravity_gateway", "%s URL fallback (429): %s -> %s", p.prefix, baseURL, availableURLs[urlIdx+1]) + return &smartRetryResult{action: smartRetryActionContinueURL} + } + + category := antigravity429Unknown + if resp.StatusCode == http.StatusTooManyRequests { + category = classifyAntigravity429(respBody) + } + + // 判断是否触发智能重试 + shouldSmartRetry, shouldRateLimitModel, waitDuration, modelName, isModelCapacityExhausted := shouldTriggerAntigravitySmartRetry(p.account, respBody) + + // AI Credits 超量请求: + // 仅在上游明确返回免费配额耗尽时才允许切换到 credits。 + if resp.StatusCode == http.StatusTooManyRequests && + category == antigravity429QuotaExhausted && + p.account.IsOveragesEnabled() && + !p.account.isCreditsExhausted() { + result := s.attemptCreditsOveragesRetry(p, baseURL, modelName, waitDuration, resp.StatusCode, respBody) + if result.handled && result.resp != nil { + return &smartRetryResult{ + action: smartRetryActionBreakWithResp, + resp: result.resp, + } + } + } + + // 情况1: retryDelay >= 阈值,限流模型并切换账号 + if shouldRateLimitModel { + // 单账号 503 退避重试模式:不设限流、不切换账号,改为原地等待+重试 + // 谷歌上游 503 (MODEL_CAPACITY_EXHAUSTED) 通常是暂时性的,等几秒就能恢复。 + // 多账号场景下切换账号是最优选择,但单账号场景下设限流毫无意义(只会导致双重等待)。 + if resp.StatusCode == http.StatusServiceUnavailable && isSingleAccountRetry(p.ctx) { + return s.handleSingleAccountRetryInPlace(p, resp, respBody, baseURL, waitDuration, modelName) + } + + rateLimitDuration := waitDuration + if rateLimitDuration <= 0 { + rateLimitDuration = antigravityDefaultRateLimitDuration + } + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d oauth_long_delay model=%s account=%d upstream_retry_delay=%v body=%s (model rate limit, switch account)", + p.prefix, resp.StatusCode, modelName, p.account.ID, rateLimitDuration, truncateForLog(respBody, 200)) + + resetAt := time.Now().Add(rateLimitDuration) + if !s.setAntigravityModelRateLimits(p.ctx, p.accountRepo, p.account, modelName, p.prefix, resp.StatusCode, resetAt, false) { + p.handleError(p.ctx, p.prefix, p.account, resp.StatusCode, resp.Header, respBody, p.requestedModel, p.groupID, p.sessionHash, p.isStickySession) + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d rate_limited account=%d (no model mapping)", p.prefix, resp.StatusCode, p.account.ID) + } + s.clearStickySession(p.ctx, p.groupID, p.sessionHash) + + // 返回账号切换信号,让上层切换账号重试 + return &smartRetryResult{ + action: smartRetryActionBreakWithResp, + switchError: &AntigravityAccountSwitchError{ + OriginalAccountID: p.account.ID, + RateLimitedModel: modelName, + IsStickySession: p.isStickySession, + }, + } + } + + // 情况2: retryDelay < 阈值(或 MODEL_CAPACITY_EXHAUSTED),智能重试 + if shouldSmartRetry { + var lastRetryResp *http.Response + var lastRetryBody []byte + + // MODEL_CAPACITY_EXHAUSTED 使用独立的重试参数(60 次,固定 1s 间隔) + maxAttempts := antigravitySmartRetryMaxAttempts + if isModelCapacityExhausted { + maxAttempts = antigravityModelCapacityRetryMaxAttempts + waitDuration = antigravityModelCapacityRetryWait + + // 全局去重:如果其他 goroutine 已在重试同一模型且尚在 cooldown 中,直接返回 503 + if modelName != "" { + modelCapacityExhaustedMu.RLock() + cooldownUntil, exists := modelCapacityExhaustedUntil[modelName] + modelCapacityExhaustedMu.RUnlock() + if exists && time.Now().Before(cooldownUntil) { + log.Printf("%s status=%d model_capacity_exhausted_dedup model=%s account=%d cooldown_until=%v (skip retry)", + p.prefix, resp.StatusCode, modelName, p.account.ID, cooldownUntil.Format("15:04:05")) + return &smartRetryResult{ + action: smartRetryActionBreakWithResp, + resp: &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(respBody)), + }, + } + } + } + } + + for attempt := 1; attempt <= maxAttempts; attempt++ { + log.Printf("%s status=%d oauth_smart_retry attempt=%d/%d delay=%v model=%s account=%d", + p.prefix, resp.StatusCode, attempt, maxAttempts, waitDuration, modelName, p.account.ID) + + timer := time.NewTimer(waitDuration) + select { + case <-p.ctx.Done(): + timer.Stop() + log.Printf("%s status=context_canceled_during_smart_retry", p.prefix) + return &smartRetryResult{action: smartRetryActionBreakWithResp, err: p.ctx.Err()} + case <-timer.C: + } + + // 智能重试:创建新请求 + retryReq, err := antigravity.NewAPIRequestWithURL(p.ctx, baseURL, p.action, p.accessToken, p.body) + if err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=smart_retry_request_build_failed error=%v", p.prefix, err) + p.handleError(p.ctx, p.prefix, p.account, resp.StatusCode, resp.Header, respBody, p.requestedModel, p.groupID, p.sessionHash, p.isStickySession) + return &smartRetryResult{ + action: smartRetryActionBreakWithResp, + resp: &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(respBody)), + }, + } + } + + retryResp, retryErr := p.httpUpstream.Do(retryReq, p.proxyURL, p.account.ID, p.account.Concurrency) + if retryErr == nil && retryResp != nil && retryResp.StatusCode != http.StatusTooManyRequests && retryResp.StatusCode != http.StatusServiceUnavailable { + log.Printf("%s status=%d smart_retry_success attempt=%d/%d", p.prefix, retryResp.StatusCode, attempt, maxAttempts) + // 重试成功,清除 MODEL_CAPACITY_EXHAUSTED cooldown + if isModelCapacityExhausted && modelName != "" { + modelCapacityExhaustedMu.Lock() + delete(modelCapacityExhaustedUntil, modelName) + modelCapacityExhaustedMu.Unlock() + } + return &smartRetryResult{action: smartRetryActionBreakWithResp, resp: retryResp} + } + + // 网络错误时,继续重试 + if retryErr != nil || retryResp == nil { + log.Printf("%s status=smart_retry_network_error attempt=%d/%d error=%v", p.prefix, attempt, maxAttempts, retryErr) + continue + } + + // 重试失败,关闭之前的响应 + if lastRetryResp != nil { + _ = lastRetryResp.Body.Close() + } + lastRetryResp = retryResp + if retryResp != nil { + lastRetryBody, _ = io.ReadAll(io.LimitReader(retryResp.Body, 8<<10)) + _ = retryResp.Body.Close() + } + + // 解析新的重试信息,用于下次重试的等待时间(MODEL_CAPACITY_EXHAUSTED 使用固定循环,跳过) + if !isModelCapacityExhausted && attempt < maxAttempts && lastRetryBody != nil { + newShouldRetry, _, newWaitDuration, _, _ := shouldTriggerAntigravitySmartRetry(p.account, lastRetryBody) + if newShouldRetry && newWaitDuration > 0 { + waitDuration = newWaitDuration + } + } + } + + // 所有重试都失败 + rateLimitDuration := waitDuration + if rateLimitDuration <= 0 { + rateLimitDuration = antigravityDefaultRateLimitDuration + } + retryBody := lastRetryBody + if retryBody == nil { + retryBody = respBody + } + + // MODEL_CAPACITY_EXHAUSTED:模型容量不足,切换账号无意义 + // 直接返回上游错误响应,不设置模型限流,不切换账号 + if isModelCapacityExhausted { + // 设置 cooldown,让后续请求快速失败,避免重复重试 + if modelName != "" { + modelCapacityExhaustedMu.Lock() + modelCapacityExhaustedUntil[modelName] = time.Now().Add(antigravityModelCapacityCooldown) + modelCapacityExhaustedMu.Unlock() + } + log.Printf("%s status=%d smart_retry_exhausted_model_capacity attempts=%d model=%s account=%d body=%s (model capacity exhausted, not switching account)", + p.prefix, resp.StatusCode, maxAttempts, modelName, p.account.ID, truncateForLog(retryBody, 200)) + return &smartRetryResult{ + action: smartRetryActionBreakWithResp, + resp: &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(retryBody)), + }, + } + } + + // 单账号 503 退避重试模式:智能重试耗尽后不设限流、不切换账号, + // 直接返回 503 让 Handler 层的单账号退避循环做最终处理。 + if resp.StatusCode == http.StatusServiceUnavailable && isSingleAccountRetry(p.ctx) { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d smart_retry_exhausted_single_account attempts=%d model=%s account=%d body=%s (return 503 directly)", + p.prefix, resp.StatusCode, antigravitySmartRetryMaxAttempts, modelName, p.account.ID, truncateForLog(retryBody, 200)) + return &smartRetryResult{ + action: smartRetryActionBreakWithResp, + resp: &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(retryBody)), + }, + } + } + + log.Printf("%s status=%d smart_retry_exhausted attempts=%d model=%s account=%d upstream_retry_delay=%v body=%s (switch account)", + p.prefix, resp.StatusCode, maxAttempts, modelName, p.account.ID, rateLimitDuration, truncateForLog(retryBody, 200)) + + resetAt := time.Now().Add(rateLimitDuration) + s.setAntigravityModelRateLimits(p.ctx, p.accountRepo, p.account, modelName, p.prefix, resp.StatusCode, resetAt, true) + + // 清除粘性会话绑定,避免下次请求仍命中限流账号 + s.clearStickySession(p.ctx, p.groupID, p.sessionHash) + + // 返回账号切换信号,让上层切换账号重试 + return &smartRetryResult{ + action: smartRetryActionBreakWithResp, + switchError: &AntigravityAccountSwitchError{ + OriginalAccountID: p.account.ID, + RateLimitedModel: modelName, + IsStickySession: p.isStickySession, + }, + } + } + + // 未触发智能重试,继续默认重试逻辑 + return &smartRetryResult{action: smartRetryActionContinue} +} + +// handleSingleAccountRetryInPlace 单账号 503 退避重试的原地重试逻辑。 +// +// 在多账号场景下,收到 503 + 长 retryDelay(≥ 7s)时会设置模型限流 + 切换账号; +// 但在单账号场景下,设限流毫无意义(因为切换回来的还是同一个账号,还要等限流过期)。 +// 此方法改为在 Service 层原地等待 + 重试,避免双重等待问题: +// +// 旧流程:Service 设限流 → Handler 退避等待 → Service 等限流过期 → 再请求(总耗时 = 退避 + 限流) +// 新流程:Service 直接等 retryDelay → 重试 → 成功/再等 → 重试...(总耗时 ≈ 实际 retryDelay × 重试次数) +// +// 约束: +// - 单次等待不超过 antigravitySingleAccountSmartRetryMaxWait +// - 总累计等待不超过 antigravitySingleAccountSmartRetryTotalMaxWait +// - 最多重试 antigravitySingleAccountSmartRetryMaxAttempts 次 +func (s *AntigravityGatewayService) handleSingleAccountRetryInPlace( + p antigravityRetryLoopParams, + resp *http.Response, + respBody []byte, + baseURL string, + waitDuration time.Duration, + modelName string, +) *smartRetryResult { + // 限制单次等待时间 + if waitDuration > antigravitySingleAccountSmartRetryMaxWait { + waitDuration = antigravitySingleAccountSmartRetryMaxWait + } + if waitDuration < antigravitySmartRetryMinWait { + waitDuration = antigravitySmartRetryMinWait + } + + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d single_account_503_retry_in_place model=%s account=%d upstream_retry_delay=%v (retrying in-place instead of rate-limiting)", + p.prefix, resp.StatusCode, modelName, p.account.ID, waitDuration) + + var lastRetryResp *http.Response + var lastRetryBody []byte + totalWaited := time.Duration(0) + + for attempt := 1; attempt <= antigravitySingleAccountSmartRetryMaxAttempts; attempt++ { + // 检查累计等待是否超限 + if totalWaited+waitDuration > antigravitySingleAccountSmartRetryTotalMaxWait { + remaining := antigravitySingleAccountSmartRetryTotalMaxWait - totalWaited + if remaining <= 0 { + logger.LegacyPrintf("service.antigravity_gateway", "%s single_account_503_retry: total_wait_exceeded total=%v max=%v, giving up", + p.prefix, totalWaited, antigravitySingleAccountSmartRetryTotalMaxWait) + break + } + waitDuration = remaining + } + + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d single_account_503_retry attempt=%d/%d delay=%v total_waited=%v model=%s account=%d", + p.prefix, resp.StatusCode, attempt, antigravitySingleAccountSmartRetryMaxAttempts, waitDuration, totalWaited, modelName, p.account.ID) + + timer := time.NewTimer(waitDuration) + select { + case <-p.ctx.Done(): + timer.Stop() + logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_single_account_retry", p.prefix) + return &smartRetryResult{action: smartRetryActionBreakWithResp, err: p.ctx.Err()} + case <-timer.C: + } + totalWaited += waitDuration + + // 创建新请求 + retryReq, err := antigravity.NewAPIRequestWithURL(p.ctx, baseURL, p.action, p.accessToken, p.body) + if err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "%s single_account_503_retry: request_build_failed error=%v", p.prefix, err) + break + } + + retryResp, retryErr := p.httpUpstream.Do(retryReq, p.proxyURL, p.account.ID, p.account.Concurrency) + if retryErr == nil && retryResp != nil && retryResp.StatusCode != http.StatusTooManyRequests && retryResp.StatusCode != http.StatusServiceUnavailable { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d single_account_503_retry_success attempt=%d/%d total_waited=%v", + p.prefix, retryResp.StatusCode, attempt, antigravitySingleAccountSmartRetryMaxAttempts, totalWaited) + // 关闭之前的响应 + if lastRetryResp != nil { + _ = lastRetryResp.Body.Close() + } + return &smartRetryResult{action: smartRetryActionBreakWithResp, resp: retryResp} + } + + // 网络错误时继续重试 + if retryErr != nil || retryResp == nil { + logger.LegacyPrintf("service.antigravity_gateway", "%s single_account_503_retry: network_error attempt=%d/%d error=%v", + p.prefix, attempt, antigravitySingleAccountSmartRetryMaxAttempts, retryErr) + continue + } + + // 关闭之前的响应 + if lastRetryResp != nil { + _ = lastRetryResp.Body.Close() + } + lastRetryResp = retryResp + lastRetryBody, _ = io.ReadAll(io.LimitReader(retryResp.Body, 8<<10)) + _ = retryResp.Body.Close() + + // 解析新的重试信息,更新下次等待时间 + if attempt < antigravitySingleAccountSmartRetryMaxAttempts && lastRetryBody != nil { + _, _, newWaitDuration, _, _ := shouldTriggerAntigravitySmartRetry(p.account, lastRetryBody) + if newWaitDuration > 0 { + waitDuration = newWaitDuration + if waitDuration > antigravitySingleAccountSmartRetryMaxWait { + waitDuration = antigravitySingleAccountSmartRetryMaxWait + } + if waitDuration < antigravitySmartRetryMinWait { + waitDuration = antigravitySmartRetryMinWait + } + } + } + } + + // 所有重试都失败,不设限流,直接返回 503 + // Handler 层的单账号退避循环会做最终处理 + retryBody := lastRetryBody + if retryBody == nil { + retryBody = respBody + } + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d single_account_503_retry_exhausted attempts=%d total_waited=%v model=%s account=%d body=%s (return 503 directly)", + p.prefix, resp.StatusCode, antigravitySingleAccountSmartRetryMaxAttempts, totalWaited, modelName, p.account.ID, truncateForLog(retryBody, 200)) + + return &smartRetryResult{ + action: smartRetryActionBreakWithResp, + resp: &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(retryBody)), + }, + } +} + +// antigravityRetryLoop 执行带 URL fallback 的重试循环 +func (s *AntigravityGatewayService) antigravityRetryLoop(p antigravityRetryLoopParams) (*antigravityRetryLoopResult, error) { + // 预检查:模型限流 + overages 启用 + 积分未耗尽 → 直接注入 AI Credits + overagesInjected := false + if p.requestedModel != "" && p.account.Platform == PlatformAntigravity && + p.account.IsOveragesEnabled() && !p.account.isCreditsExhausted() && + p.account.isModelRateLimitedWithContext(p.ctx, p.requestedModel) { + if creditsBody := injectEnabledCreditTypes(p.body); creditsBody != nil { + p.body = creditsBody + overagesInjected = true + logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: model_rate_limited_credits_inject model=%s account=%d (injecting enabledCreditTypes)", + p.prefix, p.requestedModel, p.account.ID) + } + } + + // 预检查:如果账号已限流,直接返回切换信号 + if p.requestedModel != "" { + if remaining := p.account.GetRateLimitRemainingTimeWithContext(p.ctx, p.requestedModel); remaining > 0 { + // 已注入积分的请求不再受普通模型限流预检查阻断。 + if overagesInjected { + logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: credits_injected_ignore_rate_limit remaining=%v model=%s account=%d", + p.prefix, remaining.Truncate(time.Millisecond), p.requestedModel, p.account.ID) + } else if isSingleAccountRetry(p.ctx) { + // 单账号 503 退避重试模式:跳过限流预检查,直接发请求。 + // 首次请求设的限流是为了多账号调度器跳过该账号,在单账号模式下无意义。 + // 如果上游确实还不可用,handleSmartRetry → handleSingleAccountRetryInPlace + // 会在 Service 层原地等待+重试,不需要在预检查这里等。 + logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: single_account_retry skipping rate_limit remaining=%v model=%s account=%d (will retry in-place if 503)", + p.prefix, remaining.Truncate(time.Millisecond), p.requestedModel, p.account.ID) + } else { + logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: rate_limit_switch remaining=%v model=%s account=%d", + p.prefix, remaining.Truncate(time.Millisecond), p.requestedModel, p.account.ID) + return nil, &AntigravityAccountSwitchError{ + OriginalAccountID: p.account.ID, + RateLimitedModel: p.requestedModel, + IsStickySession: p.isStickySession, + } + } + } + } + + baseURL := resolveAntigravityForwardBaseURL() + if baseURL == "" { + return nil, errors.New("no antigravity forward base url configured") + } + availableURLs := []string{baseURL} + + var resp *http.Response + var usedBaseURL string + logBody := p.settingService != nil && p.settingService.cfg != nil && p.settingService.cfg.Gateway.LogUpstreamErrorBody + maxBytes := 2048 + if p.settingService != nil && p.settingService.cfg != nil && p.settingService.cfg.Gateway.LogUpstreamErrorBodyMaxBytes > 0 { + maxBytes = p.settingService.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + } + getUpstreamDetail := func(body []byte) string { + if !logBody { + return "" + } + return truncateString(string(body), maxBytes) + } + +urlFallbackLoop: + for urlIdx, baseURL := range availableURLs { + usedBaseURL = baseURL + allAttemptsInternal500 := true // 追踪本轮所有 attempt 是否全部命中 INTERNAL 500 + for attempt := 1; attempt <= antigravityMaxRetries; attempt++ { + select { + case <-p.ctx.Done(): + logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled error=%v", p.prefix, p.ctx.Err()) + return nil, p.ctx.Err() + default: + } + + upstreamReq, err := antigravity.NewAPIRequestWithURL(p.ctx, baseURL, p.action, p.accessToken, p.body) + if err != nil { + return nil, err + } + + resp, err = p.httpUpstream.Do(upstreamReq, p.proxyURL, p.account.ID, p.account.Concurrency) + if err == nil && resp == nil { + err = errors.New("upstream returned nil response") + } + if err != nil { + safeErr := sanitizeUpstreamErrorMessage(err.Error()) + appendOpsUpstreamError(p.c, OpsUpstreamErrorEvent{ + Platform: p.account.Platform, + AccountID: p.account.ID, + AccountName: p.account.Name, + UpstreamStatusCode: 0, + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), + Kind: "request_error", + Message: safeErr, + }) + if shouldAntigravityFallbackToNextURL(err, 0) && urlIdx < len(availableURLs)-1 { + logger.LegacyPrintf("service.antigravity_gateway", "%s URL fallback (connection error): %s -> %s", p.prefix, baseURL, availableURLs[urlIdx+1]) + continue urlFallbackLoop + } + if attempt < antigravityMaxRetries { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=request_failed retry=%d/%d error=%v", p.prefix, attempt, antigravityMaxRetries, err) + if !sleepAntigravityBackoffWithContext(p.ctx, attempt) { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_backoff", p.prefix) + return nil, p.ctx.Err() + } + continue + } + logger.LegacyPrintf("service.antigravity_gateway", "%s status=request_failed retries_exhausted error=%v", p.prefix, err) + setOpsUpstreamError(p.c, 0, safeErr, "") + return nil, fmt.Errorf("upstream request failed after retries: %w", err) + } + + // 统一处理错误响应 + if resp.StatusCode >= 400 { + respBody := s.readUpstreamErrorBody(resp) + _ = resp.Body.Close() + + if overagesInjected && shouldMarkCreditsExhausted(resp, respBody, nil) { + modelKey := resolveCreditsOveragesModelKey(p.ctx, p.account, "", p.requestedModel) + s.handleCreditsRetryFailure(p.ctx, p.prefix, modelKey, p.account, &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(respBody)), + }, nil) + } + + // ★ 统一入口:自定义错误码 + 临时不可调度 + if handled, outStatus, policyErr := s.applyErrorPolicy(p, resp.StatusCode, resp.Header, respBody); handled { + if policyErr != nil { + return nil, policyErr + } + resp = &http.Response{ + StatusCode: outStatus, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(respBody)), + } + break urlFallbackLoop + } + + // 429/503 限流处理:区分 URL 级别限流、智能重试和账户配额限流 + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable { + // 尝试智能重试处理(OAuth 账号专用) + smartResult := s.handleSmartRetry(p, resp, respBody, baseURL, urlIdx, availableURLs) + switch smartResult.action { + case smartRetryActionContinueURL: + continue urlFallbackLoop + case smartRetryActionBreakWithResp: + if smartResult.err != nil { + return nil, smartResult.err + } + // 模型限流时返回切换账号信号 + if smartResult.switchError != nil { + return nil, smartResult.switchError + } + resp = smartResult.resp + break urlFallbackLoop + } + // smartRetryActionContinue: 继续默认重试逻辑 + + // 账户/模型配额限流,重试 3 次(指数退避)- 默认逻辑(非 OAuth 账号或解析失败) + if attempt < antigravityMaxRetries { + upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + appendOpsUpstreamError(p.c, OpsUpstreamErrorEvent{ + Platform: p.account.Platform, + AccountID: p.account.ID, + AccountName: p.account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), + Kind: "retry", + Message: upstreamMsg, + Detail: getUpstreamDetail(respBody), + }) + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d retry=%d/%d body=%s", p.prefix, resp.StatusCode, attempt, antigravityMaxRetries, truncateForLog(respBody, 200)) + if !sleepAntigravityBackoffWithContext(p.ctx, attempt) { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_backoff", p.prefix) + return nil, p.ctx.Err() + } + continue + } + + // 重试用尽,标记账户限流 + p.handleError(p.ctx, p.prefix, p.account, resp.StatusCode, resp.Header, respBody, p.requestedModel, p.groupID, p.sessionHash, p.isStickySession) + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d rate_limited base_url=%s body=%s", p.prefix, resp.StatusCode, baseURL, truncateForLog(respBody, 200)) + resp = &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(respBody)), + } + break urlFallbackLoop + } + + // 其他可重试错误(500/502/504/529,不包括 429 和 503) + if shouldRetryAntigravityError(resp.StatusCode) { + if attempt < antigravityMaxRetries { + upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + appendOpsUpstreamError(p.c, OpsUpstreamErrorEvent{ + Platform: p.account.Platform, + AccountID: p.account.ID, + AccountName: p.account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), + Kind: "retry", + Message: upstreamMsg, + Detail: getUpstreamDetail(respBody), + }) + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d retry=%d/%d body=%s", p.prefix, resp.StatusCode, attempt, antigravityMaxRetries, truncateForLog(respBody, 500)) + if !sleepAntigravityBackoffWithContext(p.ctx, attempt) { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_backoff", p.prefix) + return nil, p.ctx.Err() + } + // 追踪 INTERNAL 500:非匹配的 attempt 清除标记 + if !isAntigravityInternalServerError(resp.StatusCode, respBody) { + allAttemptsInternal500 = false + } + continue + } + } + + // INTERNAL 500 渐进惩罚:3 次重试全部命中特定 500 时递增计数器并惩罚 + if allAttemptsInternal500 && isAntigravityInternalServerError(resp.StatusCode, respBody) { + s.handleInternal500RetryExhausted(p.ctx, p.prefix, p.account) + } + + // 其他 4xx 错误或重试用尽,直接返回 + resp = &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(respBody)), + } + break urlFallbackLoop + } + + // 成功响应(< 400) + break urlFallbackLoop + } + } + + if resp != nil && resp.StatusCode < 400 && usedBaseURL != "" { + antigravity.DefaultURLAvailability.MarkSuccess(usedBaseURL) + } + + // 成功响应时清零 INTERNAL 500 连续失败计数器(覆盖所有成功路径,含 smart retry) + if resp != nil && resp.StatusCode < 400 { + s.resetInternal500Counter(p.ctx, p.prefix, p.account.ID) + } + + return &antigravityRetryLoopResult{resp: resp}, nil +} + +// shouldRetryAntigravityError 判断是否应该重试 +func shouldRetryAntigravityError(statusCode int) bool { + switch statusCode { + case 429, 500, 502, 503, 504, 529: + return true + default: + return false + } +} + +// isURLLevelRateLimit 判断是否为 URL 级别的限流(应切换 URL 重试) +// "Resource has been exhausted" 是 URL/节点级别限流,切换 URL 可能成功 +// "exhausted your capacity on this model" 是账户/模型配额限流,切换 URL 无效 +func isURLLevelRateLimit(body []byte) bool { + // 快速检查:包含 "Resource has been exhausted" 且不包含 "capacity on this model" + bodyStr := string(body) + return strings.Contains(bodyStr, "Resource has been exhausted") && + !strings.Contains(bodyStr, "capacity on this model") +} + +// isAntigravityConnectionError 判断是否为连接错误(网络超时、DNS 失败、连接拒绝) +func isAntigravityConnectionError(err error) bool { + if err == nil { + return false + } + + // 检查超时错误 + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return true + } + + // 检查连接错误(DNS 失败、连接拒绝) + var opErr *net.OpError + return errors.As(err, &opErr) +} + +// shouldAntigravityFallbackToNextURL 判断是否应切换到下一个 URL +// 仅连接错误和 HTTP 429 触发 URL 降级 +func shouldAntigravityFallbackToNextURL(err error, statusCode int) bool { + if isAntigravityConnectionError(err) { + return true + } + return statusCode == http.StatusTooManyRequests +} + +// getSessionID 从 gin.Context 获取 session_id(用于日志追踪) +func getSessionID(c *gin.Context) string { + if c == nil { + return "" + } + return c.GetHeader("session_id") +} + +// logPrefix 生成统一的日志前缀 +func logPrefix(sessionID, accountName string) string { + if sessionID != "" { + return fmt.Sprintf("[antigravity-Forward] session=%s account=%s", sessionID, accountName) + } + return fmt.Sprintf("[antigravity-Forward] account=%s", accountName) +} + +func (s *AntigravityGatewayService) shouldFailoverUpstreamError(statusCode int) bool { + switch statusCode { + case 401, 403, 429, 529: + return true + default: + return statusCode >= 500 + } +} + +// isGoogleProjectConfigError 判断(已提取的小写)错误消息是否属于 Google 服务端配置类问题。 +// 只精确匹配已知的服务端侧错误,避免对客户端请求错误做无意义重试。 +// 适用于所有走 Google 后端的平台(Antigravity、Gemini)。 +func isGoogleProjectConfigError(lowerMsg string) bool { + // Google 间歇性 Bug:Project ID 有效但被临时识别失败 + return strings.Contains(lowerMsg, "invalid project resource name") +} + +// googleConfigErrorCooldown 服务端配置类 400 错误的临时封禁时长 +const googleConfigErrorCooldown = 1 * time.Minute + +// tempUnscheduleGoogleConfigError 对服务端配置类 400 错误触发临时封禁, +// 避免短时间内反复调度到同一个有问题的账号。 +func tempUnscheduleGoogleConfigError(ctx context.Context, repo AccountRepository, accountID int64, logPrefix string) { + until := time.Now().Add(googleConfigErrorCooldown) + reason := "400: invalid project resource name (auto temp-unschedule 1m)" + if err := repo.SetTempUnschedulable(ctx, accountID, until, reason); err != nil { + log.Printf("%s temp_unschedule_failed account=%d error=%v", logPrefix, accountID, err) + } else { + log.Printf("%s temp_unscheduled account=%d until=%v reason=%q", logPrefix, accountID, until.Format("15:04:05"), reason) + } +} + +// emptyResponseCooldown 空流式响应的临时封禁时长 +const emptyResponseCooldown = 1 * time.Minute + +// tempUnscheduleEmptyResponse 对空流式响应触发临时封禁, +// 避免短时间内反复调度到同一个返回空响应的账号。 +func tempUnscheduleEmptyResponse(ctx context.Context, repo AccountRepository, accountID int64, logPrefix string) { + until := time.Now().Add(emptyResponseCooldown) + reason := "empty stream response (auto temp-unschedule 1m)" + if err := repo.SetTempUnschedulable(ctx, accountID, until, reason); err != nil { + log.Printf("%s temp_unschedule_failed account=%d error=%v", logPrefix, accountID, err) + } else { + log.Printf("%s temp_unscheduled account=%d until=%v reason=%q", logPrefix, accountID, until.Format("15:04:05"), reason) + } +} + +// sleepAntigravityBackoffWithContext 带 context 取消检查的退避等待 +// 返回 true 表示正常完成等待,false 表示 context 已取消 +func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool { + delay := antigravityRetryBaseDelay * time.Duration(1< antigravityRetryMaxDelay { + delay = antigravityRetryMaxDelay + } + + // +/- 20% jitter + r := mathrand.New(mathrand.NewSource(time.Now().UnixNano())) + jitter := time.Duration(float64(delay) * 0.2 * (r.Float64()*2 - 1)) + sleepFor := delay + jitter + if sleepFor < 0 { + sleepFor = 0 + } + + timer := time.NewTimer(sleepFor) + select { + case <-ctx.Done(): + timer.Stop() + return false + case <-timer.C: + return true + } +} + +// isSingleAccountRetry 检查 context 中是否设置了单账号退避重试标记 +func isSingleAccountRetry(ctx context.Context) bool { + v, _ := SingleAccountRetryFromContext(ctx) + return v +} + +// setModelRateLimitByModelName 使用官方模型 ID 设置模型级限流 +// 直接使用上游返回的模型 ID(如 claude-sonnet-4-5)作为限流 key +// 返回是否已成功设置(若模型名为空或 repo 为 nil 将返回 false) +func setModelRateLimitByModelName(ctx context.Context, repo AccountRepository, accountID int64, modelName, prefix string, statusCode int, resetAt time.Time, afterSmartRetry bool) bool { + if repo == nil || modelName == "" { + return false + } + // 直接使用官方模型 ID 作为 key,不再转换为 scope + if err := repo.SetModelRateLimit(ctx, accountID, modelName, resetAt); err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limit_failed model=%s error=%v", prefix, statusCode, modelName, err) + return false + } + if afterSmartRetry { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited_after_smart_retry model=%s account=%d reset_in=%v", prefix, statusCode, modelName, accountID, time.Until(resetAt).Truncate(time.Second)) + } else { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited model=%s account=%d reset_in=%v", prefix, statusCode, modelName, accountID, time.Until(resetAt).Truncate(time.Second)) + } + return true +} + +func (s *AntigravityGatewayService) setAntigravityModelRateLimits(ctx context.Context, repo AccountRepository, account *Account, modelName, prefix string, statusCode int, resetAt time.Time, afterSmartRetry bool) bool { + if account == nil || repo == nil { + return false + } + keys := antigravityModelRateLimitKeys(modelName) + if len(keys) == 0 { + return false + } + + success := false + for _, key := range keys { + if setModelRateLimitByModelName(ctx, repo, account.ID, key, prefix, statusCode, resetAt, afterSmartRetry) { + s.updateAccountModelRateLimitInCache(ctx, account, key, resetAt) + success = true + } + } + return success +} + +func (s *AntigravityGatewayService) clearStickySession(ctx context.Context, groupID int64, sessionHash string) { + if s == nil || s.cache == nil || strings.TrimSpace(sessionHash) == "" { + return + } + if err := s.cache.DeleteSessionAccountID(ctx, groupID, sessionHash); err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] sticky_session_clear_failed group_id=%d session=%s err=%v", groupID, shortSessionHash(sessionHash), err) + } +} + +func antigravityFallbackCooldownSeconds() (time.Duration, bool) { + raw := strings.TrimSpace(os.Getenv(antigravityFallbackSecondsEnv)) + if raw == "" { + return 0, false + } + seconds, err := strconv.Atoi(raw) + if err != nil || seconds <= 0 { + return 0, false + } + return time.Duration(seconds) * time.Second, true +} + +// antigravitySmartRetryInfo 智能重试所需的信息 +type antigravitySmartRetryInfo struct { + RetryDelay time.Duration // 重试延迟时间 + ModelName string // 限流的模型名称(如 "claude-sonnet-4-5") + IsModelCapacityExhausted bool // 是否为模型容量不足(MODEL_CAPACITY_EXHAUSTED) +} + +// parseAntigravitySmartRetryInfo 解析 Google RPC RetryInfo 和 ErrorInfo 信息 +// 返回解析结果,如果解析失败或不满足条件返回 nil +// +// 支持两种情况: +// 1. 429 RESOURCE_EXHAUSTED + RATE_LIMIT_EXCEEDED: +// - error.status == "RESOURCE_EXHAUSTED" +// - error.details[].reason == "RATE_LIMIT_EXCEEDED" +// +// 2. 503 UNAVAILABLE + MODEL_CAPACITY_EXHAUSTED: +// - error.status == "UNAVAILABLE" +// - error.details[].reason == "MODEL_CAPACITY_EXHAUSTED" +// +// 必须满足以下条件才会返回有效值: +// - error.details[] 中存在 @type == "type.googleapis.com/google.rpc.RetryInfo" 的元素 +// - 该元素包含 retryDelay 字段,格式为 "数字s"(如 "0.201506475s") +func parseAntigravitySmartRetryInfo(body []byte) *antigravitySmartRetryInfo { + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + return nil + } + + errObj, ok := parsed["error"].(map[string]any) + if !ok { + return nil + } + + // 检查 status 是否符合条件 + // 情况1: 429 RESOURCE_EXHAUSTED (需要进一步检查 reason == RATE_LIMIT_EXCEEDED) + // 情况2: 503 UNAVAILABLE (需要进一步检查 reason == MODEL_CAPACITY_EXHAUSTED) + status, _ := errObj["status"].(string) + isResourceExhausted := status == googleRPCStatusResourceExhausted + isUnavailable := status == googleRPCStatusUnavailable + + if !isResourceExhausted && !isUnavailable { + return nil + } + + details, ok := errObj["details"].([]any) + if !ok { + return nil + } + + var retryDelay time.Duration + var modelName string + var hasRateLimitExceeded bool // 429 需要此 reason + var hasModelCapacityExhausted bool // 503 需要此 reason + + for _, d := range details { + dm, ok := d.(map[string]any) + if !ok { + continue + } + + atType, _ := dm["@type"].(string) + + // 从 ErrorInfo 提取模型名称和 reason + if atType == googleRPCTypeErrorInfo { + if meta, ok := dm["metadata"].(map[string]any); ok { + if model, ok := meta["model"].(string); ok { + modelName = normalizeAntigravityModelName(model) + } + } + // 检查 reason + if reason, ok := dm["reason"].(string); ok { + if reason == googleRPCReasonModelCapacityExhausted { + hasModelCapacityExhausted = true + } + if reason == googleRPCReasonRateLimitExceeded { + hasRateLimitExceeded = true + } + } + continue + } + + // 从 RetryInfo 提取重试延迟 + if atType == googleRPCTypeRetryInfo { + delay, ok := dm["retryDelay"].(string) + if !ok || delay == "" { + continue + } + // 使用 time.ParseDuration 解析,支持所有 Go duration 格式 + // 例如: "0.5s", "10s", "4m50s", "1h30m", "200ms" 等 + dur, err := time.ParseDuration(delay) + if err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] failed to parse retryDelay: %s error=%v", delay, err) + continue + } + retryDelay = dur + } + } + + // 验证条件 + // 情况1: RESOURCE_EXHAUSTED 需要有 RATE_LIMIT_EXCEEDED reason + // 情况2: UNAVAILABLE 需要有 MODEL_CAPACITY_EXHAUSTED reason + if isResourceExhausted && !hasRateLimitExceeded { + return nil + } + if isUnavailable && !hasModelCapacityExhausted { + return nil + } + + // 必须有模型名才返回有效结果 + if modelName == "" { + return nil + } + + // 如果上游未提供 retryDelay,使用默认限流时间 + if retryDelay <= 0 { + retryDelay = antigravityDefaultRateLimitDuration + } + + return &antigravitySmartRetryInfo{ + RetryDelay: retryDelay, + ModelName: modelName, + IsModelCapacityExhausted: hasModelCapacityExhausted, + } +} + +// shouldTriggerAntigravitySmartRetry 判断是否应该触发智能重试 +// 返回: +// - shouldRetry: 是否应该智能重试(retryDelay < antigravityRateLimitThreshold,或 MODEL_CAPACITY_EXHAUSTED) +// - shouldRateLimitModel: 是否应该限流模型并切换账号(仅 RATE_LIMIT_EXCEEDED 且 retryDelay >= 阈值) +// - waitDuration: 等待时间 +// - modelName: 限流的模型名称 +// - isModelCapacityExhausted: 是否为模型容量不足(MODEL_CAPACITY_EXHAUSTED) +func shouldTriggerAntigravitySmartRetry(account *Account, respBody []byte) (shouldRetry bool, shouldRateLimitModel bool, waitDuration time.Duration, modelName string, isModelCapacityExhausted bool) { + if account.Platform != PlatformAntigravity { + return false, false, 0, "", false + } + + info := parseAntigravitySmartRetryInfo(respBody) + if info == nil { + return false, false, 0, "", false + } + + // MODEL_CAPACITY_EXHAUSTED(模型容量不足):所有账号共享同一模型容量池 + // 切换账号无意义,使用固定 1s 间隔重试 + if info.IsModelCapacityExhausted { + return true, false, antigravityModelCapacityRetryWait, info.ModelName, true + } + + // RATE_LIMIT_EXCEEDED(账号级限流): + // retryDelay >= 阈值:直接限流模型,不重试 + // 注意:如果上游未提供 retryDelay,parseAntigravitySmartRetryInfo 已设置为默认 30s + if info.RetryDelay >= antigravityRateLimitThreshold { + return false, true, info.RetryDelay, info.ModelName, false + } + + // retryDelay < 阈值:智能重试 + waitDuration = info.RetryDelay + if waitDuration < antigravitySmartRetryMinWait { + waitDuration = antigravitySmartRetryMinWait + } + + return true, false, waitDuration, info.ModelName, false +} + +// handleModelRateLimitParams 模型级限流处理参数 +type handleModelRateLimitParams struct { + ctx context.Context + prefix string + account *Account + statusCode int + body []byte + cache GatewayCache + groupID int64 + sessionHash string + isStickySession bool +} + +// handleModelRateLimitResult 模型级限流处理结果 +type handleModelRateLimitResult struct { + Handled bool // 是否已处理 + ShouldRetry bool // 是否等待后重试 + WaitDuration time.Duration // 等待时间 + SwitchError *AntigravityAccountSwitchError // 账号切换错误 +} + +// handleModelRateLimit 处理模型级限流(在原有逻辑之前调用) +// 仅处理 429/503,解析模型名和 retryDelay +// - MODEL_CAPACITY_EXHAUSTED: 返回 Handled=true(实际重试由 handleSmartRetry 处理) +// - RATE_LIMIT_EXCEEDED + retryDelay < 阈值: 返回 ShouldRetry=true,由调用方等待后重试 +// - RATE_LIMIT_EXCEEDED + retryDelay >= 阈值: 设置模型限流 + 清除粘性会话 + 返回 SwitchError +func (s *AntigravityGatewayService) handleModelRateLimit(p *handleModelRateLimitParams) *handleModelRateLimitResult { + if p.statusCode != 429 && p.statusCode != 503 { + return &handleModelRateLimitResult{Handled: false} + } + + info := parseAntigravitySmartRetryInfo(p.body) + if info == nil || info.ModelName == "" { + return &handleModelRateLimitResult{Handled: false} + } + + // MODEL_CAPACITY_EXHAUSTED:模型容量不足,所有账号共享同一容量池 + // 切换账号无意义,不设置模型限流(实际重试由 handleSmartRetry 处理) + if info.IsModelCapacityExhausted { + log.Printf("%s status=%d model_capacity_exhausted model=%s (not switching account, retry handled by smart retry)", + p.prefix, p.statusCode, info.ModelName) + return &handleModelRateLimitResult{ + Handled: true, + } + } + + // RATE_LIMIT_EXCEEDED: < antigravityRateLimitThreshold: 等待后重试 + if info.RetryDelay < antigravityRateLimitThreshold { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limit_wait model=%s wait=%v", + p.prefix, p.statusCode, info.ModelName, info.RetryDelay) + return &handleModelRateLimitResult{ + Handled: true, + ShouldRetry: true, + WaitDuration: info.RetryDelay, + } + } + + // RATE_LIMIT_EXCEEDED: >= antigravityRateLimitThreshold: 设置限流 + 清除粘性会话 + 切换账号 + s.setModelRateLimitAndClearSession(p, info) + + return &handleModelRateLimitResult{ + Handled: true, + SwitchError: &AntigravityAccountSwitchError{ + OriginalAccountID: p.account.ID, + RateLimitedModel: info.ModelName, + IsStickySession: p.isStickySession, + }, + } +} + +// setModelRateLimitAndClearSession 设置模型限流并清除粘性会话 +func (s *AntigravityGatewayService) setModelRateLimitAndClearSession(p *handleModelRateLimitParams, info *antigravitySmartRetryInfo) { + resetAt := time.Now().Add(info.RetryDelay) + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited model=%s account=%d reset_in=%v", + p.prefix, p.statusCode, info.ModelName, p.account.ID, info.RetryDelay) + + s.setAntigravityModelRateLimits(p.ctx, s.accountRepo, p.account, info.ModelName, p.prefix, p.statusCode, resetAt, false) + + // 清除粘性会话绑定 + if p.cache != nil && p.sessionHash != "" { + _ = p.cache.DeleteSessionAccountID(p.ctx, p.groupID, p.sessionHash) + } +} + +// updateAccountModelRateLimitInCache 立即更新 Redis 中账号的模型限流状态 +func (s *AntigravityGatewayService) updateAccountModelRateLimitInCache(ctx context.Context, account *Account, modelKey string, resetAt time.Time) { + if s.schedulerSnapshot == nil || account == nil || modelKey == "" { + return + } + + // 更新账号对象的 Extra 字段 + if account.Extra == nil { + account.Extra = make(map[string]any) + } + + limits, _ := account.Extra["model_rate_limits"].(map[string]any) + if limits == nil { + limits = make(map[string]any) + account.Extra["model_rate_limits"] = limits + } + + limits[modelKey] = map[string]any{ + "rate_limited_at": time.Now().UTC().Format(time.RFC3339), + "rate_limit_reset_at": resetAt.UTC().Format(time.RFC3339), + } + + // 更新 Redis 快照 + if err := s.schedulerSnapshot.UpdateAccountInCache(ctx, account); err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] cache_update_failed account=%d model=%s err=%v", account.ID, modelKey, err) + } +} + +func (s *AntigravityGatewayService) handleUpstreamError( + ctx context.Context, prefix string, account *Account, + statusCode int, headers http.Header, body []byte, + requestedModel string, + groupID int64, sessionHash string, isStickySession bool, +) *handleModelRateLimitResult { + // 遵守自定义错误码策略:未命中则跳过所有限流处理 + if !account.ShouldHandleErrorCode(statusCode) { + return nil + } + // 模型级限流处理(优先) + result := s.handleModelRateLimit(&handleModelRateLimitParams{ + ctx: ctx, + prefix: prefix, + account: account, + statusCode: statusCode, + body: body, + cache: s.cache, + groupID: groupID, + sessionHash: sessionHash, + isStickySession: isStickySession, + }) + if result.Handled { + return result + } + + // 503 仅处理模型限流(MODEL_CAPACITY_EXHAUSTED),非模型限流不做额外处理 + // 避免将普通的 503 错误误判为账号问题 + if statusCode == 503 { + return nil + } + + // 429:尝试解析模型级限流,解析失败时兜底为账号级限流 + if statusCode == 429 { + if logBody, maxBytes := s.getLogConfig(); logBody { + logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity-Debug] 429 response body: %s", truncateString(string(body), maxBytes)) + } + + resetAt := ParseGeminiRateLimitResetTime(body) + defaultDur := s.getDefaultRateLimitDuration() + + // 尝试解析模型 key 并设置模型级限流 + // + // 注意:requestedModel 可能是"映射前"的请求模型名(例如 claude-opus-4-6), + // 调度与限流判定使用的是 Antigravity 最终模型名(包含映射与 thinking 后缀)。 + // 因此这里必须写入最终模型 key,确保后续调度能正确避开已限流模型。 + modelKey := resolveFinalAntigravityModelKey(ctx, account, requestedModel) + if strings.TrimSpace(modelKey) == "" { + // 极少数情况下无法映射(理论上不应发生:能转发成功说明映射已通过), + // 保持旧行为作为兜底,避免完全丢失模型级限流记录。 + modelKey = resolveAntigravityModelKey(requestedModel) + } + if modelKey != "" { + ra := s.resolveResetTime(resetAt, defaultDur) + if !s.setAntigravityModelRateLimits(ctx, s.accountRepo, account, modelKey, prefix, statusCode, ra, false) { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 model_rate_limit_set_failed model=%s", prefix, modelKey) + } else { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 model_rate_limited model=%s account=%d reset_at=%v reset_in=%v", + prefix, modelKey, account.ID, ra.Format("15:04:05"), time.Until(ra).Truncate(time.Second)) + } + return nil + } + + // 无法解析模型 key,兜底为账号级限流 + ra := s.resolveResetTime(resetAt, defaultDur) + logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 rate_limited account=%d reset_at=%v reset_in=%v (fallback)", + prefix, account.ID, ra.Format("15:04:05"), time.Until(ra).Truncate(time.Second)) + if err := s.accountRepo.SetRateLimited(ctx, account.ID, ra); err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 rate_limit_set_failed account=%d error=%v", prefix, account.ID, err) + } + return nil + } + // 其他错误码继续使用 rateLimitService + if s.rateLimitService == nil { + return nil + } + shouldDisable := s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, headers, body) + if shouldDisable { + logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d marked_error", prefix, statusCode) + } + return nil +} + +// getDefaultRateLimitDuration 获取默认限流时间 +func (s *AntigravityGatewayService) getDefaultRateLimitDuration() time.Duration { + defaultDur := antigravityDefaultRateLimitDuration + if s.settingService != nil && s.settingService.cfg != nil && s.settingService.cfg.Gateway.AntigravityFallbackCooldownMinutes > 0 { + defaultDur = time.Duration(s.settingService.cfg.Gateway.AntigravityFallbackCooldownMinutes) * time.Minute + } + if override, ok := antigravityFallbackCooldownSeconds(); ok { + defaultDur = override + } + return defaultDur +} + +// resolveResetTime 根据解析的重置时间或默认时长计算重置时间点 +func (s *AntigravityGatewayService) resolveResetTime(resetAt *int64, defaultDur time.Duration) time.Time { + if resetAt != nil { + return time.Unix(*resetAt, 0) + } + return time.Now().Add(defaultDur) +} diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 585170438e..9e0cc804d5 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -1,28 +1,20 @@ package service import ( - "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" - "log" "log/slog" - mathrand "math/rand" - "net" "net/http" - "os" - "strconv" "strings" "sync" - "sync/atomic" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/tidwall/gjson" ) @@ -127,745 +119,6 @@ func (e *PromptTooLongError) Error() string { return fmt.Sprintf("prompt too long: status=%d", e.StatusCode) } -// antigravityRetryLoopParams 重试循环的参数 -type antigravityRetryLoopParams struct { - ctx context.Context - prefix string - account *Account - proxyURL string - accessToken string - action string - body []byte - c *gin.Context - httpUpstream HTTPUpstream - settingService *SettingService - accountRepo AccountRepository // 用于智能重试的模型级别限流 - handleError func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult - requestedModel string // 用于限流检查的原始请求模型 - isStickySession bool // 是否为粘性会话(用于账号切换时的缓存计费判断) - groupID int64 // 用于模型级限流时清除粘性会话 - sessionHash string // 用于模型级限流时清除粘性会话 -} - -// antigravityRetryLoopResult 重试循环的结果 -type antigravityRetryLoopResult struct { - resp *http.Response -} - -// resolveAntigravityForwardBaseURL 解析转发用 base URL。 -// -// 默认使用生产端点 cloudcode-pa.googleapis.com(antigravity.BaseURLs 的首个地址, -// 与账号 OAuth 登录/测试连接所用的 antigravity.BaseURL 一致)。 -// -// 历史上这里改用 ForwardBaseURLs()(把 daily/sandbox 排到首位)并默认取首个地址, -// 导致网关把带生产 OAuth token 的请求发到 daily-cloudcode-pa.sandbox.googleapis.com, -// 上游拒绝 → 账号被 401「Invalid bearer token」/502 打入临时不可调度且无法恢复 -// (见 #3611 / #2962)。后台「测试连接」用的是生产端点,所以「测试成功但网关 401」。 -// -// daily/sandbox 端点仅供内部联调,需显式设置 -// GATEWAY_ANTIGRAVITY_FORWARD_BASE_URL=daily(或 sandbox)才启用。 -func resolveAntigravityForwardBaseURL() string { - baseURLs := antigravity.BaseURLs - if len(baseURLs) == 0 { - return "" - } - mode := strings.ToLower(strings.TrimSpace(os.Getenv(antigravityForwardBaseURLEnv))) - if (mode == "daily" || mode == "sandbox") && len(baseURLs) > 1 { - return baseURLs[1] - } - return baseURLs[0] -} - -// smartRetryAction 智能重试的处理结果 -type smartRetryAction int - -const ( - smartRetryActionContinue smartRetryAction = iota // 继续默认重试逻辑 - smartRetryActionBreakWithResp // 结束循环并返回 resp - smartRetryActionContinueURL // 继续 URL fallback 循环 -) - -// smartRetryResult 智能重试的结果 -type smartRetryResult struct { - action smartRetryAction - resp *http.Response - err error - switchError *AntigravityAccountSwitchError // 模型限流时返回账号切换信号 -} - -// handleSmartRetry 处理 OAuth 账号的智能重试逻辑 -// 将 429/503 限流处理逻辑抽取为独立函数,减少 antigravityRetryLoop 的复杂度 -func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParams, resp *http.Response, respBody []byte, baseURL string, urlIdx int, availableURLs []string) *smartRetryResult { - // "Resource has been exhausted" 是 URL 级别限流,切换 URL(仅 429) - if resp.StatusCode == http.StatusTooManyRequests && isURLLevelRateLimit(respBody) && urlIdx < len(availableURLs)-1 { - logger.LegacyPrintf("service.antigravity_gateway", "%s URL fallback (429): %s -> %s", p.prefix, baseURL, availableURLs[urlIdx+1]) - return &smartRetryResult{action: smartRetryActionContinueURL} - } - - category := antigravity429Unknown - if resp.StatusCode == http.StatusTooManyRequests { - category = classifyAntigravity429(respBody) - } - - // 判断是否触发智能重试 - shouldSmartRetry, shouldRateLimitModel, waitDuration, modelName, isModelCapacityExhausted := shouldTriggerAntigravitySmartRetry(p.account, respBody) - - // AI Credits 超量请求: - // 仅在上游明确返回免费配额耗尽时才允许切换到 credits。 - if resp.StatusCode == http.StatusTooManyRequests && - category == antigravity429QuotaExhausted && - p.account.IsOveragesEnabled() && - !p.account.isCreditsExhausted() { - result := s.attemptCreditsOveragesRetry(p, baseURL, modelName, waitDuration, resp.StatusCode, respBody) - if result.handled && result.resp != nil { - return &smartRetryResult{ - action: smartRetryActionBreakWithResp, - resp: result.resp, - } - } - } - - // 情况1: retryDelay >= 阈值,限流模型并切换账号 - if shouldRateLimitModel { - // 单账号 503 退避重试模式:不设限流、不切换账号,改为原地等待+重试 - // 谷歌上游 503 (MODEL_CAPACITY_EXHAUSTED) 通常是暂时性的,等几秒就能恢复。 - // 多账号场景下切换账号是最优选择,但单账号场景下设限流毫无意义(只会导致双重等待)。 - if resp.StatusCode == http.StatusServiceUnavailable && isSingleAccountRetry(p.ctx) { - return s.handleSingleAccountRetryInPlace(p, resp, respBody, baseURL, waitDuration, modelName) - } - - rateLimitDuration := waitDuration - if rateLimitDuration <= 0 { - rateLimitDuration = antigravityDefaultRateLimitDuration - } - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d oauth_long_delay model=%s account=%d upstream_retry_delay=%v body=%s (model rate limit, switch account)", - p.prefix, resp.StatusCode, modelName, p.account.ID, rateLimitDuration, truncateForLog(respBody, 200)) - - resetAt := time.Now().Add(rateLimitDuration) - if !s.setAntigravityModelRateLimits(p.ctx, p.accountRepo, p.account, modelName, p.prefix, resp.StatusCode, resetAt, false) { - p.handleError(p.ctx, p.prefix, p.account, resp.StatusCode, resp.Header, respBody, p.requestedModel, p.groupID, p.sessionHash, p.isStickySession) - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d rate_limited account=%d (no model mapping)", p.prefix, resp.StatusCode, p.account.ID) - } - s.clearStickySession(p.ctx, p.groupID, p.sessionHash) - - // 返回账号切换信号,让上层切换账号重试 - return &smartRetryResult{ - action: smartRetryActionBreakWithResp, - switchError: &AntigravityAccountSwitchError{ - OriginalAccountID: p.account.ID, - RateLimitedModel: modelName, - IsStickySession: p.isStickySession, - }, - } - } - - // 情况2: retryDelay < 阈值(或 MODEL_CAPACITY_EXHAUSTED),智能重试 - if shouldSmartRetry { - var lastRetryResp *http.Response - var lastRetryBody []byte - - // MODEL_CAPACITY_EXHAUSTED 使用独立的重试参数(60 次,固定 1s 间隔) - maxAttempts := antigravitySmartRetryMaxAttempts - if isModelCapacityExhausted { - maxAttempts = antigravityModelCapacityRetryMaxAttempts - waitDuration = antigravityModelCapacityRetryWait - - // 全局去重:如果其他 goroutine 已在重试同一模型且尚在 cooldown 中,直接返回 503 - if modelName != "" { - modelCapacityExhaustedMu.RLock() - cooldownUntil, exists := modelCapacityExhaustedUntil[modelName] - modelCapacityExhaustedMu.RUnlock() - if exists && time.Now().Before(cooldownUntil) { - log.Printf("%s status=%d model_capacity_exhausted_dedup model=%s account=%d cooldown_until=%v (skip retry)", - p.prefix, resp.StatusCode, modelName, p.account.ID, cooldownUntil.Format("15:04:05")) - return &smartRetryResult{ - action: smartRetryActionBreakWithResp, - resp: &http.Response{ - StatusCode: resp.StatusCode, - Header: resp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(respBody)), - }, - } - } - } - } - - for attempt := 1; attempt <= maxAttempts; attempt++ { - log.Printf("%s status=%d oauth_smart_retry attempt=%d/%d delay=%v model=%s account=%d", - p.prefix, resp.StatusCode, attempt, maxAttempts, waitDuration, modelName, p.account.ID) - - timer := time.NewTimer(waitDuration) - select { - case <-p.ctx.Done(): - timer.Stop() - log.Printf("%s status=context_canceled_during_smart_retry", p.prefix) - return &smartRetryResult{action: smartRetryActionBreakWithResp, err: p.ctx.Err()} - case <-timer.C: - } - - // 智能重试:创建新请求 - retryReq, err := antigravity.NewAPIRequestWithURL(p.ctx, baseURL, p.action, p.accessToken, p.body) - if err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=smart_retry_request_build_failed error=%v", p.prefix, err) - p.handleError(p.ctx, p.prefix, p.account, resp.StatusCode, resp.Header, respBody, p.requestedModel, p.groupID, p.sessionHash, p.isStickySession) - return &smartRetryResult{ - action: smartRetryActionBreakWithResp, - resp: &http.Response{ - StatusCode: resp.StatusCode, - Header: resp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(respBody)), - }, - } - } - - retryResp, retryErr := p.httpUpstream.Do(retryReq, p.proxyURL, p.account.ID, p.account.Concurrency) - if retryErr == nil && retryResp != nil && retryResp.StatusCode != http.StatusTooManyRequests && retryResp.StatusCode != http.StatusServiceUnavailable { - log.Printf("%s status=%d smart_retry_success attempt=%d/%d", p.prefix, retryResp.StatusCode, attempt, maxAttempts) - // 重试成功,清除 MODEL_CAPACITY_EXHAUSTED cooldown - if isModelCapacityExhausted && modelName != "" { - modelCapacityExhaustedMu.Lock() - delete(modelCapacityExhaustedUntil, modelName) - modelCapacityExhaustedMu.Unlock() - } - return &smartRetryResult{action: smartRetryActionBreakWithResp, resp: retryResp} - } - - // 网络错误时,继续重试 - if retryErr != nil || retryResp == nil { - log.Printf("%s status=smart_retry_network_error attempt=%d/%d error=%v", p.prefix, attempt, maxAttempts, retryErr) - continue - } - - // 重试失败,关闭之前的响应 - if lastRetryResp != nil { - _ = lastRetryResp.Body.Close() - } - lastRetryResp = retryResp - if retryResp != nil { - lastRetryBody, _ = io.ReadAll(io.LimitReader(retryResp.Body, 8<<10)) - _ = retryResp.Body.Close() - } - - // 解析新的重试信息,用于下次重试的等待时间(MODEL_CAPACITY_EXHAUSTED 使用固定循环,跳过) - if !isModelCapacityExhausted && attempt < maxAttempts && lastRetryBody != nil { - newShouldRetry, _, newWaitDuration, _, _ := shouldTriggerAntigravitySmartRetry(p.account, lastRetryBody) - if newShouldRetry && newWaitDuration > 0 { - waitDuration = newWaitDuration - } - } - } - - // 所有重试都失败 - rateLimitDuration := waitDuration - if rateLimitDuration <= 0 { - rateLimitDuration = antigravityDefaultRateLimitDuration - } - retryBody := lastRetryBody - if retryBody == nil { - retryBody = respBody - } - - // MODEL_CAPACITY_EXHAUSTED:模型容量不足,切换账号无意义 - // 直接返回上游错误响应,不设置模型限流,不切换账号 - if isModelCapacityExhausted { - // 设置 cooldown,让后续请求快速失败,避免重复重试 - if modelName != "" { - modelCapacityExhaustedMu.Lock() - modelCapacityExhaustedUntil[modelName] = time.Now().Add(antigravityModelCapacityCooldown) - modelCapacityExhaustedMu.Unlock() - } - log.Printf("%s status=%d smart_retry_exhausted_model_capacity attempts=%d model=%s account=%d body=%s (model capacity exhausted, not switching account)", - p.prefix, resp.StatusCode, maxAttempts, modelName, p.account.ID, truncateForLog(retryBody, 200)) - return &smartRetryResult{ - action: smartRetryActionBreakWithResp, - resp: &http.Response{ - StatusCode: resp.StatusCode, - Header: resp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(retryBody)), - }, - } - } - - // 单账号 503 退避重试模式:智能重试耗尽后不设限流、不切换账号, - // 直接返回 503 让 Handler 层的单账号退避循环做最终处理。 - if resp.StatusCode == http.StatusServiceUnavailable && isSingleAccountRetry(p.ctx) { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d smart_retry_exhausted_single_account attempts=%d model=%s account=%d body=%s (return 503 directly)", - p.prefix, resp.StatusCode, antigravitySmartRetryMaxAttempts, modelName, p.account.ID, truncateForLog(retryBody, 200)) - return &smartRetryResult{ - action: smartRetryActionBreakWithResp, - resp: &http.Response{ - StatusCode: resp.StatusCode, - Header: resp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(retryBody)), - }, - } - } - - log.Printf("%s status=%d smart_retry_exhausted attempts=%d model=%s account=%d upstream_retry_delay=%v body=%s (switch account)", - p.prefix, resp.StatusCode, maxAttempts, modelName, p.account.ID, rateLimitDuration, truncateForLog(retryBody, 200)) - - resetAt := time.Now().Add(rateLimitDuration) - s.setAntigravityModelRateLimits(p.ctx, p.accountRepo, p.account, modelName, p.prefix, resp.StatusCode, resetAt, true) - - // 清除粘性会话绑定,避免下次请求仍命中限流账号 - s.clearStickySession(p.ctx, p.groupID, p.sessionHash) - - // 返回账号切换信号,让上层切换账号重试 - return &smartRetryResult{ - action: smartRetryActionBreakWithResp, - switchError: &AntigravityAccountSwitchError{ - OriginalAccountID: p.account.ID, - RateLimitedModel: modelName, - IsStickySession: p.isStickySession, - }, - } - } - - // 未触发智能重试,继续默认重试逻辑 - return &smartRetryResult{action: smartRetryActionContinue} -} - -// handleSingleAccountRetryInPlace 单账号 503 退避重试的原地重试逻辑。 -// -// 在多账号场景下,收到 503 + 长 retryDelay(≥ 7s)时会设置模型限流 + 切换账号; -// 但在单账号场景下,设限流毫无意义(因为切换回来的还是同一个账号,还要等限流过期)。 -// 此方法改为在 Service 层原地等待 + 重试,避免双重等待问题: -// -// 旧流程:Service 设限流 → Handler 退避等待 → Service 等限流过期 → 再请求(总耗时 = 退避 + 限流) -// 新流程:Service 直接等 retryDelay → 重试 → 成功/再等 → 重试...(总耗时 ≈ 实际 retryDelay × 重试次数) -// -// 约束: -// - 单次等待不超过 antigravitySingleAccountSmartRetryMaxWait -// - 总累计等待不超过 antigravitySingleAccountSmartRetryTotalMaxWait -// - 最多重试 antigravitySingleAccountSmartRetryMaxAttempts 次 -func (s *AntigravityGatewayService) handleSingleAccountRetryInPlace( - p antigravityRetryLoopParams, - resp *http.Response, - respBody []byte, - baseURL string, - waitDuration time.Duration, - modelName string, -) *smartRetryResult { - // 限制单次等待时间 - if waitDuration > antigravitySingleAccountSmartRetryMaxWait { - waitDuration = antigravitySingleAccountSmartRetryMaxWait - } - if waitDuration < antigravitySmartRetryMinWait { - waitDuration = antigravitySmartRetryMinWait - } - - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d single_account_503_retry_in_place model=%s account=%d upstream_retry_delay=%v (retrying in-place instead of rate-limiting)", - p.prefix, resp.StatusCode, modelName, p.account.ID, waitDuration) - - var lastRetryResp *http.Response - var lastRetryBody []byte - totalWaited := time.Duration(0) - - for attempt := 1; attempt <= antigravitySingleAccountSmartRetryMaxAttempts; attempt++ { - // 检查累计等待是否超限 - if totalWaited+waitDuration > antigravitySingleAccountSmartRetryTotalMaxWait { - remaining := antigravitySingleAccountSmartRetryTotalMaxWait - totalWaited - if remaining <= 0 { - logger.LegacyPrintf("service.antigravity_gateway", "%s single_account_503_retry: total_wait_exceeded total=%v max=%v, giving up", - p.prefix, totalWaited, antigravitySingleAccountSmartRetryTotalMaxWait) - break - } - waitDuration = remaining - } - - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d single_account_503_retry attempt=%d/%d delay=%v total_waited=%v model=%s account=%d", - p.prefix, resp.StatusCode, attempt, antigravitySingleAccountSmartRetryMaxAttempts, waitDuration, totalWaited, modelName, p.account.ID) - - timer := time.NewTimer(waitDuration) - select { - case <-p.ctx.Done(): - timer.Stop() - logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_single_account_retry", p.prefix) - return &smartRetryResult{action: smartRetryActionBreakWithResp, err: p.ctx.Err()} - case <-timer.C: - } - totalWaited += waitDuration - - // 创建新请求 - retryReq, err := antigravity.NewAPIRequestWithURL(p.ctx, baseURL, p.action, p.accessToken, p.body) - if err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "%s single_account_503_retry: request_build_failed error=%v", p.prefix, err) - break - } - - retryResp, retryErr := p.httpUpstream.Do(retryReq, p.proxyURL, p.account.ID, p.account.Concurrency) - if retryErr == nil && retryResp != nil && retryResp.StatusCode != http.StatusTooManyRequests && retryResp.StatusCode != http.StatusServiceUnavailable { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d single_account_503_retry_success attempt=%d/%d total_waited=%v", - p.prefix, retryResp.StatusCode, attempt, antigravitySingleAccountSmartRetryMaxAttempts, totalWaited) - // 关闭之前的响应 - if lastRetryResp != nil { - _ = lastRetryResp.Body.Close() - } - return &smartRetryResult{action: smartRetryActionBreakWithResp, resp: retryResp} - } - - // 网络错误时继续重试 - if retryErr != nil || retryResp == nil { - logger.LegacyPrintf("service.antigravity_gateway", "%s single_account_503_retry: network_error attempt=%d/%d error=%v", - p.prefix, attempt, antigravitySingleAccountSmartRetryMaxAttempts, retryErr) - continue - } - - // 关闭之前的响应 - if lastRetryResp != nil { - _ = lastRetryResp.Body.Close() - } - lastRetryResp = retryResp - lastRetryBody, _ = io.ReadAll(io.LimitReader(retryResp.Body, 8<<10)) - _ = retryResp.Body.Close() - - // 解析新的重试信息,更新下次等待时间 - if attempt < antigravitySingleAccountSmartRetryMaxAttempts && lastRetryBody != nil { - _, _, newWaitDuration, _, _ := shouldTriggerAntigravitySmartRetry(p.account, lastRetryBody) - if newWaitDuration > 0 { - waitDuration = newWaitDuration - if waitDuration > antigravitySingleAccountSmartRetryMaxWait { - waitDuration = antigravitySingleAccountSmartRetryMaxWait - } - if waitDuration < antigravitySmartRetryMinWait { - waitDuration = antigravitySmartRetryMinWait - } - } - } - } - - // 所有重试都失败,不设限流,直接返回 503 - // Handler 层的单账号退避循环会做最终处理 - retryBody := lastRetryBody - if retryBody == nil { - retryBody = respBody - } - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d single_account_503_retry_exhausted attempts=%d total_waited=%v model=%s account=%d body=%s (return 503 directly)", - p.prefix, resp.StatusCode, antigravitySingleAccountSmartRetryMaxAttempts, totalWaited, modelName, p.account.ID, truncateForLog(retryBody, 200)) - - return &smartRetryResult{ - action: smartRetryActionBreakWithResp, - resp: &http.Response{ - StatusCode: resp.StatusCode, - Header: resp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(retryBody)), - }, - } -} - -// antigravityRetryLoop 执行带 URL fallback 的重试循环 -func (s *AntigravityGatewayService) antigravityRetryLoop(p antigravityRetryLoopParams) (*antigravityRetryLoopResult, error) { - // 预检查:模型限流 + overages 启用 + 积分未耗尽 → 直接注入 AI Credits - overagesInjected := false - if p.requestedModel != "" && p.account.Platform == PlatformAntigravity && - p.account.IsOveragesEnabled() && !p.account.isCreditsExhausted() && - p.account.isModelRateLimitedWithContext(p.ctx, p.requestedModel) { - if creditsBody := injectEnabledCreditTypes(p.body); creditsBody != nil { - p.body = creditsBody - overagesInjected = true - logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: model_rate_limited_credits_inject model=%s account=%d (injecting enabledCreditTypes)", - p.prefix, p.requestedModel, p.account.ID) - } - } - - // 预检查:如果账号已限流,直接返回切换信号 - if p.requestedModel != "" { - if remaining := p.account.GetRateLimitRemainingTimeWithContext(p.ctx, p.requestedModel); remaining > 0 { - // 已注入积分的请求不再受普通模型限流预检查阻断。 - if overagesInjected { - logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: credits_injected_ignore_rate_limit remaining=%v model=%s account=%d", - p.prefix, remaining.Truncate(time.Millisecond), p.requestedModel, p.account.ID) - } else if isSingleAccountRetry(p.ctx) { - // 单账号 503 退避重试模式:跳过限流预检查,直接发请求。 - // 首次请求设的限流是为了多账号调度器跳过该账号,在单账号模式下无意义。 - // 如果上游确实还不可用,handleSmartRetry → handleSingleAccountRetryInPlace - // 会在 Service 层原地等待+重试,不需要在预检查这里等。 - logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: single_account_retry skipping rate_limit remaining=%v model=%s account=%d (will retry in-place if 503)", - p.prefix, remaining.Truncate(time.Millisecond), p.requestedModel, p.account.ID) - } else { - logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: rate_limit_switch remaining=%v model=%s account=%d", - p.prefix, remaining.Truncate(time.Millisecond), p.requestedModel, p.account.ID) - return nil, &AntigravityAccountSwitchError{ - OriginalAccountID: p.account.ID, - RateLimitedModel: p.requestedModel, - IsStickySession: p.isStickySession, - } - } - } - } - - baseURL := resolveAntigravityForwardBaseURL() - if baseURL == "" { - return nil, errors.New("no antigravity forward base url configured") - } - availableURLs := []string{baseURL} - - var resp *http.Response - var usedBaseURL string - logBody := p.settingService != nil && p.settingService.cfg != nil && p.settingService.cfg.Gateway.LogUpstreamErrorBody - maxBytes := 2048 - if p.settingService != nil && p.settingService.cfg != nil && p.settingService.cfg.Gateway.LogUpstreamErrorBodyMaxBytes > 0 { - maxBytes = p.settingService.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - } - getUpstreamDetail := func(body []byte) string { - if !logBody { - return "" - } - return truncateString(string(body), maxBytes) - } - -urlFallbackLoop: - for urlIdx, baseURL := range availableURLs { - usedBaseURL = baseURL - allAttemptsInternal500 := true // 追踪本轮所有 attempt 是否全部命中 INTERNAL 500 - for attempt := 1; attempt <= antigravityMaxRetries; attempt++ { - select { - case <-p.ctx.Done(): - logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled error=%v", p.prefix, p.ctx.Err()) - return nil, p.ctx.Err() - default: - } - - upstreamReq, err := antigravity.NewAPIRequestWithURL(p.ctx, baseURL, p.action, p.accessToken, p.body) - if err != nil { - return nil, err - } - - resp, err = p.httpUpstream.Do(upstreamReq, p.proxyURL, p.account.ID, p.account.Concurrency) - if err == nil && resp == nil { - err = errors.New("upstream returned nil response") - } - if err != nil { - safeErr := sanitizeUpstreamErrorMessage(err.Error()) - appendOpsUpstreamError(p.c, OpsUpstreamErrorEvent{ - Platform: p.account.Platform, - AccountID: p.account.ID, - AccountName: p.account.Name, - UpstreamStatusCode: 0, - UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), - Kind: "request_error", - Message: safeErr, - }) - if shouldAntigravityFallbackToNextURL(err, 0) && urlIdx < len(availableURLs)-1 { - logger.LegacyPrintf("service.antigravity_gateway", "%s URL fallback (connection error): %s -> %s", p.prefix, baseURL, availableURLs[urlIdx+1]) - continue urlFallbackLoop - } - if attempt < antigravityMaxRetries { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=request_failed retry=%d/%d error=%v", p.prefix, attempt, antigravityMaxRetries, err) - if !sleepAntigravityBackoffWithContext(p.ctx, attempt) { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_backoff", p.prefix) - return nil, p.ctx.Err() - } - continue - } - logger.LegacyPrintf("service.antigravity_gateway", "%s status=request_failed retries_exhausted error=%v", p.prefix, err) - setOpsUpstreamError(p.c, 0, safeErr, "") - return nil, fmt.Errorf("upstream request failed after retries: %w", err) - } - - // 统一处理错误响应 - if resp.StatusCode >= 400 { - respBody := s.readUpstreamErrorBody(resp) - _ = resp.Body.Close() - - if overagesInjected && shouldMarkCreditsExhausted(resp, respBody, nil) { - modelKey := resolveCreditsOveragesModelKey(p.ctx, p.account, "", p.requestedModel) - s.handleCreditsRetryFailure(p.ctx, p.prefix, modelKey, p.account, &http.Response{ - StatusCode: resp.StatusCode, - Header: resp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(respBody)), - }, nil) - } - - // ★ 统一入口:自定义错误码 + 临时不可调度 - if handled, outStatus, policyErr := s.applyErrorPolicy(p, resp.StatusCode, resp.Header, respBody); handled { - if policyErr != nil { - return nil, policyErr - } - resp = &http.Response{ - StatusCode: outStatus, - Header: resp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(respBody)), - } - break urlFallbackLoop - } - - // 429/503 限流处理:区分 URL 级别限流、智能重试和账户配额限流 - if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable { - // 尝试智能重试处理(OAuth 账号专用) - smartResult := s.handleSmartRetry(p, resp, respBody, baseURL, urlIdx, availableURLs) - switch smartResult.action { - case smartRetryActionContinueURL: - continue urlFallbackLoop - case smartRetryActionBreakWithResp: - if smartResult.err != nil { - return nil, smartResult.err - } - // 模型限流时返回切换账号信号 - if smartResult.switchError != nil { - return nil, smartResult.switchError - } - resp = smartResult.resp - break urlFallbackLoop - } - // smartRetryActionContinue: 继续默认重试逻辑 - - // 账户/模型配额限流,重试 3 次(指数退避)- 默认逻辑(非 OAuth 账号或解析失败) - if attempt < antigravityMaxRetries { - upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - appendOpsUpstreamError(p.c, OpsUpstreamErrorEvent{ - Platform: p.account.Platform, - AccountID: p.account.ID, - AccountName: p.account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), - Kind: "retry", - Message: upstreamMsg, - Detail: getUpstreamDetail(respBody), - }) - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d retry=%d/%d body=%s", p.prefix, resp.StatusCode, attempt, antigravityMaxRetries, truncateForLog(respBody, 200)) - if !sleepAntigravityBackoffWithContext(p.ctx, attempt) { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_backoff", p.prefix) - return nil, p.ctx.Err() - } - continue - } - - // 重试用尽,标记账户限流 - p.handleError(p.ctx, p.prefix, p.account, resp.StatusCode, resp.Header, respBody, p.requestedModel, p.groupID, p.sessionHash, p.isStickySession) - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d rate_limited base_url=%s body=%s", p.prefix, resp.StatusCode, baseURL, truncateForLog(respBody, 200)) - resp = &http.Response{ - StatusCode: resp.StatusCode, - Header: resp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(respBody)), - } - break urlFallbackLoop - } - - // 其他可重试错误(500/502/504/529,不包括 429 和 503) - if shouldRetryAntigravityError(resp.StatusCode) { - if attempt < antigravityMaxRetries { - upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - appendOpsUpstreamError(p.c, OpsUpstreamErrorEvent{ - Platform: p.account.Platform, - AccountID: p.account.ID, - AccountName: p.account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), - Kind: "retry", - Message: upstreamMsg, - Detail: getUpstreamDetail(respBody), - }) - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d retry=%d/%d body=%s", p.prefix, resp.StatusCode, attempt, antigravityMaxRetries, truncateForLog(respBody, 500)) - if !sleepAntigravityBackoffWithContext(p.ctx, attempt) { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_backoff", p.prefix) - return nil, p.ctx.Err() - } - // 追踪 INTERNAL 500:非匹配的 attempt 清除标记 - if !isAntigravityInternalServerError(resp.StatusCode, respBody) { - allAttemptsInternal500 = false - } - continue - } - } - - // INTERNAL 500 渐进惩罚:3 次重试全部命中特定 500 时递增计数器并惩罚 - if allAttemptsInternal500 && isAntigravityInternalServerError(resp.StatusCode, respBody) { - s.handleInternal500RetryExhausted(p.ctx, p.prefix, p.account) - } - - // 其他 4xx 错误或重试用尽,直接返回 - resp = &http.Response{ - StatusCode: resp.StatusCode, - Header: resp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(respBody)), - } - break urlFallbackLoop - } - - // 成功响应(< 400) - break urlFallbackLoop - } - } - - if resp != nil && resp.StatusCode < 400 && usedBaseURL != "" { - antigravity.DefaultURLAvailability.MarkSuccess(usedBaseURL) - } - - // 成功响应时清零 INTERNAL 500 连续失败计数器(覆盖所有成功路径,含 smart retry) - if resp != nil && resp.StatusCode < 400 { - s.resetInternal500Counter(p.ctx, p.prefix, p.account.ID) - } - - return &antigravityRetryLoopResult{resp: resp}, nil -} - -// shouldRetryAntigravityError 判断是否应该重试 -func shouldRetryAntigravityError(statusCode int) bool { - switch statusCode { - case 429, 500, 502, 503, 504, 529: - return true - default: - return false - } -} - -// isURLLevelRateLimit 判断是否为 URL 级别的限流(应切换 URL 重试) -// "Resource has been exhausted" 是 URL/节点级别限流,切换 URL 可能成功 -// "exhausted your capacity on this model" 是账户/模型配额限流,切换 URL 无效 -func isURLLevelRateLimit(body []byte) bool { - // 快速检查:包含 "Resource has been exhausted" 且不包含 "capacity on this model" - bodyStr := string(body) - return strings.Contains(bodyStr, "Resource has been exhausted") && - !strings.Contains(bodyStr, "capacity on this model") -} - -// isAntigravityConnectionError 判断是否为连接错误(网络超时、DNS 失败、连接拒绝) -func isAntigravityConnectionError(err error) bool { - if err == nil { - return false - } - - // 检查超时错误 - var netErr net.Error - if errors.As(err, &netErr) && netErr.Timeout() { - return true - } - - // 检查连接错误(DNS 失败、连接拒绝) - var opErr *net.OpError - return errors.As(err, &opErr) -} - -// shouldAntigravityFallbackToNextURL 判断是否应切换到下一个 URL -// 仅连接错误和 HTTP 429 触发 URL 降级 -func shouldAntigravityFallbackToNextURL(err error, statusCode int) bool { - if isAntigravityConnectionError(err) { - return true - } - return statusCode == http.StatusTooManyRequests -} - -// getSessionID 从 gin.Context 获取 session_id(用于日志追踪) -func getSessionID(c *gin.Context) string { - if c == nil { - return "" - } - return c.GetHeader("session_id") -} - -// logPrefix 生成统一的日志前缀 -func logPrefix(sessionID, accountName string) string { - if sessionID != "" { - return fmt.Sprintf("[antigravity-Forward] session=%s account=%s", sessionID, accountName) - } - return fmt.Sprintf("[antigravity-Forward] account=%s", accountName) -} - // AntigravityGatewayService 处理 Antigravity 平台的 API 转发 type AntigravityGatewayService struct { accountRepo AccountRepository @@ -1384,3281 +637,3 @@ func (s *AntigravityGatewayService) unwrapV1InternalResponse(body []byte) ([]byt } return body, nil } - -// Forward 转发 Claude 协议请求(Claude → Gemini 转换) -// -// 限流处理流程: -// -// 请求 → antigravityRetryLoop → 预检查(remaining>0? → 切换账号) → 发送上游 -// ├─ 成功 → 正常返回 -// └─ 429/503 → handleSmartRetry -// ├─ retryDelay >= 7s → 设置模型限流 + 清除粘性绑定 → 切换账号 -// └─ retryDelay < 7s → 等待后重试 1 次 -// ├─ 成功 → 正常返回 -// └─ 失败 → 设置模型限流 + 清除粘性绑定 → 切换账号 -func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte, isStickySession bool) (*ForwardResult, error) { - // 上游透传账号直接转发,不走 OAuth token 刷新 - if account.Type == AccountTypeUpstream { - return s.ForwardUpstream(ctx, c, account, body) - } - - startTime := time.Now() - - sessionID := getSessionID(c) - prefix := logPrefix(sessionID, account.Name) - - // 解析 Claude 请求 - var claudeReq antigravity.ClaudeRequest - if err := json.Unmarshal(body, &claudeReq); err != nil { - return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Invalid request body") - } - if strings.TrimSpace(claudeReq.Model) == "" { - return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Missing model") - } - - originalModel := claudeReq.Model - mappedModel := s.getMappedModel(account, claudeReq.Model) - if mappedModel == "" { - MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) - return nil, s.writeClaudeError(c, http.StatusForbidden, "permission_error", fmt.Sprintf("model %s not in whitelist", claudeReq.Model)) - } - // 应用 thinking 模式自动后缀:如果 thinking 开启且目标是 claude-sonnet-4-5,自动改为 thinking 版本 - thinkingEnabled := claudeReq.Thinking != nil && (claudeReq.Thinking.Type == "enabled" || claudeReq.Thinking.Type == "adaptive") - mappedModel = applyThinkingModelSuffix(mappedModel, thinkingEnabled) - billingModel := mappedModel - - // 获取 access_token - if s.tokenProvider == nil { - return nil, s.writeClaudeError(c, http.StatusBadGateway, "api_error", "Antigravity token provider not configured") - } - accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) - if err != nil { - return nil, &UpstreamFailoverError{ - StatusCode: http.StatusBadGateway, - ResponseBody: []byte(`{"error":{"type":"authentication_error","message":"Failed to get upstream access token"},"type":"error"}`), - } - } - - projectID, err := resolveAntigravityProjectID(account) - if err != nil { - _ = s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) - return nil, err - } - - // 代理 URL - proxyURL := "" - if account.ProxyID != nil && account.Proxy != nil { - proxyURL = account.Proxy.URL() - } - - // 获取转换选项 - // Antigravity 上游要求必须包含身份提示词,否则会返回 429 - transformOpts := s.getClaudeTransformOptions(ctx) - transformOpts.EnableIdentityPatch = true // 强制启用,Antigravity 上游必需 - - // 转换 Claude 请求为 Gemini 格式 - geminiBody, err := antigravity.TransformClaudeToGeminiWithOptions(&claudeReq, projectID, mappedModel, transformOpts) - if err != nil { - return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Invalid request") - } - - // Antigravity 上游只支持流式请求,统一使用 streamGenerateContent - // 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后转换返回 - action := "streamGenerateContent" - - // 执行带重试的请求 - result, err := s.antigravityRetryLoop(antigravityRetryLoopParams{ - ctx: ctx, - prefix: prefix, - account: account, - proxyURL: proxyURL, - accessToken: accessToken, - action: action, - body: geminiBody, - c: c, - httpUpstream: s.httpUpstream, - settingService: s.settingService, - accountRepo: s.accountRepo, - handleError: s.handleUpstreamError, - requestedModel: originalModel, - isStickySession: isStickySession, // Forward 由上层判断粘性会话 - groupID: 0, // Forward 方法没有 groupID,由上层处理粘性会话清除 - sessionHash: "", // Forward 方法没有 sessionHash,由上层处理粘性会话清除 - }) - if err != nil { - // 检查是否是账号切换信号,转换为 UpstreamFailoverError 让 Handler 切换账号 - if switchErr, ok := IsAntigravityAccountSwitchError(err); ok { - return nil, &UpstreamFailoverError{ - StatusCode: http.StatusServiceUnavailable, - ForceCacheBilling: switchErr.IsStickySession, - } - } - // 区分客户端取消和真正的上游失败,返回更准确的错误消息 - if c.Request.Context().Err() != nil { - return nil, s.writeClaudeError(c, http.StatusBadGateway, "client_disconnected", "Client disconnected before upstream response") - } - return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries") - } - resp := result.resp - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode >= 400 { - respBody := s.readUpstreamErrorBody(resp) - - // 优先检测 thinking block 的 signature 相关错误(400)并重试一次: - // Antigravity /v1internal 链路在部分场景会对 thought/thinking signature 做严格校验, - // 当历史消息携带的 signature 不合法时会直接 400;去除 thinking 后可继续完成请求。 - if resp.StatusCode == http.StatusBadRequest && isSignatureRelatedError(respBody) && s.settingService.IsSignatureRectifierEnabled(ctx) { - upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - logBody, maxBytes := s.getLogConfig() - upstreamDetail := s.getUpstreamErrorDetail(respBody) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "signature_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - - // Conservative two-stage fallback: - // 1) Disable top-level thinking + thinking->text - // 2) Only if still signature-related 400: also downgrade tool_use/tool_result to text. - - retryStages := []struct { - name string - strip func(*antigravity.ClaudeRequest) (bool, error) - }{ - {name: "thinking-only", strip: stripThinkingFromClaudeRequest}, - {name: "thinking+tools", strip: stripSignatureSensitiveBlocksFromClaudeRequest}, - } - - for _, stage := range retryStages { - retryClaudeReq := claudeReq - retryClaudeReq.Messages = append([]antigravity.ClaudeMessage(nil), claudeReq.Messages...) - - stripped, stripErr := stage.strip(&retryClaudeReq) - if stripErr != nil || !stripped { - continue - } - - logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: detected signature-related 400, retrying once (%s)", account.ID, stage.name) - - retryGeminiBody, txErr := antigravity.TransformClaudeToGeminiWithOptions(&retryClaudeReq, projectID, mappedModel, s.getClaudeTransformOptions(ctx)) - if txErr != nil { - continue - } - retryResult, retryErr := s.antigravityRetryLoop(antigravityRetryLoopParams{ - ctx: ctx, - prefix: prefix, - account: account, - proxyURL: proxyURL, - accessToken: accessToken, - action: action, - body: retryGeminiBody, - c: c, - httpUpstream: s.httpUpstream, - settingService: s.settingService, - accountRepo: s.accountRepo, - handleError: s.handleUpstreamError, - requestedModel: originalModel, - isStickySession: isStickySession, - groupID: 0, // Forward 方法没有 groupID,由上层处理粘性会话清除 - sessionHash: "", // Forward 方法没有 sessionHash,由上层处理粘性会话清除 - }) - if retryErr != nil { - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - Kind: "signature_retry_request_error", - Message: sanitizeUpstreamErrorMessage(retryErr.Error()), - }) - logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: signature retry request failed (%s): %v", account.ID, stage.name, retryErr) - continue - } - - retryResp := retryResult.resp - if retryResp.StatusCode < 400 { - _ = resp.Body.Close() - resp = retryResp - respBody = nil - break - } - - retryBody, _ := io.ReadAll(io.LimitReader(retryResp.Body, 8<<10)) - _ = retryResp.Body.Close() - if retryResp.StatusCode == http.StatusTooManyRequests { - retryBaseURL := "" - if retryResp.Request != nil && retryResp.Request.URL != nil { - retryBaseURL = retryResp.Request.URL.Scheme + "://" + retryResp.Request.URL.Host - } - logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 rate_limited base_url=%s retry_stage=%s body=%s", prefix, retryBaseURL, stage.name, truncateForLog(retryBody, 200)) - } - kind := "signature_retry" - if strings.TrimSpace(stage.name) != "" { - kind = "signature_retry_" + strings.ReplaceAll(stage.name, "+", "_") - } - retryUpstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(retryBody)) - retryUpstreamMsg = sanitizeUpstreamErrorMessage(retryUpstreamMsg) - retryUpstreamDetail := "" - if logBody { - retryUpstreamDetail = truncateString(string(retryBody), maxBytes) - } - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: retryResp.StatusCode, - UpstreamRequestID: retryResp.Header.Get("x-request-id"), - Kind: kind, - Message: retryUpstreamMsg, - Detail: retryUpstreamDetail, - }) - - // If this stage fixed the signature issue, we stop; otherwise we may try the next stage. - if retryResp.StatusCode != http.StatusBadRequest || !isSignatureRelatedError(retryBody) { - respBody = retryBody - resp = &http.Response{ - StatusCode: retryResp.StatusCode, - Header: retryResp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(retryBody)), - } - break - } - - // Still signature-related; capture context and allow next stage. - respBody = retryBody - resp = &http.Response{ - StatusCode: retryResp.StatusCode, - Header: retryResp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(retryBody)), - } - } - } - - // Budget 整流:检测 budget_tokens 约束错误并自动修正重试 - if resp.StatusCode == http.StatusBadRequest && respBody != nil && !isSignatureRelatedError(respBody) { - errMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody)) - if isThinkingBudgetConstraintError(errMsg) && s.settingService.IsBudgetRectifierEnabled(ctx) { - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "budget_constraint_error", - Message: errMsg, - Detail: s.getUpstreamErrorDetail(respBody), - }) - - // 修正 claudeReq 的 thinking 参数(adaptive 模式不修正) - if claudeReq.Thinking == nil || claudeReq.Thinking.Type != "adaptive" { - retryClaudeReq := claudeReq - retryClaudeReq.Messages = append([]antigravity.ClaudeMessage(nil), claudeReq.Messages...) - // 创建新的 ThinkingConfig 避免修改原始 claudeReq.Thinking 指针 - retryClaudeReq.Thinking = &antigravity.ThinkingConfig{ - Type: "enabled", - BudgetTokens: BudgetRectifyBudgetTokens, - } - if retryClaudeReq.MaxTokens < BudgetRectifyMinMaxTokens { - retryClaudeReq.MaxTokens = BudgetRectifyMaxTokens - } - - logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: detected budget_tokens constraint error, retrying with rectified budget (budget_tokens=%d, max_tokens=%d)", account.ID, BudgetRectifyBudgetTokens, BudgetRectifyMaxTokens) - - retryGeminiBody, txErr := antigravity.TransformClaudeToGeminiWithOptions(&retryClaudeReq, projectID, mappedModel, transformOpts) - if txErr == nil { - retryResult, retryErr := s.antigravityRetryLoop(antigravityRetryLoopParams{ - ctx: ctx, - prefix: prefix, - account: account, - proxyURL: proxyURL, - accessToken: accessToken, - action: action, - body: retryGeminiBody, - c: c, - httpUpstream: s.httpUpstream, - settingService: s.settingService, - accountRepo: s.accountRepo, - handleError: s.handleUpstreamError, - requestedModel: originalModel, - isStickySession: isStickySession, - groupID: 0, - sessionHash: "", - }) - if retryErr == nil { - retryResp := retryResult.resp - if retryResp.StatusCode < 400 { - _ = resp.Body.Close() - resp = retryResp - respBody = nil - } else { - retryBody := s.readUpstreamErrorBody(retryResp) - _ = retryResp.Body.Close() - respBody = retryBody - resp = &http.Response{ - StatusCode: retryResp.StatusCode, - Header: retryResp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(retryBody)), - } - } - } else { - logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: budget rectifier retry failed: %v", account.ID, retryErr) - } - } - } - } - } - - // 处理错误响应(重试后仍失败或不触发重试) - if resp.StatusCode >= 400 { - // 检测 prompt too long 错误,返回特殊错误类型供上层 fallback - if resp.StatusCode == http.StatusBadRequest && isPromptTooLongError(respBody) { - upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - upstreamDetail := s.getUpstreamErrorDetail(respBody) - logBody, maxBytes := s.getLogConfig() - if logBody { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=400 prompt_too_long=true upstream_message=%q request_id=%s body=%s", prefix, upstreamMsg, resp.Header.Get("x-request-id"), truncateForLog(respBody, maxBytes)) - } - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "prompt_too_long", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - return nil, &PromptTooLongError{ - StatusCode: resp.StatusCode, - RequestID: resp.Header.Get("x-request-id"), - Body: respBody, - } - } - - s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, 0, "", isStickySession) - - // 精确匹配服务端配置类 400 错误,触发同账号重试 + failover - if resp.StatusCode == http.StatusBadRequest { - msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody))) - if isGoogleProjectConfigError(msg) { - upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(respBody))) - upstreamDetail := s.getUpstreamErrorDetail(respBody) - log.Printf("%s status=400 google_config_error failover=true upstream_message=%q account=%d", prefix, upstreamMsg, account.ID) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "failover", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: true} - } - } - - if s.shouldFailoverUpstreamError(resp.StatusCode) { - upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - upstreamDetail := s.getUpstreamErrorDetail(respBody) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "failover", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody} - } - - return nil, s.writeMappedClaudeError(c, account, resp.StatusCode, resp.Header.Get("x-request-id"), respBody) - } - } - - requestID := resp.Header.Get("x-request-id") - if requestID != "" { - c.Header("x-request-id", requestID) - } - - var usage *ClaudeUsage - var firstTokenMs *int - var clientDisconnect bool - if claudeReq.Stream { - // 客户端要求流式,直接透传转换 - streamRes, err := s.handleClaudeStreamingResponse(c, resp, startTime, originalModel) - if err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_error error=%v", prefix, err) - return nil, err - } - usage = streamRes.usage - firstTokenMs = streamRes.firstTokenMs - clientDisconnect = streamRes.clientDisconnect - } else { - // 客户端要求非流式,收集流式响应后转换返回 - streamRes, err := s.handleClaudeStreamToNonStreaming(c, resp, startTime, originalModel) - if err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_collect_error error=%v", prefix, err) - return nil, err - } - usage = streamRes.usage - firstTokenMs = streamRes.firstTokenMs - } - - return &ForwardResult{ - RequestID: requestID, - Usage: *usage, - Model: originalModel, - UpstreamModel: billingModel, - Stream: claudeReq.Stream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, - }, nil -} - -func isSignatureRelatedError(respBody []byte) bool { - msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody))) - if msg == "" { - // Fallback: best-effort scan of the raw payload. - msg = strings.ToLower(string(respBody)) - } - - // Keep this intentionally broad: different upstreams may use "signature" or "thought_signature". - if strings.Contains(msg, "thought_signature") || strings.Contains(msg, "signature") { - return true - } - - // Also detect thinking block structural errors: - // "Expected `thinking` or `redacted_thinking`, but found `text`" - if strings.Contains(msg, "expected") && (strings.Contains(msg, "thinking") || strings.Contains(msg, "redacted_thinking")) { - return true - } - - return false -} - -// isPromptTooLongError 检测是否为 prompt too long 错误 -func isPromptTooLongError(respBody []byte) bool { - msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody))) - if msg == "" { - msg = strings.ToLower(string(respBody)) - } - return strings.Contains(msg, "prompt is too long") || - strings.Contains(msg, "request is too long") || - strings.Contains(msg, "context length exceeded") || - strings.Contains(msg, "max_tokens") -} - -// isPassthroughErrorMessage 检查错误消息是否在透传白名单中 -func isPassthroughErrorMessage(msg string) bool { - lower := strings.ToLower(msg) - for _, pattern := range antigravityPassthroughErrorMessages { - if strings.Contains(lower, pattern) { - return true - } - } - return false -} - -// getPassthroughOrDefault 若消息在白名单内则返回原始消息,否则返回默认消息 -func getPassthroughOrDefault(upstreamMsg, defaultMsg string) string { - if isPassthroughErrorMessage(upstreamMsg) { - return upstreamMsg - } - return defaultMsg -} - -func extractAntigravityErrorMessage(body []byte) string { - var payload map[string]any - if err := json.Unmarshal(body, &payload); err != nil { - return "" - } - - // Google-style: {"error": {"message": "..."}} - if errObj, ok := payload["error"].(map[string]any); ok { - if msg, ok := errObj["message"].(string); ok && strings.TrimSpace(msg) != "" { - return msg - } - } - - // Fallback: top-level message - if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" { - return msg - } - - return "" -} - -// stripThinkingFromClaudeRequest converts thinking blocks to text blocks in a Claude Messages request. -// This preserves the thinking content while avoiding signature validation errors. -// Note: redacted_thinking blocks are removed because they cannot be converted to text. -// It also disables top-level `thinking` to avoid upstream structural constraints for thinking mode. -func stripThinkingFromClaudeRequest(req *antigravity.ClaudeRequest) (bool, error) { - if req == nil { - return false, nil - } - - changed := false - if req.Thinking != nil { - req.Thinking = nil - changed = true - } - - for i := range req.Messages { - raw := req.Messages[i].Content - if len(raw) == 0 { - continue - } - - // If content is a string, nothing to strip. - var str string - if json.Unmarshal(raw, &str) == nil { - continue - } - - // Otherwise treat as an array of blocks and convert thinking blocks to text. - var blocks []map[string]any - if err := json.Unmarshal(raw, &blocks); err != nil { - continue - } - - filtered := make([]map[string]any, 0, len(blocks)) - modifiedAny := false - for _, block := range blocks { - t, _ := block["type"].(string) - switch t { - case "thinking": - thinkingText, _ := block["thinking"].(string) - if thinkingText != "" { - filtered = append(filtered, map[string]any{ - "type": "text", - "text": thinkingText, - }) - } - modifiedAny = true - case "redacted_thinking": - modifiedAny = true - case "": - if thinkingText, hasThinking := block["thinking"].(string); hasThinking { - if thinkingText != "" { - filtered = append(filtered, map[string]any{ - "type": "text", - "text": thinkingText, - }) - } - modifiedAny = true - } else { - filtered = append(filtered, block) - } - default: - filtered = append(filtered, block) - } - } - - if !modifiedAny { - continue - } - - if len(filtered) == 0 { - filtered = append(filtered, map[string]any{ - "type": "text", - "text": "(content removed)", - }) - } - - newRaw, err := json.Marshal(filtered) - if err != nil { - return changed, err - } - req.Messages[i].Content = newRaw - changed = true - } - - return changed, nil -} - -// stripSignatureSensitiveBlocksFromClaudeRequest is a stronger retry degradation that additionally converts -// tool blocks to plain text. Use this only after a thinking-only retry still fails with signature errors. -func stripSignatureSensitiveBlocksFromClaudeRequest(req *antigravity.ClaudeRequest) (bool, error) { - if req == nil { - return false, nil - } - - changed := false - if req.Thinking != nil { - req.Thinking = nil - changed = true - } - - for i := range req.Messages { - raw := req.Messages[i].Content - if len(raw) == 0 { - continue - } - - // If content is a string, nothing to strip. - var str string - if json.Unmarshal(raw, &str) == nil { - continue - } - - // Otherwise treat as an array of blocks and convert signature-sensitive blocks to text. - var blocks []map[string]any - if err := json.Unmarshal(raw, &blocks); err != nil { - continue - } - - filtered := make([]map[string]any, 0, len(blocks)) - modifiedAny := false - for _, block := range blocks { - t, _ := block["type"].(string) - switch t { - case "thinking": - // Convert thinking to text, skip if empty - thinkingText, _ := block["thinking"].(string) - if thinkingText != "" { - filtered = append(filtered, map[string]any{ - "type": "text", - "text": thinkingText, - }) - } - modifiedAny = true - case "redacted_thinking": - // Remove redacted_thinking (cannot convert encrypted content) - modifiedAny = true - case "tool_use": - // Convert tool_use to text to avoid upstream signature/thought_signature validation errors. - // This is a retry-only degradation path, so we prioritise request validity over tool semantics. - name, _ := block["name"].(string) - id, _ := block["id"].(string) - input := block["input"] - inputJSON, _ := json.Marshal(input) - text := "(tool_use)" - if name != "" { - text += " name=" + name - } - if id != "" { - text += " id=" + id - } - if len(inputJSON) > 0 && string(inputJSON) != "null" { - text += " input=" + string(inputJSON) - } - filtered = append(filtered, map[string]any{ - "type": "text", - "text": text, - }) - modifiedAny = true - case "tool_result": - // Convert tool_result to text so it stays consistent when tool_use is downgraded. - toolUseID, _ := block["tool_use_id"].(string) - isError, _ := block["is_error"].(bool) - content := block["content"] - contentJSON, _ := json.Marshal(content) - text := "(tool_result)" - if toolUseID != "" { - text += " tool_use_id=" + toolUseID - } - if isError { - text += " is_error=true" - } - if len(contentJSON) > 0 && string(contentJSON) != "null" { - text += "\n" + string(contentJSON) - } - filtered = append(filtered, map[string]any{ - "type": "text", - "text": text, - }) - modifiedAny = true - case "": - // Handle untyped block with "thinking" field - if thinkingText, hasThinking := block["thinking"].(string); hasThinking { - if thinkingText != "" { - filtered = append(filtered, map[string]any{ - "type": "text", - "text": thinkingText, - }) - } - modifiedAny = true - } else { - filtered = append(filtered, block) - } - default: - filtered = append(filtered, block) - } - } - - if !modifiedAny { - continue - } - - if len(filtered) == 0 { - // Keep request valid: upstream rejects empty content arrays. - filtered = append(filtered, map[string]any{ - "type": "text", - "text": "(content removed)", - }) - } - - newRaw, err := json.Marshal(filtered) - if err != nil { - return changed, err - } - req.Messages[i].Content = newRaw - changed = true - } - - return changed, nil -} - -// ForwardGemini 转发 Gemini 协议请求 -// -// 限流处理流程: -// -// 请求 → antigravityRetryLoop → 预检查(remaining>0? → 切换账号) → 发送上游 -// ├─ 成功 → 正常返回 -// └─ 429/503 → handleSmartRetry -// ├─ retryDelay >= 7s → 设置模型限流 + 清除粘性绑定 → 切换账号 -// └─ retryDelay < 7s → 等待后重试 1 次 -// ├─ 成功 → 正常返回 -// └─ 失败 → 设置模型限流 + 清除粘性绑定 → 切换账号 -type ForwardGeminiOption func(*forwardGeminiOptions) - -type forwardGeminiOptions struct { - groupID int64 - sessionHash string -} - -func WithForwardGeminiSession(groupID int64, sessionHash string) ForwardGeminiOption { - return func(opts *forwardGeminiOptions) { - opts.groupID = groupID - opts.sessionHash = sessionHash - } -} - -func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte, isStickySession bool, options ...ForwardGeminiOption) (*ForwardResult, error) { - startTime := time.Now() - forwardOpts := forwardGeminiOptions{} - for _, apply := range options { - if apply != nil { - apply(&forwardOpts) - } - } - - sessionID := getSessionID(c) - prefix := logPrefix(sessionID, account.Name) - - if strings.TrimSpace(originalModel) == "" { - return nil, s.writeGoogleError(c, http.StatusBadRequest, "Missing model in URL") - } - if strings.TrimSpace(action) == "" { - return nil, s.writeGoogleError(c, http.StatusBadRequest, "Missing action in URL") - } - if len(body) == 0 { - return nil, s.writeGoogleError(c, http.StatusBadRequest, "Request body is empty") - } - - // 解析请求以获取 image_size(用于图片计费) - imageInputSize := s.extractImageInputSize(body) - imageSize := normalizeOpenAIImageSizeTier(imageInputSize) - - switch action { - case "generateContent", "streamGenerateContent": - // ok - case "countTokens": - // 直接返回空值,不透传上游 - c.JSON(http.StatusOK, map[string]any{"totalTokens": 0}) - return &ForwardResult{ - RequestID: "", - Usage: ClaudeUsage{}, - Model: originalModel, - Stream: false, - Duration: time.Since(startTime), - FirstTokenMs: nil, - }, nil - default: - return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action) - } - - mappedModel := s.getMappedModel(account, originalModel) - if mappedModel == "" { - MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) - return nil, s.writeGoogleError(c, http.StatusForbidden, fmt.Sprintf("model %s not in whitelist", originalModel)) - } - billingModel := mappedModel - - // 获取 access_token - if s.tokenProvider == nil { - return nil, s.writeGoogleError(c, http.StatusBadGateway, "Antigravity token provider not configured") - } - accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) - if err != nil { - return nil, &UpstreamFailoverError{ - StatusCode: http.StatusBadGateway, - ResponseBody: []byte(`{"error":{"message":"Failed to get upstream access token","status":"UNAVAILABLE"}}`), - } - } - - projectID, err := resolveAntigravityProjectID(account) - if err != nil { - _ = s.writeGoogleError(c, http.StatusBadRequest, err.Error()) - return nil, err - } - - // 代理 URL - proxyURL := "" - if account.ProxyID != nil && account.Proxy != nil { - proxyURL = account.Proxy.URL() - } - - // Antigravity 上游要求必须包含身份提示词,注入到请求中 - injectedBody, err := injectIdentityPatchToGeminiRequest(body) - if err != nil { - return nil, s.writeGoogleError(c, http.StatusBadRequest, "Invalid request body") - } - - // 清理 Schema - if cleanedBody, err := cleanGeminiRequest(injectedBody); err == nil { - injectedBody = cleanedBody - logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Cleaned request schema in forwarded request for account %s", account.Name) - } else { - logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Failed to clean schema: %v", err) - } - - // 包装请求 - wrappedBody, err := s.wrapV1InternalRequest(projectID, mappedModel, injectedBody) - if err != nil { - return nil, s.writeGoogleError(c, http.StatusInternalServerError, "Failed to build upstream request") - } - - // Antigravity 上游只支持流式请求,统一使用 streamGenerateContent - // 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后返回 - upstreamAction := "streamGenerateContent" - - // 执行带重试的请求 - result, err := s.antigravityRetryLoop(antigravityRetryLoopParams{ - ctx: ctx, - prefix: prefix, - account: account, - proxyURL: proxyURL, - accessToken: accessToken, - action: upstreamAction, - body: wrappedBody, - c: c, - httpUpstream: s.httpUpstream, - settingService: s.settingService, - accountRepo: s.accountRepo, - handleError: s.handleUpstreamError, - requestedModel: originalModel, - isStickySession: isStickySession, // ForwardGemini 由上层判断粘性会话 - groupID: forwardOpts.groupID, - sessionHash: forwardOpts.sessionHash, - }) - if err != nil { - // 检查是否是账号切换信号,转换为 UpstreamFailoverError 让 Handler 切换账号 - if switchErr, ok := IsAntigravityAccountSwitchError(err); ok { - return nil, &UpstreamFailoverError{ - StatusCode: http.StatusServiceUnavailable, - ForceCacheBilling: switchErr.IsStickySession, - } - } - // 区分客户端取消和真正的上游失败,返回更准确的错误消息 - if c.Request.Context().Err() != nil { - return nil, s.writeGoogleError(c, http.StatusBadGateway, "Client disconnected before upstream response") - } - return nil, s.writeGoogleError(c, http.StatusBadGateway, "Upstream request failed after retries") - } - resp := result.resp - defer func() { - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - }() - - // 处理错误响应 - if resp.StatusCode >= 400 { - respBody := s.readUpstreamErrorBody(resp) - contentType := resp.Header.Get("Content-Type") - // 尽早关闭原始响应体,释放连接;后续逻辑仍可能需要读取 body,因此用内存副本重新包装。 - _ = resp.Body.Close() - resp.Body = io.NopCloser(bytes.NewReader(respBody)) - - // 模型兜底:模型不存在且开启 fallback 时,自动用 fallback 模型重试一次 - if s.settingService != nil && s.settingService.IsModelFallbackEnabled(ctx) && - isModelNotFoundError(resp.StatusCode, respBody) { - fallbackModel := s.settingService.GetFallbackModel(ctx, PlatformAntigravity) - if fallbackModel != "" && fallbackModel != mappedModel { - logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Model not found (%s), retrying with fallback model %s (account: %s)", mappedModel, fallbackModel, account.Name) - - fallbackWrapped, err := s.wrapV1InternalRequest(projectID, fallbackModel, injectedBody) - if err == nil { - fallbackReq, err := antigravity.NewAPIRequest(ctx, upstreamAction, accessToken, fallbackWrapped) - if err == nil { - fallbackResp, err := s.httpUpstream.Do(fallbackReq, proxyURL, account.ID, account.Concurrency) - if err == nil && fallbackResp.StatusCode < 400 { - _ = resp.Body.Close() - resp = fallbackResp - } else if fallbackResp != nil { - _ = fallbackResp.Body.Close() - } - } - } - } - } - - // Gemini 原生请求中的 thoughtSignature 可能来自旧上下文/旧账号,触发上游严格校验后返回 - // "Corrupted thought signature."。检测到此类 400 时,将 thoughtSignature 清理为 dummy 值后重试一次。 - signatureCheckBody := respBody - if unwrapped, unwrapErr := s.unwrapV1InternalResponse(respBody); unwrapErr == nil && len(unwrapped) > 0 { - signatureCheckBody = unwrapped - } - if resp.StatusCode == http.StatusBadRequest && - s.settingService != nil && - s.settingService.IsSignatureRectifierEnabled(ctx) && - isSignatureRelatedError(signatureCheckBody) && - bytes.Contains(injectedBody, []byte(`"thoughtSignature"`)) { - upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(signatureCheckBody))) - upstreamDetail := s.getUpstreamErrorDetail(signatureCheckBody) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "signature_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - - logger.LegacyPrintf("service.antigravity_gateway", "Antigravity Gemini account %d: detected signature-related 400, retrying with cleaned thought signatures", account.ID) - - cleanedInjectedBody := CleanGeminiNativeThoughtSignatures(injectedBody) - retryWrappedBody, wrapErr := s.wrapV1InternalRequest(projectID, mappedModel, cleanedInjectedBody) - if wrapErr == nil { - retryResult, retryErr := s.antigravityRetryLoop(antigravityRetryLoopParams{ - ctx: ctx, - prefix: prefix, - account: account, - proxyURL: proxyURL, - accessToken: accessToken, - action: upstreamAction, - body: retryWrappedBody, - c: c, - httpUpstream: s.httpUpstream, - settingService: s.settingService, - accountRepo: s.accountRepo, - handleError: s.handleUpstreamError, - requestedModel: originalModel, - isStickySession: isStickySession, - groupID: forwardOpts.groupID, - sessionHash: forwardOpts.sessionHash, - }) - if retryErr == nil { - retryResp := retryResult.resp - if retryResp.StatusCode < 400 { - resp = retryResp - } else { - retryRespBody := s.readUpstreamErrorBody(retryResp) - _ = retryResp.Body.Close() - retryOpsBody := retryRespBody - if retryUnwrapped, unwrapErr := s.unwrapV1InternalResponse(retryRespBody); unwrapErr == nil && len(retryUnwrapped) > 0 { - retryOpsBody = retryUnwrapped - } - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: retryResp.StatusCode, - UpstreamRequestID: retryResp.Header.Get("x-request-id"), - Kind: "signature_retry", - Message: sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(retryOpsBody))), - Detail: s.getUpstreamErrorDetail(retryOpsBody), - }) - respBody = retryRespBody - resp = &http.Response{ - StatusCode: retryResp.StatusCode, - Header: retryResp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(retryRespBody)), - } - contentType = resp.Header.Get("Content-Type") - } - } else { - if switchErr, ok := IsAntigravityAccountSwitchError(retryErr); ok { - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: http.StatusServiceUnavailable, - Kind: "failover", - Message: sanitizeUpstreamErrorMessage(retryErr.Error()), - }) - return nil, &UpstreamFailoverError{ - StatusCode: http.StatusServiceUnavailable, - ForceCacheBilling: switchErr.IsStickySession, - } - } - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - Kind: "signature_retry_request_error", - Message: sanitizeUpstreamErrorMessage(retryErr.Error()), - }) - logger.LegacyPrintf("service.antigravity_gateway", "Antigravity Gemini account %d: signature retry request failed: %v", account.ID, retryErr) - } - } else { - logger.LegacyPrintf("service.antigravity_gateway", "Antigravity Gemini account %d: signature retry wrap failed: %v", account.ID, wrapErr) - } - } - - // fallback 成功:继续按正常响应处理 - if resp.StatusCode < 400 { - goto handleSuccess - } - - requestID := resp.Header.Get("x-request-id") - if requestID != "" { - c.Header("x-request-id", requestID) - } - - unwrapped, unwrapErr := s.unwrapV1InternalResponse(respBody) - unwrappedForOps := unwrapped - if unwrapErr != nil || len(unwrappedForOps) == 0 { - unwrappedForOps = respBody - } - s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, forwardOpts.groupID, forwardOpts.sessionHash, isStickySession) - upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(unwrappedForOps)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - upstreamDetail := s.getUpstreamErrorDetail(unwrappedForOps) - - // Always record upstream context for Ops error logs, even when we will failover. - setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) - - // 精确匹配服务端配置类 400 错误,触发同账号重试 + failover - if resp.StatusCode == http.StatusBadRequest && isGoogleProjectConfigError(strings.ToLower(upstreamMsg)) { - log.Printf("%s status=400 google_config_error failover=true upstream_message=%q account=%d", prefix, upstreamMsg, account.ID) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: requestID, - Kind: "failover", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: unwrappedForOps, RetryableOnSameAccount: true} - } - - if s.shouldFailoverUpstreamError(resp.StatusCode) { - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: requestID, - Kind: "failover", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: unwrappedForOps} - } - if contentType == "" { - contentType = "application/json" - } - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: requestID, - Kind: "http_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] upstream error status=%d body=%s", resp.StatusCode, truncateForLog(unwrappedForOps, 500)) - MarkResponseCommitted(c) - c.Data(resp.StatusCode, contentType, unwrappedForOps) - return nil, fmt.Errorf("antigravity upstream error: %d", resp.StatusCode) - } - -handleSuccess: - requestID := resp.Header.Get("x-request-id") - if requestID != "" { - c.Header("x-request-id", requestID) - } - - var usage *ClaudeUsage - var firstTokenMs *int - var clientDisconnect bool - - if stream { - // 客户端要求流式,直接透传 - streamRes, err := s.handleGeminiStreamingResponse(c, resp, startTime) - if err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_error error=%v", prefix, err) - return nil, err - } - usage = streamRes.usage - firstTokenMs = streamRes.firstTokenMs - clientDisconnect = streamRes.clientDisconnect - } else { - // 客户端要求非流式,收集流式响应后返回 - streamRes, err := s.handleGeminiStreamToNonStreaming(c, resp, startTime) - if err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_collect_error error=%v", prefix, err) - return nil, err - } - usage = streamRes.usage - firstTokenMs = streamRes.firstTokenMs - } - - if usage == nil { - usage = &ClaudeUsage{} - } - - // 判断是否为图片生成模型 - imageCount := 0 - if isImageGenerationModel(mappedModel) { - // Gemini 图片生成 API 每次请求只生成一张图片(API 限制) - imageCount = 1 - } - - return &ForwardResult{ - RequestID: requestID, - Usage: *usage, - Model: originalModel, - UpstreamModel: billingModel, - Stream: stream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, - ImageCount: imageCount, - ImageSize: imageSize, - ImageInputSize: imageInputSize, - }, nil -} - -func (s *AntigravityGatewayService) shouldFailoverUpstreamError(statusCode int) bool { - switch statusCode { - case 401, 403, 429, 529: - return true - default: - return statusCode >= 500 - } -} - -// isGoogleProjectConfigError 判断(已提取的小写)错误消息是否属于 Google 服务端配置类问题。 -// 只精确匹配已知的服务端侧错误,避免对客户端请求错误做无意义重试。 -// 适用于所有走 Google 后端的平台(Antigravity、Gemini)。 -func isGoogleProjectConfigError(lowerMsg string) bool { - // Google 间歇性 Bug:Project ID 有效但被临时识别失败 - return strings.Contains(lowerMsg, "invalid project resource name") -} - -// googleConfigErrorCooldown 服务端配置类 400 错误的临时封禁时长 -const googleConfigErrorCooldown = 1 * time.Minute - -// tempUnscheduleGoogleConfigError 对服务端配置类 400 错误触发临时封禁, -// 避免短时间内反复调度到同一个有问题的账号。 -func tempUnscheduleGoogleConfigError(ctx context.Context, repo AccountRepository, accountID int64, logPrefix string) { - until := time.Now().Add(googleConfigErrorCooldown) - reason := "400: invalid project resource name (auto temp-unschedule 1m)" - if err := repo.SetTempUnschedulable(ctx, accountID, until, reason); err != nil { - log.Printf("%s temp_unschedule_failed account=%d error=%v", logPrefix, accountID, err) - } else { - log.Printf("%s temp_unscheduled account=%d until=%v reason=%q", logPrefix, accountID, until.Format("15:04:05"), reason) - } -} - -// emptyResponseCooldown 空流式响应的临时封禁时长 -const emptyResponseCooldown = 1 * time.Minute - -// tempUnscheduleEmptyResponse 对空流式响应触发临时封禁, -// 避免短时间内反复调度到同一个返回空响应的账号。 -func tempUnscheduleEmptyResponse(ctx context.Context, repo AccountRepository, accountID int64, logPrefix string) { - until := time.Now().Add(emptyResponseCooldown) - reason := "empty stream response (auto temp-unschedule 1m)" - if err := repo.SetTempUnschedulable(ctx, accountID, until, reason); err != nil { - log.Printf("%s temp_unschedule_failed account=%d error=%v", logPrefix, accountID, err) - } else { - log.Printf("%s temp_unscheduled account=%d until=%v reason=%q", logPrefix, accountID, until.Format("15:04:05"), reason) - } -} - -// sleepAntigravityBackoffWithContext 带 context 取消检查的退避等待 -// 返回 true 表示正常完成等待,false 表示 context 已取消 -func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool { - delay := antigravityRetryBaseDelay * time.Duration(1< antigravityRetryMaxDelay { - delay = antigravityRetryMaxDelay - } - - // +/- 20% jitter - r := mathrand.New(mathrand.NewSource(time.Now().UnixNano())) - jitter := time.Duration(float64(delay) * 0.2 * (r.Float64()*2 - 1)) - sleepFor := delay + jitter - if sleepFor < 0 { - sleepFor = 0 - } - - timer := time.NewTimer(sleepFor) - select { - case <-ctx.Done(): - timer.Stop() - return false - case <-timer.C: - return true - } -} - -// isSingleAccountRetry 检查 context 中是否设置了单账号退避重试标记 -func isSingleAccountRetry(ctx context.Context) bool { - v, _ := SingleAccountRetryFromContext(ctx) - return v -} - -// setModelRateLimitByModelName 使用官方模型 ID 设置模型级限流 -// 直接使用上游返回的模型 ID(如 claude-sonnet-4-5)作为限流 key -// 返回是否已成功设置(若模型名为空或 repo 为 nil 将返回 false) -func setModelRateLimitByModelName(ctx context.Context, repo AccountRepository, accountID int64, modelName, prefix string, statusCode int, resetAt time.Time, afterSmartRetry bool) bool { - if repo == nil || modelName == "" { - return false - } - // 直接使用官方模型 ID 作为 key,不再转换为 scope - if err := repo.SetModelRateLimit(ctx, accountID, modelName, resetAt); err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limit_failed model=%s error=%v", prefix, statusCode, modelName, err) - return false - } - if afterSmartRetry { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited_after_smart_retry model=%s account=%d reset_in=%v", prefix, statusCode, modelName, accountID, time.Until(resetAt).Truncate(time.Second)) - } else { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited model=%s account=%d reset_in=%v", prefix, statusCode, modelName, accountID, time.Until(resetAt).Truncate(time.Second)) - } - return true -} - -func (s *AntigravityGatewayService) setAntigravityModelRateLimits(ctx context.Context, repo AccountRepository, account *Account, modelName, prefix string, statusCode int, resetAt time.Time, afterSmartRetry bool) bool { - if account == nil || repo == nil { - return false - } - keys := antigravityModelRateLimitKeys(modelName) - if len(keys) == 0 { - return false - } - - success := false - for _, key := range keys { - if setModelRateLimitByModelName(ctx, repo, account.ID, key, prefix, statusCode, resetAt, afterSmartRetry) { - s.updateAccountModelRateLimitInCache(ctx, account, key, resetAt) - success = true - } - } - return success -} - -func (s *AntigravityGatewayService) clearStickySession(ctx context.Context, groupID int64, sessionHash string) { - if s == nil || s.cache == nil || strings.TrimSpace(sessionHash) == "" { - return - } - if err := s.cache.DeleteSessionAccountID(ctx, groupID, sessionHash); err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] sticky_session_clear_failed group_id=%d session=%s err=%v", groupID, shortSessionHash(sessionHash), err) - } -} - -func antigravityFallbackCooldownSeconds() (time.Duration, bool) { - raw := strings.TrimSpace(os.Getenv(antigravityFallbackSecondsEnv)) - if raw == "" { - return 0, false - } - seconds, err := strconv.Atoi(raw) - if err != nil || seconds <= 0 { - return 0, false - } - return time.Duration(seconds) * time.Second, true -} - -// antigravitySmartRetryInfo 智能重试所需的信息 -type antigravitySmartRetryInfo struct { - RetryDelay time.Duration // 重试延迟时间 - ModelName string // 限流的模型名称(如 "claude-sonnet-4-5") - IsModelCapacityExhausted bool // 是否为模型容量不足(MODEL_CAPACITY_EXHAUSTED) -} - -// parseAntigravitySmartRetryInfo 解析 Google RPC RetryInfo 和 ErrorInfo 信息 -// 返回解析结果,如果解析失败或不满足条件返回 nil -// -// 支持两种情况: -// 1. 429 RESOURCE_EXHAUSTED + RATE_LIMIT_EXCEEDED: -// - error.status == "RESOURCE_EXHAUSTED" -// - error.details[].reason == "RATE_LIMIT_EXCEEDED" -// -// 2. 503 UNAVAILABLE + MODEL_CAPACITY_EXHAUSTED: -// - error.status == "UNAVAILABLE" -// - error.details[].reason == "MODEL_CAPACITY_EXHAUSTED" -// -// 必须满足以下条件才会返回有效值: -// - error.details[] 中存在 @type == "type.googleapis.com/google.rpc.RetryInfo" 的元素 -// - 该元素包含 retryDelay 字段,格式为 "数字s"(如 "0.201506475s") -func parseAntigravitySmartRetryInfo(body []byte) *antigravitySmartRetryInfo { - var parsed map[string]any - if err := json.Unmarshal(body, &parsed); err != nil { - return nil - } - - errObj, ok := parsed["error"].(map[string]any) - if !ok { - return nil - } - - // 检查 status 是否符合条件 - // 情况1: 429 RESOURCE_EXHAUSTED (需要进一步检查 reason == RATE_LIMIT_EXCEEDED) - // 情况2: 503 UNAVAILABLE (需要进一步检查 reason == MODEL_CAPACITY_EXHAUSTED) - status, _ := errObj["status"].(string) - isResourceExhausted := status == googleRPCStatusResourceExhausted - isUnavailable := status == googleRPCStatusUnavailable - - if !isResourceExhausted && !isUnavailable { - return nil - } - - details, ok := errObj["details"].([]any) - if !ok { - return nil - } - - var retryDelay time.Duration - var modelName string - var hasRateLimitExceeded bool // 429 需要此 reason - var hasModelCapacityExhausted bool // 503 需要此 reason - - for _, d := range details { - dm, ok := d.(map[string]any) - if !ok { - continue - } - - atType, _ := dm["@type"].(string) - - // 从 ErrorInfo 提取模型名称和 reason - if atType == googleRPCTypeErrorInfo { - if meta, ok := dm["metadata"].(map[string]any); ok { - if model, ok := meta["model"].(string); ok { - modelName = normalizeAntigravityModelName(model) - } - } - // 检查 reason - if reason, ok := dm["reason"].(string); ok { - if reason == googleRPCReasonModelCapacityExhausted { - hasModelCapacityExhausted = true - } - if reason == googleRPCReasonRateLimitExceeded { - hasRateLimitExceeded = true - } - } - continue - } - - // 从 RetryInfo 提取重试延迟 - if atType == googleRPCTypeRetryInfo { - delay, ok := dm["retryDelay"].(string) - if !ok || delay == "" { - continue - } - // 使用 time.ParseDuration 解析,支持所有 Go duration 格式 - // 例如: "0.5s", "10s", "4m50s", "1h30m", "200ms" 等 - dur, err := time.ParseDuration(delay) - if err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] failed to parse retryDelay: %s error=%v", delay, err) - continue - } - retryDelay = dur - } - } - - // 验证条件 - // 情况1: RESOURCE_EXHAUSTED 需要有 RATE_LIMIT_EXCEEDED reason - // 情况2: UNAVAILABLE 需要有 MODEL_CAPACITY_EXHAUSTED reason - if isResourceExhausted && !hasRateLimitExceeded { - return nil - } - if isUnavailable && !hasModelCapacityExhausted { - return nil - } - - // 必须有模型名才返回有效结果 - if modelName == "" { - return nil - } - - // 如果上游未提供 retryDelay,使用默认限流时间 - if retryDelay <= 0 { - retryDelay = antigravityDefaultRateLimitDuration - } - - return &antigravitySmartRetryInfo{ - RetryDelay: retryDelay, - ModelName: modelName, - IsModelCapacityExhausted: hasModelCapacityExhausted, - } -} - -// shouldTriggerAntigravitySmartRetry 判断是否应该触发智能重试 -// 返回: -// - shouldRetry: 是否应该智能重试(retryDelay < antigravityRateLimitThreshold,或 MODEL_CAPACITY_EXHAUSTED) -// - shouldRateLimitModel: 是否应该限流模型并切换账号(仅 RATE_LIMIT_EXCEEDED 且 retryDelay >= 阈值) -// - waitDuration: 等待时间 -// - modelName: 限流的模型名称 -// - isModelCapacityExhausted: 是否为模型容量不足(MODEL_CAPACITY_EXHAUSTED) -func shouldTriggerAntigravitySmartRetry(account *Account, respBody []byte) (shouldRetry bool, shouldRateLimitModel bool, waitDuration time.Duration, modelName string, isModelCapacityExhausted bool) { - if account.Platform != PlatformAntigravity { - return false, false, 0, "", false - } - - info := parseAntigravitySmartRetryInfo(respBody) - if info == nil { - return false, false, 0, "", false - } - - // MODEL_CAPACITY_EXHAUSTED(模型容量不足):所有账号共享同一模型容量池 - // 切换账号无意义,使用固定 1s 间隔重试 - if info.IsModelCapacityExhausted { - return true, false, antigravityModelCapacityRetryWait, info.ModelName, true - } - - // RATE_LIMIT_EXCEEDED(账号级限流): - // retryDelay >= 阈值:直接限流模型,不重试 - // 注意:如果上游未提供 retryDelay,parseAntigravitySmartRetryInfo 已设置为默认 30s - if info.RetryDelay >= antigravityRateLimitThreshold { - return false, true, info.RetryDelay, info.ModelName, false - } - - // retryDelay < 阈值:智能重试 - waitDuration = info.RetryDelay - if waitDuration < antigravitySmartRetryMinWait { - waitDuration = antigravitySmartRetryMinWait - } - - return true, false, waitDuration, info.ModelName, false -} - -// handleModelRateLimitParams 模型级限流处理参数 -type handleModelRateLimitParams struct { - ctx context.Context - prefix string - account *Account - statusCode int - body []byte - cache GatewayCache - groupID int64 - sessionHash string - isStickySession bool -} - -// handleModelRateLimitResult 模型级限流处理结果 -type handleModelRateLimitResult struct { - Handled bool // 是否已处理 - ShouldRetry bool // 是否等待后重试 - WaitDuration time.Duration // 等待时间 - SwitchError *AntigravityAccountSwitchError // 账号切换错误 -} - -// handleModelRateLimit 处理模型级限流(在原有逻辑之前调用) -// 仅处理 429/503,解析模型名和 retryDelay -// - MODEL_CAPACITY_EXHAUSTED: 返回 Handled=true(实际重试由 handleSmartRetry 处理) -// - RATE_LIMIT_EXCEEDED + retryDelay < 阈值: 返回 ShouldRetry=true,由调用方等待后重试 -// - RATE_LIMIT_EXCEEDED + retryDelay >= 阈值: 设置模型限流 + 清除粘性会话 + 返回 SwitchError -func (s *AntigravityGatewayService) handleModelRateLimit(p *handleModelRateLimitParams) *handleModelRateLimitResult { - if p.statusCode != 429 && p.statusCode != 503 { - return &handleModelRateLimitResult{Handled: false} - } - - info := parseAntigravitySmartRetryInfo(p.body) - if info == nil || info.ModelName == "" { - return &handleModelRateLimitResult{Handled: false} - } - - // MODEL_CAPACITY_EXHAUSTED:模型容量不足,所有账号共享同一容量池 - // 切换账号无意义,不设置模型限流(实际重试由 handleSmartRetry 处理) - if info.IsModelCapacityExhausted { - log.Printf("%s status=%d model_capacity_exhausted model=%s (not switching account, retry handled by smart retry)", - p.prefix, p.statusCode, info.ModelName) - return &handleModelRateLimitResult{ - Handled: true, - } - } - - // RATE_LIMIT_EXCEEDED: < antigravityRateLimitThreshold: 等待后重试 - if info.RetryDelay < antigravityRateLimitThreshold { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limit_wait model=%s wait=%v", - p.prefix, p.statusCode, info.ModelName, info.RetryDelay) - return &handleModelRateLimitResult{ - Handled: true, - ShouldRetry: true, - WaitDuration: info.RetryDelay, - } - } - - // RATE_LIMIT_EXCEEDED: >= antigravityRateLimitThreshold: 设置限流 + 清除粘性会话 + 切换账号 - s.setModelRateLimitAndClearSession(p, info) - - return &handleModelRateLimitResult{ - Handled: true, - SwitchError: &AntigravityAccountSwitchError{ - OriginalAccountID: p.account.ID, - RateLimitedModel: info.ModelName, - IsStickySession: p.isStickySession, - }, - } -} - -// setModelRateLimitAndClearSession 设置模型限流并清除粘性会话 -func (s *AntigravityGatewayService) setModelRateLimitAndClearSession(p *handleModelRateLimitParams, info *antigravitySmartRetryInfo) { - resetAt := time.Now().Add(info.RetryDelay) - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited model=%s account=%d reset_in=%v", - p.prefix, p.statusCode, info.ModelName, p.account.ID, info.RetryDelay) - - s.setAntigravityModelRateLimits(p.ctx, s.accountRepo, p.account, info.ModelName, p.prefix, p.statusCode, resetAt, false) - - // 清除粘性会话绑定 - if p.cache != nil && p.sessionHash != "" { - _ = p.cache.DeleteSessionAccountID(p.ctx, p.groupID, p.sessionHash) - } -} - -// updateAccountModelRateLimitInCache 立即更新 Redis 中账号的模型限流状态 -func (s *AntigravityGatewayService) updateAccountModelRateLimitInCache(ctx context.Context, account *Account, modelKey string, resetAt time.Time) { - if s.schedulerSnapshot == nil || account == nil || modelKey == "" { - return - } - - // 更新账号对象的 Extra 字段 - if account.Extra == nil { - account.Extra = make(map[string]any) - } - - limits, _ := account.Extra["model_rate_limits"].(map[string]any) - if limits == nil { - limits = make(map[string]any) - account.Extra["model_rate_limits"] = limits - } - - limits[modelKey] = map[string]any{ - "rate_limited_at": time.Now().UTC().Format(time.RFC3339), - "rate_limit_reset_at": resetAt.UTC().Format(time.RFC3339), - } - - // 更新 Redis 快照 - if err := s.schedulerSnapshot.UpdateAccountInCache(ctx, account); err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] cache_update_failed account=%d model=%s err=%v", account.ID, modelKey, err) - } -} - -func (s *AntigravityGatewayService) handleUpstreamError( - ctx context.Context, prefix string, account *Account, - statusCode int, headers http.Header, body []byte, - requestedModel string, - groupID int64, sessionHash string, isStickySession bool, -) *handleModelRateLimitResult { - // 遵守自定义错误码策略:未命中则跳过所有限流处理 - if !account.ShouldHandleErrorCode(statusCode) { - return nil - } - // 模型级限流处理(优先) - result := s.handleModelRateLimit(&handleModelRateLimitParams{ - ctx: ctx, - prefix: prefix, - account: account, - statusCode: statusCode, - body: body, - cache: s.cache, - groupID: groupID, - sessionHash: sessionHash, - isStickySession: isStickySession, - }) - if result.Handled { - return result - } - - // 503 仅处理模型限流(MODEL_CAPACITY_EXHAUSTED),非模型限流不做额外处理 - // 避免将普通的 503 错误误判为账号问题 - if statusCode == 503 { - return nil - } - - // 429:尝试解析模型级限流,解析失败时兜底为账号级限流 - if statusCode == 429 { - if logBody, maxBytes := s.getLogConfig(); logBody { - logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity-Debug] 429 response body: %s", truncateString(string(body), maxBytes)) - } - - resetAt := ParseGeminiRateLimitResetTime(body) - defaultDur := s.getDefaultRateLimitDuration() - - // 尝试解析模型 key 并设置模型级限流 - // - // 注意:requestedModel 可能是"映射前"的请求模型名(例如 claude-opus-4-6), - // 调度与限流判定使用的是 Antigravity 最终模型名(包含映射与 thinking 后缀)。 - // 因此这里必须写入最终模型 key,确保后续调度能正确避开已限流模型。 - modelKey := resolveFinalAntigravityModelKey(ctx, account, requestedModel) - if strings.TrimSpace(modelKey) == "" { - // 极少数情况下无法映射(理论上不应发生:能转发成功说明映射已通过), - // 保持旧行为作为兜底,避免完全丢失模型级限流记录。 - modelKey = resolveAntigravityModelKey(requestedModel) - } - if modelKey != "" { - ra := s.resolveResetTime(resetAt, defaultDur) - if !s.setAntigravityModelRateLimits(ctx, s.accountRepo, account, modelKey, prefix, statusCode, ra, false) { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 model_rate_limit_set_failed model=%s", prefix, modelKey) - } else { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 model_rate_limited model=%s account=%d reset_at=%v reset_in=%v", - prefix, modelKey, account.ID, ra.Format("15:04:05"), time.Until(ra).Truncate(time.Second)) - } - return nil - } - - // 无法解析模型 key,兜底为账号级限流 - ra := s.resolveResetTime(resetAt, defaultDur) - logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 rate_limited account=%d reset_at=%v reset_in=%v (fallback)", - prefix, account.ID, ra.Format("15:04:05"), time.Until(ra).Truncate(time.Second)) - if err := s.accountRepo.SetRateLimited(ctx, account.ID, ra); err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 rate_limit_set_failed account=%d error=%v", prefix, account.ID, err) - } - return nil - } - // 其他错误码继续使用 rateLimitService - if s.rateLimitService == nil { - return nil - } - shouldDisable := s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, headers, body) - if shouldDisable { - logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d marked_error", prefix, statusCode) - } - return nil -} - -// getDefaultRateLimitDuration 获取默认限流时间 -func (s *AntigravityGatewayService) getDefaultRateLimitDuration() time.Duration { - defaultDur := antigravityDefaultRateLimitDuration - if s.settingService != nil && s.settingService.cfg != nil && s.settingService.cfg.Gateway.AntigravityFallbackCooldownMinutes > 0 { - defaultDur = time.Duration(s.settingService.cfg.Gateway.AntigravityFallbackCooldownMinutes) * time.Minute - } - if override, ok := antigravityFallbackCooldownSeconds(); ok { - defaultDur = override - } - return defaultDur -} - -// resolveResetTime 根据解析的重置时间或默认时长计算重置时间点 -func (s *AntigravityGatewayService) resolveResetTime(resetAt *int64, defaultDur time.Duration) time.Time { - if resetAt != nil { - return time.Unix(*resetAt, 0) - } - return time.Now().Add(defaultDur) -} - -type antigravityStreamResult struct { - usage *ClaudeUsage - firstTokenMs *int - clientDisconnect bool // 客户端是否在流式传输过程中断开 -} - -// antigravityClientWriter 封装流式响应的客户端写入,自动检测断开并标记。 -// 断开后所有写入操作变为 no-op,调用方通过 Disconnected() 判断是否继续 drain 上游。 -type antigravityClientWriter struct { - w gin.ResponseWriter - flusher http.Flusher - disconnected bool - prefix string // 日志前缀,标识来源方法 -} - -func newAntigravityClientWriter(w gin.ResponseWriter, flusher http.Flusher, prefix string) *antigravityClientWriter { - return &antigravityClientWriter{w: w, flusher: flusher, prefix: prefix} -} - -// Write 写入数据到客户端,写入失败时标记断开并返回 false -func (cw *antigravityClientWriter) Write(p []byte) bool { - if cw.disconnected { - return false - } - if _, err := cw.w.Write(p); err != nil { - cw.markDisconnected() - return false - } - cw.flusher.Flush() - return true -} - -// Fprintf 格式化写入数据到客户端,写入失败时标记断开并返回 false -func (cw *antigravityClientWriter) Fprintf(format string, args ...any) bool { - if cw.disconnected { - return false - } - if _, err := fmt.Fprintf(cw.w, format, args...); err != nil { - cw.markDisconnected() - return false - } - cw.flusher.Flush() - return true -} - -func (cw *antigravityClientWriter) Disconnected() bool { return cw.disconnected } - -func (cw *antigravityClientWriter) markDisconnected() { - cw.disconnected = true - logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during streaming (%s), continuing to drain upstream for billing", cw.prefix) -} - -// handleStreamReadError 处理上游读取错误的通用逻辑。 -// 返回 (clientDisconnect, handled):handled=true 表示错误已处理,调用方应返回已收集的 usage。 -func handleStreamReadError(err error, clientDisconnected bool, prefix string) (disconnect bool, handled bool) { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - logger.LegacyPrintf("service.antigravity_gateway", "Context canceled during streaming (%s), returning collected usage", prefix) - return true, true - } - if clientDisconnected { - logger.LegacyPrintf("service.antigravity_gateway", "Upstream read error after client disconnect (%s): %v, returning collected usage", prefix, err) - return true, true - } - return false, false -} - -func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) { - c.Status(resp.StatusCode) - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") - - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "text/event-stream; charset=utf-8" - } - c.Header("Content-Type", contentType) - - flusher, ok := c.Writer.(http.Flusher) - if !ok { - return nil, errors.New("streaming not supported") - } - - // 使用 Scanner 并限制单行大小,避免 ReadString 无上限导致 OOM - scanner := bufio.NewScanner(resp.Body) - maxLineSize := defaultMaxLineSize - if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { - maxLineSize = s.settingService.cfg.Gateway.MaxLineSize - } - scanBuf := getSSEScannerBuf64K() - scanner.Buffer(scanBuf[:0], maxLineSize) - usage := &ClaudeUsage{} - var firstTokenMs *int - - type scanEvent struct { - line string - err error - } - // 独立 goroutine 读取上游,避免读取阻塞影响超时处理 - events := make(chan scanEvent, 16) - done := make(chan struct{}) - sendEvent := func(ev scanEvent) bool { - select { - case events <- ev: - return true - case <-done: - return false - } - } - var lastReadAt int64 - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - go func(scanBuf *sseScannerBuf64K) { - defer putSSEScannerBuf64K(scanBuf) - defer close(events) - for scanner.Scan() { - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - if !sendEvent(scanEvent{line: scanner.Text()}) { - return - } - } - if err := scanner.Err(); err != nil { - _ = sendEvent(scanEvent{err: err}) - } - }(scanBuf) - defer close(done) - - // 上游数据间隔超时保护(防止上游挂起长期占用连接) - streamInterval := time.Duration(0) - if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamDataIntervalTimeout > 0 { - streamInterval = time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second - } - var intervalTicker *time.Ticker - if streamInterval > 0 { - intervalTicker = time.NewTicker(streamInterval) - defer intervalTicker.Stop() - } - var intervalCh <-chan time.Time - if intervalTicker != nil { - intervalCh = intervalTicker.C - } - - // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 - keepaliveInterval := time.Duration(0) - if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 { - keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second - } - var keepaliveTicker *time.Ticker - if keepaliveInterval > 0 { - keepaliveTicker = time.NewTicker(keepaliveInterval) - defer keepaliveTicker.Stop() - } - var keepaliveCh <-chan time.Time - if keepaliveTicker != nil { - keepaliveCh = keepaliveTicker.C - } - lastDataAt := time.Now() - - cw := newAntigravityClientWriter(c.Writer, flusher, "antigravity gemini") - - // 仅发送一次错误事件,避免多次写入导致协议混乱 - errorEventSent := false - sendErrorEvent := func(reason string) { - if errorEventSent || cw.Disconnected() { - return - } - errorEventSent = true - _, _ = fmt.Fprintf(c.Writer, "event: error\ndata: {\"error\":\"%s\"}\n\n", reason) - flusher.Flush() - } - - for { - select { - case ev, ok := <-events: - if !ok { - return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: cw.Disconnected()}, nil - } - if ev.err != nil { - if disconnect, handled := handleStreamReadError(ev.err, cw.Disconnected(), "antigravity gemini"); handled { - return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: disconnect}, nil - } - if errors.Is(ev.err, bufio.ErrTooLong) { - logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity): max_size=%d error=%v", maxLineSize, ev.err) - sendErrorEvent("response_too_large") - return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, ev.err - } - sendErrorEvent("stream_read_error") - return nil, ev.err - } - - lastDataAt = time.Now() - - line := ev.line - trimmed := strings.TrimRight(line, "\r\n") - if strings.HasPrefix(trimmed, "data:") { - payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) - if payload == "" || payload == "[DONE]" { - cw.Fprintf("%s\n", line) - continue - } - - // 解包 v1internal 响应 - inner, parseErr := s.unwrapV1InternalResponse([]byte(payload)) - if parseErr == nil && inner != nil { - payload = string(inner) - } - - // 解析 usage - if u := extractGeminiUsage(inner); u != nil { - usage = u - } - var parsed map[string]any - if json.Unmarshal(inner, &parsed) == nil { - // Check for MALFORMED_FUNCTION_CALL - if candidates, ok := parsed["candidates"].([]any); ok && len(candidates) > 0 { - if cand, ok := candidates[0].(map[string]any); ok { - if fr, ok := cand["finishReason"].(string); ok && fr == "MALFORMED_FUNCTION_CALL" { - logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] MALFORMED_FUNCTION_CALL detected in forward stream") - if content, ok := cand["content"]; ok { - if b, err := json.Marshal(content); err == nil { - logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Malformed content: %s", string(b)) - } - } - } - } - } - } - - if firstTokenMs == nil { - ms := int(time.Since(startTime).Milliseconds()) - firstTokenMs = &ms - } - - cw.Fprintf("data: %s\n\n", payload) - continue - } - - cw.Fprintf("%s\n", line) - - case <-intervalCh: - lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) - if time.Since(lastRead) < streamInterval { - continue - } - if cw.Disconnected() { - logger.LegacyPrintf("service.antigravity_gateway", "Upstream timeout after client disconnect (antigravity gemini), returning collected usage") - return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil - } - logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity)") - sendErrorEvent("stream_timeout") - return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout") - - case <-keepaliveCh: - if cw.Disconnected() { - continue - } - if time.Since(lastDataAt) < keepaliveInterval { - continue - } - // SSE ping/keepalive:保持连接活跃防止 Cloudflare Tunnel 等代理断开 - if !cw.Fprintf(":\n\n") { - logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during keepalive ping (antigravity gemini), continuing to drain upstream for billing") - continue - } - } - } -} - -// handleGeminiStreamToNonStreaming 读取上游流式响应,合并为非流式响应返回给客户端 -// Gemini 流式响应是增量的,需要累积所有 chunk 的内容 -func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) { - scanner := bufio.NewScanner(resp.Body) - maxLineSize := defaultMaxLineSize - if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { - maxLineSize = s.settingService.cfg.Gateway.MaxLineSize - } - scanBuf := getSSEScannerBuf64K() - scanner.Buffer(scanBuf[:0], maxLineSize) - - usage := &ClaudeUsage{} - var firstTokenMs *int - var last map[string]any - var lastWithParts map[string]any - var collectedImageParts []map[string]any // 收集所有包含图片的 parts - var collectedTextParts []string // 收集所有文本片段 - - type scanEvent struct { - line string - err error - } - - // 独立 goroutine 读取上游,避免读取阻塞影响超时处理 - events := make(chan scanEvent, 16) - done := make(chan struct{}) - sendEvent := func(ev scanEvent) bool { - select { - case events <- ev: - return true - case <-done: - return false - } - } - - var lastReadAt int64 - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - go func(scanBuf *sseScannerBuf64K) { - defer putSSEScannerBuf64K(scanBuf) - defer close(events) - for scanner.Scan() { - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - if !sendEvent(scanEvent{line: scanner.Text()}) { - return - } - } - if err := scanner.Err(); err != nil { - _ = sendEvent(scanEvent{err: err}) - } - }(scanBuf) - defer close(done) - - // 上游数据间隔超时保护(防止上游挂起长期占用连接) - streamInterval := time.Duration(0) - if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamDataIntervalTimeout > 0 { - streamInterval = time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second - } - var intervalTicker *time.Ticker - if streamInterval > 0 { - intervalTicker = time.NewTicker(streamInterval) - defer intervalTicker.Stop() - } - var intervalCh <-chan time.Time - if intervalTicker != nil { - intervalCh = intervalTicker.C - } - - for { - select { - case ev, ok := <-events: - if !ok { - // 流结束,返回收集的响应 - goto returnResponse - } - if ev.err != nil { - if errors.Is(ev.err, bufio.ErrTooLong) { - logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity non-stream): max_size=%d error=%v", maxLineSize, ev.err) - } - return nil, ev.err - } - - line := ev.line - trimmed := strings.TrimRight(line, "\r\n") - - if !strings.HasPrefix(trimmed, "data:") { - continue - } - - payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) - if payload == "" || payload == "[DONE]" { - continue - } - - // 解包 v1internal 响应 - inner, parseErr := s.unwrapV1InternalResponse([]byte(payload)) - if parseErr != nil { - continue - } - - var parsed map[string]any - if err := json.Unmarshal(inner, &parsed); err != nil { - continue - } - - // 记录首 token 时间 - if firstTokenMs == nil { - ms := int(time.Since(startTime).Milliseconds()) - firstTokenMs = &ms - } - - last = parsed - - // 提取 usage - if u := extractGeminiUsage(inner); u != nil { - usage = u - } - - // Check for MALFORMED_FUNCTION_CALL - if candidates, ok := parsed["candidates"].([]any); ok && len(candidates) > 0 { - if cand, ok := candidates[0].(map[string]any); ok { - if fr, ok := cand["finishReason"].(string); ok && fr == "MALFORMED_FUNCTION_CALL" { - logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] MALFORMED_FUNCTION_CALL detected in forward non-stream collect") - if content, ok := cand["content"]; ok { - if b, err := json.Marshal(content); err == nil { - logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Malformed content: %s", string(b)) - } - } - } - } - } - - // 保留最后一个有 parts 的响应 - if parts := extractGeminiParts(parsed); len(parts) > 0 { - lastWithParts = parsed - // 收集包含图片和文本的 parts - for _, part := range parts { - if inlineData, ok := part["inlineData"].(map[string]any); ok { - collectedImageParts = append(collectedImageParts, part) - _ = inlineData // 避免 unused 警告 - } - if text, ok := part["text"].(string); ok && text != "" { - collectedTextParts = append(collectedTextParts, text) - } - } - } - - case <-intervalCh: - lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) - if time.Since(lastRead) < streamInterval { - continue - } - logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity non-stream)") - return nil, fmt.Errorf("stream data interval timeout") - } - } - -returnResponse: - // 选择最后一个有效响应 - finalResponse := pickGeminiCollectResult(last, lastWithParts) - - // 处理空响应情况 — 触发同账号重试 + failover 切换账号 - if last == nil && lastWithParts == nil { - logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] warning: empty stream response (gemini non-stream), triggering failover") - return nil, &UpstreamFailoverError{ - StatusCode: http.StatusBadGateway, - ResponseBody: []byte(`{"error":"empty stream response from upstream"}`), - RetryableOnSameAccount: true, - } - } - - // 如果收集到了图片 parts,需要合并到最终响应中 - if len(collectedImageParts) > 0 { - finalResponse = mergeImagePartsToResponse(finalResponse, collectedImageParts) - } - - // 如果收集到了文本,需要合并到最终响应中 - if len(collectedTextParts) > 0 { - finalResponse = mergeTextPartsToResponse(finalResponse, collectedTextParts) - } - - respBody, err := json.Marshal(finalResponse) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - c.Data(http.StatusOK, "application/json", respBody) - - return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, nil -} - -// getOrCreateGeminiParts 获取 Gemini 响应的 parts 结构,返回深拷贝和更新回调 -func getOrCreateGeminiParts(response map[string]any) (result map[string]any, existingParts []any, setParts func([]any)) { - // 深拷贝 response - result = make(map[string]any) - for k, v := range response { - result[k] = v - } - - // 获取或创建 candidates - candidates, ok := result["candidates"].([]any) - if !ok || len(candidates) == 0 { - candidates = []any{map[string]any{}} - } - - // 获取第一个 candidate - candidate, ok := candidates[0].(map[string]any) - if !ok { - candidate = make(map[string]any) - candidates[0] = candidate - } - - // 获取或创建 content - content, ok := candidate["content"].(map[string]any) - if !ok { - content = map[string]any{"role": "model"} - candidate["content"] = content - } - - // 获取现有 parts - existingParts, ok = content["parts"].([]any) - if !ok { - existingParts = []any{} - } - - // 返回更新回调 - setParts = func(newParts []any) { - content["parts"] = newParts - result["candidates"] = candidates - } - - return result, existingParts, setParts -} - -// mergeCollectedPartsToResponse 将收集的所有 parts 合并到 Gemini 响应中 -// 这个函数会合并所有类型的 parts:text、thinking、functionCall、inlineData 等 -// 保持原始顺序,只合并连续的普通 text parts -func mergeCollectedPartsToResponse(response map[string]any, collectedParts []map[string]any) map[string]any { - if len(collectedParts) == 0 { - return response - } - - result, _, setParts := getOrCreateGeminiParts(response) - - // 合并策略: - // 1. 保持原始顺序 - // 2. 连续的普通 text parts 合并为一个 - // 3. thinking、functionCall、inlineData 等保持原样 - var mergedParts []any - var textBuffer strings.Builder - - flushTextBuffer := func() { - if textBuffer.Len() > 0 { - mergedParts = append(mergedParts, map[string]any{ - "text": textBuffer.String(), - }) - textBuffer.Reset() - } - } - - for _, part := range collectedParts { - // 检查是否是普通 text part - if text, ok := part["text"].(string); ok { - // 检查是否有 thought 标记 - if thought, _ := part["thought"].(bool); thought { - // thinking part,先刷新 text buffer,然后保留原样 - flushTextBuffer() - mergedParts = append(mergedParts, part) - } else { - // 普通 text,累积到 buffer - _, _ = textBuffer.WriteString(text) - } - } else { - // 非 text part(functionCall、inlineData 等),先刷新 text buffer,然后保留原样 - flushTextBuffer() - mergedParts = append(mergedParts, part) - } - } - - // 刷新剩余的 text - flushTextBuffer() - - setParts(mergedParts) - return result -} - -// mergeImagePartsToResponse 将收集到的图片 parts 合并到 Gemini 响应中 -func mergeImagePartsToResponse(response map[string]any, imageParts []map[string]any) map[string]any { - if len(imageParts) == 0 { - return response - } - - result, existingParts, setParts := getOrCreateGeminiParts(response) - - // 检查现有 parts 中是否已经有图片 - for _, p := range existingParts { - if pm, ok := p.(map[string]any); ok { - if _, hasInline := pm["inlineData"]; hasInline { - return result // 已有图片,不重复添加 - } - } - } - - // 添加收集到的图片 parts - for _, imgPart := range imageParts { - existingParts = append(existingParts, imgPart) - } - setParts(existingParts) - return result -} - -// mergeTextPartsToResponse 将收集到的文本合并到 Gemini 响应中 -func mergeTextPartsToResponse(response map[string]any, textParts []string) map[string]any { - if len(textParts) == 0 { - return response - } - - mergedText := strings.Join(textParts, "") - result, existingParts, setParts := getOrCreateGeminiParts(response) - - // 查找并更新第一个 text part,或创建新的 - newParts := make([]any, 0, len(existingParts)+1) - textUpdated := false - - for _, p := range existingParts { - pm, ok := p.(map[string]any) - if !ok { - newParts = append(newParts, p) - continue - } - if _, hasText := pm["text"]; hasText && !textUpdated { - // 用累积的文本替换 - newPart := make(map[string]any) - for k, v := range pm { - newPart[k] = v - } - newPart["text"] = mergedText - newParts = append(newParts, newPart) - textUpdated = true - } else { - newParts = append(newParts, pm) - } - } - - if !textUpdated { - newParts = append([]any{map[string]any{"text": mergedText}}, newParts...) - } - - setParts(newParts) - return result -} - -func (s *AntigravityGatewayService) writeClaudeError(c *gin.Context, status int, errType, message string) error { - MarkResponseCommitted(c) - c.JSON(status, gin.H{ - "type": "error", - "error": gin.H{"type": errType, "message": message}, - }) - return fmt.Errorf("%s", message) -} - -// WriteMappedClaudeError 导出版本,供 handler 层使用(如 fallback 错误处理) -func (s *AntigravityGatewayService) WriteMappedClaudeError(c *gin.Context, account *Account, upstreamStatus int, upstreamRequestID string, body []byte) error { - return s.writeMappedClaudeError(c, account, upstreamStatus, upstreamRequestID, body) -} - -func (s *AntigravityGatewayService) writeMappedClaudeError(c *gin.Context, account *Account, upstreamStatus int, upstreamRequestID string, body []byte) error { - MarkResponseCommitted(c) - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - logBody, maxBytes := s.getLogConfig() - upstreamDetail := s.getUpstreamErrorDetail(body) - setOpsUpstreamError(c, upstreamStatus, upstreamMsg, upstreamDetail) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: upstreamStatus, - UpstreamRequestID: upstreamRequestID, - Kind: "http_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - - // 记录上游错误详情便于排障(可选:由配置控制;不回显到客户端) - if logBody { - logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] upstream_error status=%d body=%s", upstreamStatus, truncateForLog(body, maxBytes)) - } - - // 检查错误透传规则 - if ptStatus, ptErrType, ptErrMsg, matched := applyErrorPassthroughRule( - c, account.Platform, upstreamStatus, body, - 0, "", "", - ); matched { - c.JSON(ptStatus, gin.H{ - "type": "error", - "error": gin.H{"type": ptErrType, "message": ptErrMsg}, - }) - if upstreamMsg == "" { - return fmt.Errorf("upstream error: %d", upstreamStatus) - } - return fmt.Errorf("upstream error: %d message=%s", upstreamStatus, upstreamMsg) - } - - var statusCode int - var errType, errMsg string - - switch upstreamStatus { - case 400: - statusCode = http.StatusBadRequest - errType = "invalid_request_error" - errMsg = getPassthroughOrDefault(upstreamMsg, "Invalid request") - case 401: - statusCode = http.StatusBadGateway - errType = "authentication_error" - errMsg = "Upstream authentication failed" - case 403: - statusCode = http.StatusBadGateway - errType = "permission_error" - errMsg = "Upstream access forbidden" - case 429: - statusCode = http.StatusTooManyRequests - errType = "rate_limit_error" - errMsg = "Upstream rate limit exceeded" - case 529: - statusCode = http.StatusServiceUnavailable - errType = "overloaded_error" - errMsg = "Upstream service overloaded" - default: - statusCode = http.StatusBadGateway - errType = "upstream_error" - errMsg = "Upstream request failed" - } - - c.JSON(statusCode, gin.H{ - "type": "error", - "error": gin.H{"type": errType, "message": errMsg}, - }) - if upstreamMsg == "" { - return fmt.Errorf("upstream error: %d", upstreamStatus) - } - return fmt.Errorf("upstream error: %d message=%s", upstreamStatus, upstreamMsg) -} - -func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int, message string) error { - MarkResponseCommitted(c) - statusStr := "UNKNOWN" - switch status { - case 400: - statusStr = "INVALID_ARGUMENT" - case 404: - statusStr = "NOT_FOUND" - case 429: - statusStr = "RESOURCE_EXHAUSTED" - case 500: - statusStr = "INTERNAL" - case 502, 503: - statusStr = "UNAVAILABLE" - } - - c.JSON(status, gin.H{ - "error": gin.H{ - "code": status, - "message": message, - "status": statusStr, - }, - }) - return fmt.Errorf("%s", message) -} - -// handleClaudeStreamToNonStreaming 收集上游流式响应,转换为 Claude 非流式格式返回 -// 用于处理客户端非流式请求但上游只支持流式的情况 -func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) (*antigravityStreamResult, error) { - scanner := bufio.NewScanner(resp.Body) - maxLineSize := defaultMaxLineSize - if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { - maxLineSize = s.settingService.cfg.Gateway.MaxLineSize - } - scanBuf := getSSEScannerBuf64K() - scanner.Buffer(scanBuf[:0], maxLineSize) - - var firstTokenMs *int - var last map[string]any - var lastWithParts map[string]any - var collectedParts []map[string]any // 收集所有 parts(包括 text、thinking、functionCall、inlineData 等) - - type scanEvent struct { - line string - err error - } - - // 独立 goroutine 读取上游,避免读取阻塞影响超时处理 - events := make(chan scanEvent, 16) - done := make(chan struct{}) - sendEvent := func(ev scanEvent) bool { - select { - case events <- ev: - return true - case <-done: - return false - } - } - - var lastReadAt int64 - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - go func(scanBuf *sseScannerBuf64K) { - defer putSSEScannerBuf64K(scanBuf) - defer close(events) - for scanner.Scan() { - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - if !sendEvent(scanEvent{line: scanner.Text()}) { - return - } - } - if err := scanner.Err(); err != nil { - _ = sendEvent(scanEvent{err: err}) - } - }(scanBuf) - defer close(done) - - // 上游数据间隔超时保护(防止上游挂起长期占用连接) - streamInterval := time.Duration(0) - if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamDataIntervalTimeout > 0 { - streamInterval = time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second - } - var intervalTicker *time.Ticker - if streamInterval > 0 { - intervalTicker = time.NewTicker(streamInterval) - defer intervalTicker.Stop() - } - var intervalCh <-chan time.Time - if intervalTicker != nil { - intervalCh = intervalTicker.C - } - - for { - select { - case ev, ok := <-events: - if !ok { - // 流结束,转换并返回响应 - goto returnResponse - } - if ev.err != nil { - if errors.Is(ev.err, bufio.ErrTooLong) { - logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity claude non-stream): max_size=%d error=%v", maxLineSize, ev.err) - } - return nil, ev.err - } - - line := ev.line - trimmed := strings.TrimRight(line, "\r\n") - - if !strings.HasPrefix(trimmed, "data:") { - continue - } - - payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) - if payload == "" || payload == "[DONE]" { - continue - } - - // 解包 v1internal 响应 - inner, parseErr := s.unwrapV1InternalResponse([]byte(payload)) - if parseErr != nil { - continue - } - - var parsed map[string]any - if err := json.Unmarshal(inner, &parsed); err != nil { - continue - } - - // 记录首 token 时间 - if firstTokenMs == nil { - ms := int(time.Since(startTime).Milliseconds()) - firstTokenMs = &ms - } - - last = parsed - - // 保留最后一个有 parts 的响应,并收集所有 parts - if parts := extractGeminiParts(parsed); len(parts) > 0 { - lastWithParts = parsed - - // 收集所有 parts(text、thinking、functionCall、inlineData 等) - collectedParts = append(collectedParts, parts...) - } - - case <-intervalCh: - lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) - if time.Since(lastRead) < streamInterval { - continue - } - logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity claude non-stream)") - return nil, fmt.Errorf("stream data interval timeout") - } - } - -returnResponse: - // 选择最后一个有效响应 - finalResponse := pickGeminiCollectResult(last, lastWithParts) - - // 处理空响应情况 — 触发同账号重试 + failover 切换账号 - if last == nil && lastWithParts == nil { - logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] warning: empty stream response (claude non-stream), triggering failover") - return nil, &UpstreamFailoverError{ - StatusCode: http.StatusBadGateway, - ResponseBody: []byte(`{"error":"empty stream response from upstream"}`), - RetryableOnSameAccount: true, - } - } - - // 将收集的所有 parts 合并到最终响应中 - if len(collectedParts) > 0 { - finalResponse = mergeCollectedPartsToResponse(finalResponse, collectedParts) - } - - // 序列化为 JSON(Gemini 格式) - geminiBody, err := json.Marshal(finalResponse) - if err != nil { - return nil, fmt.Errorf("failed to marshal gemini response: %w", err) - } - - // 转换 Gemini 响应为 Claude 格式 - claudeResp, agUsage, err := antigravity.TransformGeminiToClaude(geminiBody, originalModel) - if err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] transform_error error=%v body=%s", err, string(geminiBody)) - return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response") - } - - c.Data(http.StatusOK, "application/json", claudeResp) - - // 转换为 service.ClaudeUsage - usage := &ClaudeUsage{ - InputTokens: agUsage.InputTokens, - OutputTokens: agUsage.OutputTokens, - CacheCreationInputTokens: agUsage.CacheCreationInputTokens, - CacheReadInputTokens: agUsage.CacheReadInputTokens, - } - - return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, nil -} - -// handleClaudeStreamingResponse 处理 Claude 流式响应(Gemini SSE → Claude SSE 转换) -func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) (*antigravityStreamResult, error) { - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") - c.Status(http.StatusOK) - - flusher, ok := c.Writer.(http.Flusher) - if !ok { - return nil, errors.New("streaming not supported") - } - - processor := antigravity.NewStreamingProcessor(originalModel) - var firstTokenMs *int - // 使用 Scanner 并限制单行大小,避免 ReadString 无上限导致 OOM - scanner := bufio.NewScanner(resp.Body) - maxLineSize := defaultMaxLineSize - if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { - maxLineSize = s.settingService.cfg.Gateway.MaxLineSize - } - scanBuf := getSSEScannerBuf64K() - scanner.Buffer(scanBuf[:0], maxLineSize) - - // 辅助函数:转换 antigravity.ClaudeUsage 到 service.ClaudeUsage - convertUsage := func(agUsage *antigravity.ClaudeUsage) *ClaudeUsage { - if agUsage == nil { - return &ClaudeUsage{} - } - return &ClaudeUsage{ - InputTokens: agUsage.InputTokens, - OutputTokens: agUsage.OutputTokens, - CacheCreationInputTokens: agUsage.CacheCreationInputTokens, - CacheReadInputTokens: agUsage.CacheReadInputTokens, - } - } - - type scanEvent struct { - line string - err error - } - // 独立 goroutine 读取上游,避免读取阻塞影响超时处理 - events := make(chan scanEvent, 16) - done := make(chan struct{}) - sendEvent := func(ev scanEvent) bool { - select { - case events <- ev: - return true - case <-done: - return false - } - } - var lastReadAt int64 - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - go func(scanBuf *sseScannerBuf64K) { - defer putSSEScannerBuf64K(scanBuf) - defer close(events) - for scanner.Scan() { - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - if !sendEvent(scanEvent{line: scanner.Text()}) { - return - } - } - if err := scanner.Err(); err != nil { - _ = sendEvent(scanEvent{err: err}) - } - }(scanBuf) - defer close(done) - - streamInterval := time.Duration(0) - if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamDataIntervalTimeout > 0 { - streamInterval = time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second - } - var intervalTicker *time.Ticker - if streamInterval > 0 { - intervalTicker = time.NewTicker(streamInterval) - defer intervalTicker.Stop() - } - var intervalCh <-chan time.Time - if intervalTicker != nil { - intervalCh = intervalTicker.C - } - - // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 - keepaliveInterval := time.Duration(0) - if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 { - keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second - } - var keepaliveTicker *time.Ticker - if keepaliveInterval > 0 { - keepaliveTicker = time.NewTicker(keepaliveInterval) - defer keepaliveTicker.Stop() - } - var keepaliveCh <-chan time.Time - if keepaliveTicker != nil { - keepaliveCh = keepaliveTicker.C - } - lastDataAt := time.Now() - - cw := newAntigravityClientWriter(c.Writer, flusher, "antigravity claude") - - // 仅发送一次错误事件,避免多次写入导致协议混乱 - errorEventSent := false - sendErrorEvent := func(reason string) { - if errorEventSent || cw.Disconnected() { - return - } - errorEventSent = true - _, _ = fmt.Fprintf(c.Writer, "event: error\ndata: {\"error\":\"%s\"}\n\n", reason) - flusher.Flush() - } - - // finishUsage 是获取 processor 最终 usage 的辅助函数 - finishUsage := func() *ClaudeUsage { - _, agUsage := processor.Finish() - return convertUsage(agUsage) - } - - for { - select { - case ev, ok := <-events: - if !ok { - // 上游完成,发送结束事件 - finalEvents, agUsage := processor.Finish() - if len(finalEvents) > 0 { - cw.Write(finalEvents) - } else if !processor.MessageStartSent() && !cw.Disconnected() { - // 整个流未收到任何可解析的上游数据(全部 SSE 行均无法被 JSON 解析), - // 触发 failover 在同账号重试,避免向客户端发出缺少 message_start 的残缺流 - logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Claude-Stream] empty stream response (no valid events parsed), triggering failover") - return nil, &UpstreamFailoverError{ - StatusCode: http.StatusBadGateway, - ResponseBody: []byte(`{"error":"empty stream response from upstream"}`), - RetryableOnSameAccount: true, - } - } - return &antigravityStreamResult{usage: convertUsage(agUsage), firstTokenMs: firstTokenMs, clientDisconnect: cw.Disconnected()}, nil - } - if ev.err != nil { - if disconnect, handled := handleStreamReadError(ev.err, cw.Disconnected(), "antigravity claude"); handled { - return &antigravityStreamResult{usage: finishUsage(), firstTokenMs: firstTokenMs, clientDisconnect: disconnect}, nil - } - if errors.Is(ev.err, bufio.ErrTooLong) { - logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity): max_size=%d error=%v", maxLineSize, ev.err) - sendErrorEvent("response_too_large") - return &antigravityStreamResult{usage: convertUsage(nil), firstTokenMs: firstTokenMs}, ev.err - } - sendErrorEvent("stream_read_error") - return nil, fmt.Errorf("stream read error: %w", ev.err) - } - - lastDataAt = time.Now() - - // 处理 SSE 行,转换为 Claude 格式 - claudeEvents := processor.ProcessLine(strings.TrimRight(ev.line, "\r\n")) - if len(claudeEvents) > 0 { - if firstTokenMs == nil { - ms := int(time.Since(startTime).Milliseconds()) - firstTokenMs = &ms - } - cw.Write(claudeEvents) - } - - case <-intervalCh: - lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) - if time.Since(lastRead) < streamInterval { - continue - } - if cw.Disconnected() { - logger.LegacyPrintf("service.antigravity_gateway", "Upstream timeout after client disconnect (antigravity claude), returning collected usage") - return &antigravityStreamResult{usage: finishUsage(), firstTokenMs: firstTokenMs, clientDisconnect: true}, nil - } - logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity)") - sendErrorEvent("stream_timeout") - return &antigravityStreamResult{usage: convertUsage(nil), firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout") - - case <-keepaliveCh: - if cw.Disconnected() { - continue - } - if time.Since(lastDataAt) < keepaliveInterval { - continue - } - // SSE ping 事件:Anthropic 原生格式,客户端会正确处理, - // 同时保持连接活跃防止 Cloudflare Tunnel 等代理断开 - if !cw.Fprintf("event: ping\ndata: {\"type\": \"ping\"}\n\n") { - logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during keepalive ping (antigravity claude), continuing to drain upstream for billing") - continue - } - } - } -} - -func (s *AntigravityGatewayService) extractImageInputSize(body []byte) string { - var req antigravity.GeminiRequest - if err := json.Unmarshal(body, &req); err != nil { - return "" - } - - if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil { - return strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize) - } - - return "" -} - -// isImageGenerationModel 判断模型是否为图片生成模型 -// 支持的模型:gemini-3.1-flash-image, gemini-3-pro-image, gemini-2.5-flash-image 等 -func isImageGenerationModel(model string) bool { - modelLower := strings.ToLower(model) - // 移除 models/ 前缀 - modelLower = strings.TrimPrefix(modelLower, "models/") - - // 精确匹配或前缀匹配 - return modelLower == "gemini-3.1-flash-image" || - modelLower == "gemini-3.1-flash-image-preview" || - strings.HasPrefix(modelLower, "gemini-3.1-flash-image-") || - modelLower == "gemini-3-pro-image" || - modelLower == "gemini-3-pro-image-preview" || - strings.HasPrefix(modelLower, "gemini-3-pro-image-") || - modelLower == "gemini-2.5-flash-image" || - modelLower == "gemini-2.5-flash-image-preview" || - strings.HasPrefix(modelLower, "gemini-2.5-flash-image-") -} - -// cleanGeminiRequest 清理 Gemini 请求体中的 Schema -func cleanGeminiRequest(body []byte) ([]byte, error) { - var payload map[string]any - if err := json.Unmarshal(body, &payload); err != nil { - return nil, err - } - - modified := false - - // 1. 清理 Tools - if tools, ok := payload["tools"].([]any); ok && len(tools) > 0 { - for _, t := range tools { - toolMap, ok := t.(map[string]any) - if !ok { - continue - } - - // function_declarations (snake_case) or functionDeclarations (camelCase) - var funcs []any - if f, ok := toolMap["functionDeclarations"].([]any); ok { - funcs = f - } else if f, ok := toolMap["function_declarations"].([]any); ok { - funcs = f - } - - if len(funcs) == 0 { - continue - } - - for _, f := range funcs { - funcMap, ok := f.(map[string]any) - if !ok { - continue - } - - if params, ok := funcMap["parameters"].(map[string]any); ok { - antigravity.DeepCleanUndefined(params) - cleaned := antigravity.CleanJSONSchema(params) - funcMap["parameters"] = cleaned - modified = true - } - } - } - } - - if !modified { - return body, nil - } - - return json.Marshal(payload) -} - -// filterEmptyPartsFromGeminiRequest 过滤掉 parts 为空的消息 -// Gemini API 不接受空 parts,需要在请求前过滤 -func filterEmptyPartsFromGeminiRequest(body []byte) ([]byte, error) { - var payload map[string]any - if err := json.Unmarshal(body, &payload); err != nil { - return nil, err - } - - contents, ok := payload["contents"].([]any) - if !ok || len(contents) == 0 { - return body, nil - } - - filtered := make([]any, 0, len(contents)) - modified := false - - for _, c := range contents { - contentMap, ok := c.(map[string]any) - if !ok { - filtered = append(filtered, c) - continue - } - - parts, hasParts := contentMap["parts"] - if !hasParts { - filtered = append(filtered, c) - continue - } - - partsSlice, ok := parts.([]any) - if !ok { - filtered = append(filtered, c) - continue - } - - // 跳过 parts 为空数组的消息 - if len(partsSlice) == 0 { - modified = true - continue - } - - filtered = append(filtered, c) - } - - if !modified { - return body, nil - } - - payload["contents"] = filtered - return json.Marshal(payload) -} - -// ForwardUpstream 使用 base_url + /v1/messages + 双 header 认证透传上游 Claude 请求 -func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { - startTime := time.Now() - sessionID := getSessionID(c) - prefix := logPrefix(sessionID, account.Name) - - // 获取上游配置 - baseURL := strings.TrimSpace(account.GetCredential("base_url")) - apiKey := strings.TrimSpace(account.GetCredential("api_key")) - if baseURL == "" || apiKey == "" { - return nil, fmt.Errorf("upstream account missing base_url or api_key") - } - baseURL = strings.TrimSuffix(baseURL, "/") - - // 解析请求获取模型信息 - var claudeReq antigravity.ClaudeRequest - if err := json.Unmarshal(body, &claudeReq); err != nil { - return nil, fmt.Errorf("parse claude request: %w", err) - } - if strings.TrimSpace(claudeReq.Model) == "" { - return nil, fmt.Errorf("missing model") - } - originalModel := claudeReq.Model - - // 构建上游请求 URL - upstreamURL := baseURL + "/v1/messages" - - // 能力维度 sanitize:Anthropic-compatible 上游透传路径也需要保证 body↔beta header - // 对称。客户端 anthropic-beta header 不含 context-management-2025-06-27 但 body 带 - // context_management 时 strip,与 Anthropic 直连 / Bedrock / Vertex 路径保持一致。 - clientBeta := c.GetHeader("anthropic-beta") - if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed { - body = sanitized - } - - // 创建请求 - req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("create upstream request: %w", err) - } - - // 设置请求头 - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+apiKey) - req.Header.Set("x-api-key", apiKey) // Claude API 兼容 - - // 透传 Claude 相关 headers - if v := c.GetHeader("anthropic-version"); v != "" { - req.Header.Set("anthropic-version", v) - } - if v := clientBeta; v != "" { - req.Header.Set("anthropic-beta", v) - } - - // 代理 URL - proxyURL := "" - if account.ProxyID != nil && account.Proxy != nil { - proxyURL = account.Proxy.URL() - } - - // 发送请求 - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) - if err != nil { - logger.LegacyPrintf("service.antigravity_gateway", "%s upstream request failed: %v", prefix, err) - return nil, fmt.Errorf("upstream request failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - // 处理错误响应 - if resp.StatusCode >= 400 { - respBody := s.readUpstreamErrorBody(resp) - - // 429 错误时标记账号限流 - if resp.StatusCode == http.StatusTooManyRequests { - s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, 0, "", false) - } - - // 透传上游错误 - c.Header("Content-Type", resp.Header.Get("Content-Type")) - c.Status(resp.StatusCode) - _, _ = c.Writer.Write(respBody) - - return &ForwardResult{ - Model: originalModel, - }, nil - } - - // 处理成功响应(流式/非流式) - var usage *ClaudeUsage - var firstTokenMs *int - var clientDisconnect bool - - if claudeReq.Stream { - // 流式响应:透传 - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") - c.Status(http.StatusOK) - - streamRes := s.streamUpstreamResponse(c, resp, startTime) - usage = streamRes.usage - firstTokenMs = streamRes.firstTokenMs - clientDisconnect = streamRes.clientDisconnect - } else { - // 非流式响应:直接透传 - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read upstream response: %w", err) - } - - // 提取 usage - usage = s.extractClaudeUsage(respBody) - - c.Header("Content-Type", resp.Header.Get("Content-Type")) - c.Status(http.StatusOK) - _, _ = c.Writer.Write(respBody) - } - - // 构建计费结果 - duration := time.Since(startTime) - logger.LegacyPrintf("service.antigravity_gateway", "%s status=success duration_ms=%d", prefix, duration.Milliseconds()) - - return &ForwardResult{ - Model: originalModel, - Stream: claudeReq.Stream, - Duration: duration, - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, - Usage: ClaudeUsage{ - InputTokens: usage.InputTokens, - OutputTokens: usage.OutputTokens, - CacheReadInputTokens: usage.CacheReadInputTokens, - CacheCreationInputTokens: usage.CacheCreationInputTokens, - }, - }, nil -} - -// streamUpstreamResponse 透传上游 SSE 流并提取 Claude usage -func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp *http.Response, startTime time.Time) *antigravityStreamResult { - usage := &ClaudeUsage{} - var firstTokenMs *int - - scanner := bufio.NewScanner(resp.Body) - maxLineSize := defaultMaxLineSize - if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { - maxLineSize = s.settingService.cfg.Gateway.MaxLineSize - } - scanner.Buffer(make([]byte, 64*1024), maxLineSize) - - type scanEvent struct { - line string - err error - } - events := make(chan scanEvent, 16) - done := make(chan struct{}) - sendEvent := func(ev scanEvent) bool { - select { - case events <- ev: - return true - case <-done: - return false - } - } - var lastReadAt int64 - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - go func() { - defer close(events) - for scanner.Scan() { - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - if !sendEvent(scanEvent{line: scanner.Text()}) { - return - } - } - if err := scanner.Err(); err != nil { - _ = sendEvent(scanEvent{err: err}) - } - }() - defer close(done) - - streamInterval := time.Duration(0) - if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamDataIntervalTimeout > 0 { - streamInterval = time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second - } - var intervalTicker *time.Ticker - if streamInterval > 0 { - intervalTicker = time.NewTicker(streamInterval) - defer intervalTicker.Stop() - } - var intervalCh <-chan time.Time - if intervalTicker != nil { - intervalCh = intervalTicker.C - } - - // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 - keepaliveInterval := time.Duration(0) - if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 { - keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second - } - var keepaliveTicker *time.Ticker - if keepaliveInterval > 0 { - keepaliveTicker = time.NewTicker(keepaliveInterval) - defer keepaliveTicker.Stop() - } - var keepaliveCh <-chan time.Time - if keepaliveTicker != nil { - keepaliveCh = keepaliveTicker.C - } - lastDataAt := time.Now() - - flusher, _ := c.Writer.(http.Flusher) - cw := newAntigravityClientWriter(c.Writer, flusher, "antigravity upstream") - - for { - select { - case ev, ok := <-events: - if !ok { - return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: cw.Disconnected()} - } - if ev.err != nil { - if disconnect, handled := handleStreamReadError(ev.err, cw.Disconnected(), "antigravity upstream"); handled { - return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: disconnect} - } - logger.LegacyPrintf("service.antigravity_gateway", "Stream read error (antigravity upstream): %v", ev.err) - return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs} - } - - lastDataAt = time.Now() - - line := ev.line - - // 记录首 token 时间 - if firstTokenMs == nil && len(line) > 0 { - ms := int(time.Since(startTime).Milliseconds()) - firstTokenMs = &ms - } - - // 尝试从 message_delta 或 message_stop 事件提取 usage - s.extractSSEUsage(line, usage) - - // 透传行 - cw.Fprintf("%s\n", line) - - case <-intervalCh: - lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) - if time.Since(lastRead) < streamInterval { - continue - } - if cw.Disconnected() { - logger.LegacyPrintf("service.antigravity_gateway", "Upstream timeout after client disconnect (antigravity upstream), returning collected usage") - return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true} - } - logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity upstream)") - return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs} - - case <-keepaliveCh: - if cw.Disconnected() { - continue - } - if time.Since(lastDataAt) < keepaliveInterval { - continue - } - // SSE ping 事件:Anthropic 原生格式,客户端会正确处理, - // 同时保持连接活跃防止 Cloudflare Tunnel 等代理断开 - if !cw.Fprintf("event: ping\ndata: {\"type\": \"ping\"}\n\n") { - logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during keepalive ping (antigravity upstream), continuing to drain upstream for billing") - continue - } - } - } -} - -// extractSSEUsage 从 SSE data 行中提取 Claude usage(用于流式透传场景) -// -// Anthropic streaming 的 usage 字段分布在两类事件中: -// - message_start:嵌套在 event.message.usage(input_tokens、cache_creation_input_tokens、 -// cache_read_input_tokens 等输入侧字段) -// - message_delta:位于顶层 event.usage(流结束时的最终 output_tokens) -// -// 仅读取顶层 event.usage 会漏掉 message_start 的输入侧字段,导致流式透传请求落库的 -// usage_logs 记录 input_tokens=0。 -func (s *AntigravityGatewayService) extractSSEUsage(line string, usage *ClaudeUsage) { - if !strings.HasPrefix(line, "data: ") { - return - } - dataStr := strings.TrimPrefix(line, "data: ") - var event map[string]any - if json.Unmarshal([]byte(dataStr), &event) != nil { - return - } - var u map[string]any - if eventType, _ := event["type"].(string); eventType == "message_start" { - if msg, ok := event["message"].(map[string]any); ok { - u, _ = msg["usage"].(map[string]any) - } - } else { - u, _ = event["usage"].(map[string]any) - } - if u == nil { - return - } - if v, ok := u["input_tokens"].(float64); ok && int(v) > 0 { - usage.InputTokens = int(v) - } - if v, ok := u["output_tokens"].(float64); ok && int(v) > 0 { - usage.OutputTokens = int(v) - } - if v, ok := u["cache_read_input_tokens"].(float64); ok && int(v) > 0 { - usage.CacheReadInputTokens = int(v) - } - if v, ok := u["cache_creation_input_tokens"].(float64); ok && int(v) > 0 { - usage.CacheCreationInputTokens = int(v) - } - // 解析嵌套的 cache_creation 对象中的 5m/1h 明细 - if cc, ok := u["cache_creation"].(map[string]any); ok { - if v, ok := cc["ephemeral_5m_input_tokens"].(float64); ok { - usage.CacheCreation5mTokens = int(v) - } - if v, ok := cc["ephemeral_1h_input_tokens"].(float64); ok { - usage.CacheCreation1hTokens = int(v) - } - } -} - -// extractClaudeUsage 从非流式 Claude 响应提取 usage -func (s *AntigravityGatewayService) extractClaudeUsage(body []byte) *ClaudeUsage { - usage := &ClaudeUsage{} - var resp map[string]any - if json.Unmarshal(body, &resp) != nil { - return usage - } - if u, ok := resp["usage"].(map[string]any); ok { - if v, ok := u["input_tokens"].(float64); ok { - usage.InputTokens = int(v) - } - if v, ok := u["output_tokens"].(float64); ok { - usage.OutputTokens = int(v) - } - if v, ok := u["cache_read_input_tokens"].(float64); ok { - usage.CacheReadInputTokens = int(v) - } - if v, ok := u["cache_creation_input_tokens"].(float64); ok { - usage.CacheCreationInputTokens = int(v) - } - // 解析嵌套的 cache_creation 对象中的 5m/1h 明细 - if cc, ok := u["cache_creation"].(map[string]any); ok { - if v, ok := cc["ephemeral_5m_input_tokens"].(float64); ok { - usage.CacheCreation5mTokens = int(v) - } - if v, ok := cc["ephemeral_1h_input_tokens"].(float64); ok { - usage.CacheCreation1hTokens = int(v) - } - } - } - return usage -} diff --git a/backend/internal/service/antigravity_gateway_streaming.go b/backend/internal/service/antigravity_gateway_streaming.go new file mode 100644 index 0000000000..1a6c59f617 --- /dev/null +++ b/backend/internal/service/antigravity_gateway_streaming.go @@ -0,0 +1,1150 @@ +package service + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/gin-gonic/gin" +) + +type antigravityStreamResult struct { + usage *ClaudeUsage + firstTokenMs *int + clientDisconnect bool // 客户端是否在流式传输过程中断开 +} + +// antigravityClientWriter 封装流式响应的客户端写入,自动检测断开并标记。 +// 断开后所有写入操作变为 no-op,调用方通过 Disconnected() 判断是否继续 drain 上游。 +type antigravityClientWriter struct { + w gin.ResponseWriter + flusher http.Flusher + disconnected bool + prefix string // 日志前缀,标识来源方法 +} + +func newAntigravityClientWriter(w gin.ResponseWriter, flusher http.Flusher, prefix string) *antigravityClientWriter { + return &antigravityClientWriter{w: w, flusher: flusher, prefix: prefix} +} + +// Write 写入数据到客户端,写入失败时标记断开并返回 false +func (cw *antigravityClientWriter) Write(p []byte) bool { + if cw.disconnected { + return false + } + if _, err := cw.w.Write(p); err != nil { + cw.markDisconnected() + return false + } + cw.flusher.Flush() + return true +} + +// Fprintf 格式化写入数据到客户端,写入失败时标记断开并返回 false +func (cw *antigravityClientWriter) Fprintf(format string, args ...any) bool { + if cw.disconnected { + return false + } + if _, err := fmt.Fprintf(cw.w, format, args...); err != nil { + cw.markDisconnected() + return false + } + cw.flusher.Flush() + return true +} + +func (cw *antigravityClientWriter) Disconnected() bool { return cw.disconnected } + +func (cw *antigravityClientWriter) markDisconnected() { + cw.disconnected = true + logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during streaming (%s), continuing to drain upstream for billing", cw.prefix) +} + +// handleStreamReadError 处理上游读取错误的通用逻辑。 +// 返回 (clientDisconnect, handled):handled=true 表示错误已处理,调用方应返回已收集的 usage。 +func handleStreamReadError(err error, clientDisconnected bool, prefix string) (disconnect bool, handled bool) { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + logger.LegacyPrintf("service.antigravity_gateway", "Context canceled during streaming (%s), returning collected usage", prefix) + return true, true + } + if clientDisconnected { + logger.LegacyPrintf("service.antigravity_gateway", "Upstream read error after client disconnect (%s): %v, returning collected usage", prefix, err) + return true, true + } + return false, false +} + +func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) { + c.Status(resp.StatusCode) + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "text/event-stream; charset=utf-8" + } + c.Header("Content-Type", contentType) + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + return nil, errors.New("streaming not supported") + } + + // 使用 Scanner 并限制单行大小,避免 ReadString 无上限导致 OOM + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.settingService.cfg.Gateway.MaxLineSize + } + scanBuf := getSSEScannerBuf64K() + scanner.Buffer(scanBuf[:0], maxLineSize) + usage := &ClaudeUsage{} + var firstTokenMs *int + + type scanEvent struct { + line string + err error + } + // 独立 goroutine 读取上游,避免读取阻塞影响超时处理 + events := make(chan scanEvent, 16) + done := make(chan struct{}) + sendEvent := func(ev scanEvent) bool { + select { + case events <- ev: + return true + case <-done: + return false + } + } + var lastReadAt int64 + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + go func(scanBuf *sseScannerBuf64K) { + defer putSSEScannerBuf64K(scanBuf) + defer close(events) + for scanner.Scan() { + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + if !sendEvent(scanEvent{line: scanner.Text()}) { + return + } + } + if err := scanner.Err(); err != nil { + _ = sendEvent(scanEvent{err: err}) + } + }(scanBuf) + defer close(done) + + // 上游数据间隔超时保护(防止上游挂起长期占用连接) + streamInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamDataIntervalTimeout > 0 { + streamInterval = time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second + } + var intervalTicker *time.Ticker + if streamInterval > 0 { + intervalTicker = time.NewTicker(streamInterval) + defer intervalTicker.Stop() + } + var intervalCh <-chan time.Time + if intervalTicker != nil { + intervalCh = intervalTicker.C + } + + // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 + keepaliveInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + var keepaliveTicker *time.Ticker + if keepaliveInterval > 0 { + keepaliveTicker = time.NewTicker(keepaliveInterval) + defer keepaliveTicker.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTicker != nil { + keepaliveCh = keepaliveTicker.C + } + lastDataAt := time.Now() + + cw := newAntigravityClientWriter(c.Writer, flusher, "antigravity gemini") + + // 仅发送一次错误事件,避免多次写入导致协议混乱 + errorEventSent := false + sendErrorEvent := func(reason string) { + if errorEventSent || cw.Disconnected() { + return + } + errorEventSent = true + _, _ = fmt.Fprintf(c.Writer, "event: error\ndata: {\"error\":\"%s\"}\n\n", reason) + flusher.Flush() + } + + for { + select { + case ev, ok := <-events: + if !ok { + return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: cw.Disconnected()}, nil + } + if ev.err != nil { + if disconnect, handled := handleStreamReadError(ev.err, cw.Disconnected(), "antigravity gemini"); handled { + return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: disconnect}, nil + } + if errors.Is(ev.err, bufio.ErrTooLong) { + logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity): max_size=%d error=%v", maxLineSize, ev.err) + sendErrorEvent("response_too_large") + return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, ev.err + } + sendErrorEvent("stream_read_error") + return nil, ev.err + } + + lastDataAt = time.Now() + + line := ev.line + trimmed := strings.TrimRight(line, "\r\n") + if strings.HasPrefix(trimmed, "data:") { + payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + if payload == "" || payload == "[DONE]" { + cw.Fprintf("%s\n", line) + continue + } + + // 解包 v1internal 响应 + inner, parseErr := s.unwrapV1InternalResponse([]byte(payload)) + if parseErr == nil && inner != nil { + payload = string(inner) + } + + // 解析 usage + if u := extractGeminiUsage(inner); u != nil { + usage = u + } + var parsed map[string]any + if json.Unmarshal(inner, &parsed) == nil { + // Check for MALFORMED_FUNCTION_CALL + if candidates, ok := parsed["candidates"].([]any); ok && len(candidates) > 0 { + if cand, ok := candidates[0].(map[string]any); ok { + if fr, ok := cand["finishReason"].(string); ok && fr == "MALFORMED_FUNCTION_CALL" { + logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] MALFORMED_FUNCTION_CALL detected in forward stream") + if content, ok := cand["content"]; ok { + if b, err := json.Marshal(content); err == nil { + logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Malformed content: %s", string(b)) + } + } + } + } + } + } + + if firstTokenMs == nil { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + + cw.Fprintf("data: %s\n\n", payload) + continue + } + + cw.Fprintf("%s\n", line) + + case <-intervalCh: + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) < streamInterval { + continue + } + if cw.Disconnected() { + logger.LegacyPrintf("service.antigravity_gateway", "Upstream timeout after client disconnect (antigravity gemini), returning collected usage") + return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil + } + logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity)") + sendErrorEvent("stream_timeout") + return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout") + + case <-keepaliveCh: + if cw.Disconnected() { + continue + } + if time.Since(lastDataAt) < keepaliveInterval { + continue + } + // SSE ping/keepalive:保持连接活跃防止 Cloudflare Tunnel 等代理断开 + if !cw.Fprintf(":\n\n") { + logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during keepalive ping (antigravity gemini), continuing to drain upstream for billing") + continue + } + } + } +} + +// handleGeminiStreamToNonStreaming 读取上游流式响应,合并为非流式响应返回给客户端 +// Gemini 流式响应是增量的,需要累积所有 chunk 的内容 +func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) { + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.settingService.cfg.Gateway.MaxLineSize + } + scanBuf := getSSEScannerBuf64K() + scanner.Buffer(scanBuf[:0], maxLineSize) + + usage := &ClaudeUsage{} + var firstTokenMs *int + var last map[string]any + var lastWithParts map[string]any + var collectedImageParts []map[string]any // 收集所有包含图片的 parts + var collectedTextParts []string // 收集所有文本片段 + + type scanEvent struct { + line string + err error + } + + // 独立 goroutine 读取上游,避免读取阻塞影响超时处理 + events := make(chan scanEvent, 16) + done := make(chan struct{}) + sendEvent := func(ev scanEvent) bool { + select { + case events <- ev: + return true + case <-done: + return false + } + } + + var lastReadAt int64 + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + go func(scanBuf *sseScannerBuf64K) { + defer putSSEScannerBuf64K(scanBuf) + defer close(events) + for scanner.Scan() { + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + if !sendEvent(scanEvent{line: scanner.Text()}) { + return + } + } + if err := scanner.Err(); err != nil { + _ = sendEvent(scanEvent{err: err}) + } + }(scanBuf) + defer close(done) + + // 上游数据间隔超时保护(防止上游挂起长期占用连接) + streamInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamDataIntervalTimeout > 0 { + streamInterval = time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second + } + var intervalTicker *time.Ticker + if streamInterval > 0 { + intervalTicker = time.NewTicker(streamInterval) + defer intervalTicker.Stop() + } + var intervalCh <-chan time.Time + if intervalTicker != nil { + intervalCh = intervalTicker.C + } + + for { + select { + case ev, ok := <-events: + if !ok { + // 流结束,返回收集的响应 + goto returnResponse + } + if ev.err != nil { + if errors.Is(ev.err, bufio.ErrTooLong) { + logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity non-stream): max_size=%d error=%v", maxLineSize, ev.err) + } + return nil, ev.err + } + + line := ev.line + trimmed := strings.TrimRight(line, "\r\n") + + if !strings.HasPrefix(trimmed, "data:") { + continue + } + + payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + if payload == "" || payload == "[DONE]" { + continue + } + + // 解包 v1internal 响应 + inner, parseErr := s.unwrapV1InternalResponse([]byte(payload)) + if parseErr != nil { + continue + } + + var parsed map[string]any + if err := json.Unmarshal(inner, &parsed); err != nil { + continue + } + + // 记录首 token 时间 + if firstTokenMs == nil { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + + last = parsed + + // 提取 usage + if u := extractGeminiUsage(inner); u != nil { + usage = u + } + + // Check for MALFORMED_FUNCTION_CALL + if candidates, ok := parsed["candidates"].([]any); ok && len(candidates) > 0 { + if cand, ok := candidates[0].(map[string]any); ok { + if fr, ok := cand["finishReason"].(string); ok && fr == "MALFORMED_FUNCTION_CALL" { + logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] MALFORMED_FUNCTION_CALL detected in forward non-stream collect") + if content, ok := cand["content"]; ok { + if b, err := json.Marshal(content); err == nil { + logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Malformed content: %s", string(b)) + } + } + } + } + } + + // 保留最后一个有 parts 的响应 + if parts := extractGeminiParts(parsed); len(parts) > 0 { + lastWithParts = parsed + // 收集包含图片和文本的 parts + for _, part := range parts { + if inlineData, ok := part["inlineData"].(map[string]any); ok { + collectedImageParts = append(collectedImageParts, part) + _ = inlineData // 避免 unused 警告 + } + if text, ok := part["text"].(string); ok && text != "" { + collectedTextParts = append(collectedTextParts, text) + } + } + } + + case <-intervalCh: + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) < streamInterval { + continue + } + logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity non-stream)") + return nil, fmt.Errorf("stream data interval timeout") + } + } + +returnResponse: + // 选择最后一个有效响应 + finalResponse := pickGeminiCollectResult(last, lastWithParts) + + // 处理空响应情况 — 触发同账号重试 + failover 切换账号 + if last == nil && lastWithParts == nil { + logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] warning: empty stream response (gemini non-stream), triggering failover") + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: []byte(`{"error":"empty stream response from upstream"}`), + RetryableOnSameAccount: true, + } + } + + // 如果收集到了图片 parts,需要合并到最终响应中 + if len(collectedImageParts) > 0 { + finalResponse = mergeImagePartsToResponse(finalResponse, collectedImageParts) + } + + // 如果收集到了文本,需要合并到最终响应中 + if len(collectedTextParts) > 0 { + finalResponse = mergeTextPartsToResponse(finalResponse, collectedTextParts) + } + + respBody, err := json.Marshal(finalResponse) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + c.Data(http.StatusOK, "application/json", respBody) + + return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, nil +} + +// getOrCreateGeminiParts 获取 Gemini 响应的 parts 结构,返回深拷贝和更新回调 +func getOrCreateGeminiParts(response map[string]any) (result map[string]any, existingParts []any, setParts func([]any)) { + // 深拷贝 response + result = make(map[string]any) + for k, v := range response { + result[k] = v + } + + // 获取或创建 candidates + candidates, ok := result["candidates"].([]any) + if !ok || len(candidates) == 0 { + candidates = []any{map[string]any{}} + } + + // 获取第一个 candidate + candidate, ok := candidates[0].(map[string]any) + if !ok { + candidate = make(map[string]any) + candidates[0] = candidate + } + + // 获取或创建 content + content, ok := candidate["content"].(map[string]any) + if !ok { + content = map[string]any{"role": "model"} + candidate["content"] = content + } + + // 获取现有 parts + existingParts, ok = content["parts"].([]any) + if !ok { + existingParts = []any{} + } + + // 返回更新回调 + setParts = func(newParts []any) { + content["parts"] = newParts + result["candidates"] = candidates + } + + return result, existingParts, setParts +} + +// mergeCollectedPartsToResponse 将收集的所有 parts 合并到 Gemini 响应中 +// 这个函数会合并所有类型的 parts:text、thinking、functionCall、inlineData 等 +// 保持原始顺序,只合并连续的普通 text parts +func mergeCollectedPartsToResponse(response map[string]any, collectedParts []map[string]any) map[string]any { + if len(collectedParts) == 0 { + return response + } + + result, _, setParts := getOrCreateGeminiParts(response) + + // 合并策略: + // 1. 保持原始顺序 + // 2. 连续的普通 text parts 合并为一个 + // 3. thinking、functionCall、inlineData 等保持原样 + var mergedParts []any + var textBuffer strings.Builder + + flushTextBuffer := func() { + if textBuffer.Len() > 0 { + mergedParts = append(mergedParts, map[string]any{ + "text": textBuffer.String(), + }) + textBuffer.Reset() + } + } + + for _, part := range collectedParts { + // 检查是否是普通 text part + if text, ok := part["text"].(string); ok { + // 检查是否有 thought 标记 + if thought, _ := part["thought"].(bool); thought { + // thinking part,先刷新 text buffer,然后保留原样 + flushTextBuffer() + mergedParts = append(mergedParts, part) + } else { + // 普通 text,累积到 buffer + _, _ = textBuffer.WriteString(text) + } + } else { + // 非 text part(functionCall、inlineData 等),先刷新 text buffer,然后保留原样 + flushTextBuffer() + mergedParts = append(mergedParts, part) + } + } + + // 刷新剩余的 text + flushTextBuffer() + + setParts(mergedParts) + return result +} + +// mergeImagePartsToResponse 将收集到的图片 parts 合并到 Gemini 响应中 +func mergeImagePartsToResponse(response map[string]any, imageParts []map[string]any) map[string]any { + if len(imageParts) == 0 { + return response + } + + result, existingParts, setParts := getOrCreateGeminiParts(response) + + // 检查现有 parts 中是否已经有图片 + for _, p := range existingParts { + if pm, ok := p.(map[string]any); ok { + if _, hasInline := pm["inlineData"]; hasInline { + return result // 已有图片,不重复添加 + } + } + } + + // 添加收集到的图片 parts + for _, imgPart := range imageParts { + existingParts = append(existingParts, imgPart) + } + setParts(existingParts) + return result +} + +// mergeTextPartsToResponse 将收集到的文本合并到 Gemini 响应中 +func mergeTextPartsToResponse(response map[string]any, textParts []string) map[string]any { + if len(textParts) == 0 { + return response + } + + mergedText := strings.Join(textParts, "") + result, existingParts, setParts := getOrCreateGeminiParts(response) + + // 查找并更新第一个 text part,或创建新的 + newParts := make([]any, 0, len(existingParts)+1) + textUpdated := false + + for _, p := range existingParts { + pm, ok := p.(map[string]any) + if !ok { + newParts = append(newParts, p) + continue + } + if _, hasText := pm["text"]; hasText && !textUpdated { + // 用累积的文本替换 + newPart := make(map[string]any) + for k, v := range pm { + newPart[k] = v + } + newPart["text"] = mergedText + newParts = append(newParts, newPart) + textUpdated = true + } else { + newParts = append(newParts, pm) + } + } + + if !textUpdated { + newParts = append([]any{map[string]any{"text": mergedText}}, newParts...) + } + + setParts(newParts) + return result +} + +func (s *AntigravityGatewayService) writeClaudeError(c *gin.Context, status int, errType, message string) error { + MarkResponseCommitted(c) + c.JSON(status, gin.H{ + "type": "error", + "error": gin.H{"type": errType, "message": message}, + }) + return fmt.Errorf("%s", message) +} + +// WriteMappedClaudeError 导出版本,供 handler 层使用(如 fallback 错误处理) +func (s *AntigravityGatewayService) WriteMappedClaudeError(c *gin.Context, account *Account, upstreamStatus int, upstreamRequestID string, body []byte) error { + return s.writeMappedClaudeError(c, account, upstreamStatus, upstreamRequestID, body) +} + +func (s *AntigravityGatewayService) writeMappedClaudeError(c *gin.Context, account *Account, upstreamStatus int, upstreamRequestID string, body []byte) error { + MarkResponseCommitted(c) + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + logBody, maxBytes := s.getLogConfig() + upstreamDetail := s.getUpstreamErrorDetail(body) + setOpsUpstreamError(c, upstreamStatus, upstreamMsg, upstreamDetail) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: upstreamStatus, + UpstreamRequestID: upstreamRequestID, + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + + // 记录上游错误详情便于排障(可选:由配置控制;不回显到客户端) + if logBody { + logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] upstream_error status=%d body=%s", upstreamStatus, truncateForLog(body, maxBytes)) + } + + // 检查错误透传规则 + if ptStatus, ptErrType, ptErrMsg, matched := applyErrorPassthroughRule( + c, account.Platform, upstreamStatus, body, + 0, "", "", + ); matched { + c.JSON(ptStatus, gin.H{ + "type": "error", + "error": gin.H{"type": ptErrType, "message": ptErrMsg}, + }) + if upstreamMsg == "" { + return fmt.Errorf("upstream error: %d", upstreamStatus) + } + return fmt.Errorf("upstream error: %d message=%s", upstreamStatus, upstreamMsg) + } + + var statusCode int + var errType, errMsg string + + switch upstreamStatus { + case 400: + statusCode = http.StatusBadRequest + errType = "invalid_request_error" + errMsg = getPassthroughOrDefault(upstreamMsg, "Invalid request") + case 401: + statusCode = http.StatusBadGateway + errType = "authentication_error" + errMsg = "Upstream authentication failed" + case 403: + statusCode = http.StatusBadGateway + errType = "permission_error" + errMsg = "Upstream access forbidden" + case 429: + statusCode = http.StatusTooManyRequests + errType = "rate_limit_error" + errMsg = "Upstream rate limit exceeded" + case 529: + statusCode = http.StatusServiceUnavailable + errType = "overloaded_error" + errMsg = "Upstream service overloaded" + default: + statusCode = http.StatusBadGateway + errType = "upstream_error" + errMsg = "Upstream request failed" + } + + c.JSON(statusCode, gin.H{ + "type": "error", + "error": gin.H{"type": errType, "message": errMsg}, + }) + if upstreamMsg == "" { + return fmt.Errorf("upstream error: %d", upstreamStatus) + } + return fmt.Errorf("upstream error: %d message=%s", upstreamStatus, upstreamMsg) +} + +func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int, message string) error { + MarkResponseCommitted(c) + statusStr := "UNKNOWN" + switch status { + case 400: + statusStr = "INVALID_ARGUMENT" + case 404: + statusStr = "NOT_FOUND" + case 429: + statusStr = "RESOURCE_EXHAUSTED" + case 500: + statusStr = "INTERNAL" + case 502, 503: + statusStr = "UNAVAILABLE" + } + + c.JSON(status, gin.H{ + "error": gin.H{ + "code": status, + "message": message, + "status": statusStr, + }, + }) + return fmt.Errorf("%s", message) +} + +// handleClaudeStreamToNonStreaming 收集上游流式响应,转换为 Claude 非流式格式返回 +// 用于处理客户端非流式请求但上游只支持流式的情况 +func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) (*antigravityStreamResult, error) { + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.settingService.cfg.Gateway.MaxLineSize + } + scanBuf := getSSEScannerBuf64K() + scanner.Buffer(scanBuf[:0], maxLineSize) + + var firstTokenMs *int + var last map[string]any + var lastWithParts map[string]any + var collectedParts []map[string]any // 收集所有 parts(包括 text、thinking、functionCall、inlineData 等) + + type scanEvent struct { + line string + err error + } + + // 独立 goroutine 读取上游,避免读取阻塞影响超时处理 + events := make(chan scanEvent, 16) + done := make(chan struct{}) + sendEvent := func(ev scanEvent) bool { + select { + case events <- ev: + return true + case <-done: + return false + } + } + + var lastReadAt int64 + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + go func(scanBuf *sseScannerBuf64K) { + defer putSSEScannerBuf64K(scanBuf) + defer close(events) + for scanner.Scan() { + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + if !sendEvent(scanEvent{line: scanner.Text()}) { + return + } + } + if err := scanner.Err(); err != nil { + _ = sendEvent(scanEvent{err: err}) + } + }(scanBuf) + defer close(done) + + // 上游数据间隔超时保护(防止上游挂起长期占用连接) + streamInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamDataIntervalTimeout > 0 { + streamInterval = time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second + } + var intervalTicker *time.Ticker + if streamInterval > 0 { + intervalTicker = time.NewTicker(streamInterval) + defer intervalTicker.Stop() + } + var intervalCh <-chan time.Time + if intervalTicker != nil { + intervalCh = intervalTicker.C + } + + for { + select { + case ev, ok := <-events: + if !ok { + // 流结束,转换并返回响应 + goto returnResponse + } + if ev.err != nil { + if errors.Is(ev.err, bufio.ErrTooLong) { + logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity claude non-stream): max_size=%d error=%v", maxLineSize, ev.err) + } + return nil, ev.err + } + + line := ev.line + trimmed := strings.TrimRight(line, "\r\n") + + if !strings.HasPrefix(trimmed, "data:") { + continue + } + + payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + if payload == "" || payload == "[DONE]" { + continue + } + + // 解包 v1internal 响应 + inner, parseErr := s.unwrapV1InternalResponse([]byte(payload)) + if parseErr != nil { + continue + } + + var parsed map[string]any + if err := json.Unmarshal(inner, &parsed); err != nil { + continue + } + + // 记录首 token 时间 + if firstTokenMs == nil { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + + last = parsed + + // 保留最后一个有 parts 的响应,并收集所有 parts + if parts := extractGeminiParts(parsed); len(parts) > 0 { + lastWithParts = parsed + + // 收集所有 parts(text、thinking、functionCall、inlineData 等) + collectedParts = append(collectedParts, parts...) + } + + case <-intervalCh: + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) < streamInterval { + continue + } + logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity claude non-stream)") + return nil, fmt.Errorf("stream data interval timeout") + } + } + +returnResponse: + // 选择最后一个有效响应 + finalResponse := pickGeminiCollectResult(last, lastWithParts) + + // 处理空响应情况 — 触发同账号重试 + failover 切换账号 + if last == nil && lastWithParts == nil { + logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] warning: empty stream response (claude non-stream), triggering failover") + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: []byte(`{"error":"empty stream response from upstream"}`), + RetryableOnSameAccount: true, + } + } + + // 将收集的所有 parts 合并到最终响应中 + if len(collectedParts) > 0 { + finalResponse = mergeCollectedPartsToResponse(finalResponse, collectedParts) + } + + // 序列化为 JSON(Gemini 格式) + geminiBody, err := json.Marshal(finalResponse) + if err != nil { + return nil, fmt.Errorf("failed to marshal gemini response: %w", err) + } + + // 转换 Gemini 响应为 Claude 格式 + claudeResp, agUsage, err := antigravity.TransformGeminiToClaude(geminiBody, originalModel) + if err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] transform_error error=%v body=%s", err, string(geminiBody)) + return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response") + } + + c.Data(http.StatusOK, "application/json", claudeResp) + + // 转换为 service.ClaudeUsage + usage := &ClaudeUsage{ + InputTokens: agUsage.InputTokens, + OutputTokens: agUsage.OutputTokens, + CacheCreationInputTokens: agUsage.CacheCreationInputTokens, + CacheReadInputTokens: agUsage.CacheReadInputTokens, + } + + return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, nil +} + +// handleClaudeStreamingResponse 处理 Claude 流式响应(Gemini SSE → Claude SSE 转换) +func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) (*antigravityStreamResult, error) { + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + return nil, errors.New("streaming not supported") + } + + processor := antigravity.NewStreamingProcessor(originalModel) + var firstTokenMs *int + // 使用 Scanner 并限制单行大小,避免 ReadString 无上限导致 OOM + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.settingService.cfg.Gateway.MaxLineSize + } + scanBuf := getSSEScannerBuf64K() + scanner.Buffer(scanBuf[:0], maxLineSize) + + // 辅助函数:转换 antigravity.ClaudeUsage 到 service.ClaudeUsage + convertUsage := func(agUsage *antigravity.ClaudeUsage) *ClaudeUsage { + if agUsage == nil { + return &ClaudeUsage{} + } + return &ClaudeUsage{ + InputTokens: agUsage.InputTokens, + OutputTokens: agUsage.OutputTokens, + CacheCreationInputTokens: agUsage.CacheCreationInputTokens, + CacheReadInputTokens: agUsage.CacheReadInputTokens, + } + } + + type scanEvent struct { + line string + err error + } + // 独立 goroutine 读取上游,避免读取阻塞影响超时处理 + events := make(chan scanEvent, 16) + done := make(chan struct{}) + sendEvent := func(ev scanEvent) bool { + select { + case events <- ev: + return true + case <-done: + return false + } + } + var lastReadAt int64 + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + go func(scanBuf *sseScannerBuf64K) { + defer putSSEScannerBuf64K(scanBuf) + defer close(events) + for scanner.Scan() { + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + if !sendEvent(scanEvent{line: scanner.Text()}) { + return + } + } + if err := scanner.Err(); err != nil { + _ = sendEvent(scanEvent{err: err}) + } + }(scanBuf) + defer close(done) + + streamInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamDataIntervalTimeout > 0 { + streamInterval = time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second + } + var intervalTicker *time.Ticker + if streamInterval > 0 { + intervalTicker = time.NewTicker(streamInterval) + defer intervalTicker.Stop() + } + var intervalCh <-chan time.Time + if intervalTicker != nil { + intervalCh = intervalTicker.C + } + + // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 + keepaliveInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + var keepaliveTicker *time.Ticker + if keepaliveInterval > 0 { + keepaliveTicker = time.NewTicker(keepaliveInterval) + defer keepaliveTicker.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTicker != nil { + keepaliveCh = keepaliveTicker.C + } + lastDataAt := time.Now() + + cw := newAntigravityClientWriter(c.Writer, flusher, "antigravity claude") + + // 仅发送一次错误事件,避免多次写入导致协议混乱 + errorEventSent := false + sendErrorEvent := func(reason string) { + if errorEventSent || cw.Disconnected() { + return + } + errorEventSent = true + _, _ = fmt.Fprintf(c.Writer, "event: error\ndata: {\"error\":\"%s\"}\n\n", reason) + flusher.Flush() + } + + // finishUsage 是获取 processor 最终 usage 的辅助函数 + finishUsage := func() *ClaudeUsage { + _, agUsage := processor.Finish() + return convertUsage(agUsage) + } + + for { + select { + case ev, ok := <-events: + if !ok { + // 上游完成,发送结束事件 + finalEvents, agUsage := processor.Finish() + if len(finalEvents) > 0 { + cw.Write(finalEvents) + } else if !processor.MessageStartSent() && !cw.Disconnected() { + // 整个流未收到任何可解析的上游数据(全部 SSE 行均无法被 JSON 解析), + // 触发 failover 在同账号重试,避免向客户端发出缺少 message_start 的残缺流 + logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Claude-Stream] empty stream response (no valid events parsed), triggering failover") + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: []byte(`{"error":"empty stream response from upstream"}`), + RetryableOnSameAccount: true, + } + } + return &antigravityStreamResult{usage: convertUsage(agUsage), firstTokenMs: firstTokenMs, clientDisconnect: cw.Disconnected()}, nil + } + if ev.err != nil { + if disconnect, handled := handleStreamReadError(ev.err, cw.Disconnected(), "antigravity claude"); handled { + return &antigravityStreamResult{usage: finishUsage(), firstTokenMs: firstTokenMs, clientDisconnect: disconnect}, nil + } + if errors.Is(ev.err, bufio.ErrTooLong) { + logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity): max_size=%d error=%v", maxLineSize, ev.err) + sendErrorEvent("response_too_large") + return &antigravityStreamResult{usage: convertUsage(nil), firstTokenMs: firstTokenMs}, ev.err + } + sendErrorEvent("stream_read_error") + return nil, fmt.Errorf("stream read error: %w", ev.err) + } + + lastDataAt = time.Now() + + // 处理 SSE 行,转换为 Claude 格式 + claudeEvents := processor.ProcessLine(strings.TrimRight(ev.line, "\r\n")) + if len(claudeEvents) > 0 { + if firstTokenMs == nil { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + cw.Write(claudeEvents) + } + + case <-intervalCh: + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) < streamInterval { + continue + } + if cw.Disconnected() { + logger.LegacyPrintf("service.antigravity_gateway", "Upstream timeout after client disconnect (antigravity claude), returning collected usage") + return &antigravityStreamResult{usage: finishUsage(), firstTokenMs: firstTokenMs, clientDisconnect: true}, nil + } + logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity)") + sendErrorEvent("stream_timeout") + return &antigravityStreamResult{usage: convertUsage(nil), firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout") + + case <-keepaliveCh: + if cw.Disconnected() { + continue + } + if time.Since(lastDataAt) < keepaliveInterval { + continue + } + // SSE ping 事件:Anthropic 原生格式,客户端会正确处理, + // 同时保持连接活跃防止 Cloudflare Tunnel 等代理断开 + if !cw.Fprintf("event: ping\ndata: {\"type\": \"ping\"}\n\n") { + logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during keepalive ping (antigravity claude), continuing to drain upstream for billing") + continue + } + } + } +} + +func (s *AntigravityGatewayService) extractImageInputSize(body []byte) string { + var req antigravity.GeminiRequest + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + + if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil { + return strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize) + } + + return "" +} + +// isImageGenerationModel 判断模型是否为图片生成模型 +// 支持的模型:gemini-3.1-flash-image, gemini-3-pro-image, gemini-2.5-flash-image 等 +func isImageGenerationModel(model string) bool { + modelLower := strings.ToLower(model) + // 移除 models/ 前缀 + modelLower = strings.TrimPrefix(modelLower, "models/") + + // 精确匹配或前缀匹配 + return modelLower == "gemini-3.1-flash-image" || + modelLower == "gemini-3.1-flash-image-preview" || + strings.HasPrefix(modelLower, "gemini-3.1-flash-image-") || + modelLower == "gemini-3-pro-image" || + modelLower == "gemini-3-pro-image-preview" || + strings.HasPrefix(modelLower, "gemini-3-pro-image-") || + modelLower == "gemini-2.5-flash-image" || + modelLower == "gemini-2.5-flash-image-preview" || + strings.HasPrefix(modelLower, "gemini-2.5-flash-image-") +} diff --git a/backend/internal/service/antigravity_gateway_upstream.go b/backend/internal/service/antigravity_gateway_upstream.go new file mode 100644 index 0000000000..2914863131 --- /dev/null +++ b/backend/internal/service/antigravity_gateway_upstream.go @@ -0,0 +1,375 @@ +package service + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/gin-gonic/gin" +) + +// ForwardUpstream 使用 base_url + /v1/messages + 双 header 认证透传上游 Claude 请求 +func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { + startTime := time.Now() + sessionID := getSessionID(c) + prefix := logPrefix(sessionID, account.Name) + + // 获取上游配置 + baseURL := strings.TrimSpace(account.GetCredential("base_url")) + apiKey := strings.TrimSpace(account.GetCredential("api_key")) + if baseURL == "" || apiKey == "" { + return nil, fmt.Errorf("upstream account missing base_url or api_key") + } + baseURL = strings.TrimSuffix(baseURL, "/") + + // 解析请求获取模型信息 + var claudeReq antigravity.ClaudeRequest + if err := json.Unmarshal(body, &claudeReq); err != nil { + return nil, fmt.Errorf("parse claude request: %w", err) + } + if strings.TrimSpace(claudeReq.Model) == "" { + return nil, fmt.Errorf("missing model") + } + originalModel := claudeReq.Model + + // 构建上游请求 URL + upstreamURL := baseURL + "/v1/messages" + + // 能力维度 sanitize:Anthropic-compatible 上游透传路径也需要保证 body↔beta header + // 对称。客户端 anthropic-beta header 不含 context-management-2025-06-27 但 body 带 + // context_management 时 strip,与 Anthropic 直连 / Bedrock / Vertex 路径保持一致。 + clientBeta := c.GetHeader("anthropic-beta") + if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed { + body = sanitized + } + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create upstream request: %w", err) + } + + // 设置请求头 + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("x-api-key", apiKey) // Claude API 兼容 + + // 透传 Claude 相关 headers + if v := c.GetHeader("anthropic-version"); v != "" { + req.Header.Set("anthropic-version", v) + } + if v := clientBeta; v != "" { + req.Header.Set("anthropic-beta", v) + } + + // 代理 URL + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + // 发送请求 + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + logger.LegacyPrintf("service.antigravity_gateway", "%s upstream request failed: %v", prefix, err) + return nil, fmt.Errorf("upstream request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // 处理错误响应 + if resp.StatusCode >= 400 { + respBody := s.readUpstreamErrorBody(resp) + + // 429 错误时标记账号限流 + if resp.StatusCode == http.StatusTooManyRequests { + s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, 0, "", false) + } + + // 透传上游错误 + c.Header("Content-Type", resp.Header.Get("Content-Type")) + c.Status(resp.StatusCode) + _, _ = c.Writer.Write(respBody) + + return &ForwardResult{ + Model: originalModel, + }, nil + } + + // 处理成功响应(流式/非流式) + var usage *ClaudeUsage + var firstTokenMs *int + var clientDisconnect bool + + if claudeReq.Stream { + // 流式响应:透传 + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + + streamRes := s.streamUpstreamResponse(c, resp, startTime) + usage = streamRes.usage + firstTokenMs = streamRes.firstTokenMs + clientDisconnect = streamRes.clientDisconnect + } else { + // 非流式响应:直接透传 + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read upstream response: %w", err) + } + + // 提取 usage + usage = s.extractClaudeUsage(respBody) + + c.Header("Content-Type", resp.Header.Get("Content-Type")) + c.Status(http.StatusOK) + _, _ = c.Writer.Write(respBody) + } + + // 构建计费结果 + duration := time.Since(startTime) + logger.LegacyPrintf("service.antigravity_gateway", "%s status=success duration_ms=%d", prefix, duration.Milliseconds()) + + return &ForwardResult{ + Model: originalModel, + Stream: claudeReq.Stream, + Duration: duration, + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, + Usage: ClaudeUsage{ + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + CacheReadInputTokens: usage.CacheReadInputTokens, + CacheCreationInputTokens: usage.CacheCreationInputTokens, + }, + }, nil +} + +// streamUpstreamResponse 透传上游 SSE 流并提取 Claude usage +func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp *http.Response, startTime time.Time) *antigravityStreamResult { + usage := &ClaudeUsage{} + var firstTokenMs *int + + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.settingService.cfg.Gateway.MaxLineSize + } + scanner.Buffer(make([]byte, 64*1024), maxLineSize) + + type scanEvent struct { + line string + err error + } + events := make(chan scanEvent, 16) + done := make(chan struct{}) + sendEvent := func(ev scanEvent) bool { + select { + case events <- ev: + return true + case <-done: + return false + } + } + var lastReadAt int64 + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + go func() { + defer close(events) + for scanner.Scan() { + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + if !sendEvent(scanEvent{line: scanner.Text()}) { + return + } + } + if err := scanner.Err(); err != nil { + _ = sendEvent(scanEvent{err: err}) + } + }() + defer close(done) + + streamInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamDataIntervalTimeout > 0 { + streamInterval = time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second + } + var intervalTicker *time.Ticker + if streamInterval > 0 { + intervalTicker = time.NewTicker(streamInterval) + defer intervalTicker.Stop() + } + var intervalCh <-chan time.Time + if intervalTicker != nil { + intervalCh = intervalTicker.C + } + + // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 + keepaliveInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + var keepaliveTicker *time.Ticker + if keepaliveInterval > 0 { + keepaliveTicker = time.NewTicker(keepaliveInterval) + defer keepaliveTicker.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTicker != nil { + keepaliveCh = keepaliveTicker.C + } + lastDataAt := time.Now() + + flusher, _ := c.Writer.(http.Flusher) + cw := newAntigravityClientWriter(c.Writer, flusher, "antigravity upstream") + + for { + select { + case ev, ok := <-events: + if !ok { + return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: cw.Disconnected()} + } + if ev.err != nil { + if disconnect, handled := handleStreamReadError(ev.err, cw.Disconnected(), "antigravity upstream"); handled { + return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: disconnect} + } + logger.LegacyPrintf("service.antigravity_gateway", "Stream read error (antigravity upstream): %v", ev.err) + return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs} + } + + lastDataAt = time.Now() + + line := ev.line + + // 记录首 token 时间 + if firstTokenMs == nil && len(line) > 0 { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + + // 尝试从 message_delta 或 message_stop 事件提取 usage + s.extractSSEUsage(line, usage) + + // 透传行 + cw.Fprintf("%s\n", line) + + case <-intervalCh: + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) < streamInterval { + continue + } + if cw.Disconnected() { + logger.LegacyPrintf("service.antigravity_gateway", "Upstream timeout after client disconnect (antigravity upstream), returning collected usage") + return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true} + } + logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity upstream)") + return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs} + + case <-keepaliveCh: + if cw.Disconnected() { + continue + } + if time.Since(lastDataAt) < keepaliveInterval { + continue + } + // SSE ping 事件:Anthropic 原生格式,客户端会正确处理, + // 同时保持连接活跃防止 Cloudflare Tunnel 等代理断开 + if !cw.Fprintf("event: ping\ndata: {\"type\": \"ping\"}\n\n") { + logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during keepalive ping (antigravity upstream), continuing to drain upstream for billing") + continue + } + } + } +} + +// extractSSEUsage 从 SSE data 行中提取 Claude usage(用于流式透传场景) +// +// Anthropic streaming 的 usage 字段分布在两类事件中: +// - message_start:嵌套在 event.message.usage(input_tokens、cache_creation_input_tokens、 +// cache_read_input_tokens 等输入侧字段) +// - message_delta:位于顶层 event.usage(流结束时的最终 output_tokens) +// +// 仅读取顶层 event.usage 会漏掉 message_start 的输入侧字段,导致流式透传请求落库的 +// usage_logs 记录 input_tokens=0。 +func (s *AntigravityGatewayService) extractSSEUsage(line string, usage *ClaudeUsage) { + if !strings.HasPrefix(line, "data: ") { + return + } + dataStr := strings.TrimPrefix(line, "data: ") + var event map[string]any + if json.Unmarshal([]byte(dataStr), &event) != nil { + return + } + var u map[string]any + if eventType, _ := event["type"].(string); eventType == "message_start" { + if msg, ok := event["message"].(map[string]any); ok { + u, _ = msg["usage"].(map[string]any) + } + } else { + u, _ = event["usage"].(map[string]any) + } + if u == nil { + return + } + if v, ok := u["input_tokens"].(float64); ok && int(v) > 0 { + usage.InputTokens = int(v) + } + if v, ok := u["output_tokens"].(float64); ok && int(v) > 0 { + usage.OutputTokens = int(v) + } + if v, ok := u["cache_read_input_tokens"].(float64); ok && int(v) > 0 { + usage.CacheReadInputTokens = int(v) + } + if v, ok := u["cache_creation_input_tokens"].(float64); ok && int(v) > 0 { + usage.CacheCreationInputTokens = int(v) + } + // 解析嵌套的 cache_creation 对象中的 5m/1h 明细 + if cc, ok := u["cache_creation"].(map[string]any); ok { + if v, ok := cc["ephemeral_5m_input_tokens"].(float64); ok { + usage.CacheCreation5mTokens = int(v) + } + if v, ok := cc["ephemeral_1h_input_tokens"].(float64); ok { + usage.CacheCreation1hTokens = int(v) + } + } +} + +// extractClaudeUsage 从非流式 Claude 响应提取 usage +func (s *AntigravityGatewayService) extractClaudeUsage(body []byte) *ClaudeUsage { + usage := &ClaudeUsage{} + var resp map[string]any + if json.Unmarshal(body, &resp) != nil { + return usage + } + if u, ok := resp["usage"].(map[string]any); ok { + if v, ok := u["input_tokens"].(float64); ok { + usage.InputTokens = int(v) + } + if v, ok := u["output_tokens"].(float64); ok { + usage.OutputTokens = int(v) + } + if v, ok := u["cache_read_input_tokens"].(float64); ok { + usage.CacheReadInputTokens = int(v) + } + if v, ok := u["cache_creation_input_tokens"].(float64); ok { + usage.CacheCreationInputTokens = int(v) + } + // 解析嵌套的 cache_creation 对象中的 5m/1h 明细 + if cc, ok := u["cache_creation"].(map[string]any); ok { + if v, ok := cc["ephemeral_5m_input_tokens"].(float64); ok { + usage.CacheCreation5mTokens = int(v) + } + if v, ok := cc["ephemeral_1h_input_tokens"].(float64); ok { + usage.CacheCreation1hTokens = int(v) + } + } + } + return usage +} diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go new file mode 100644 index 0000000000..80604dd8ac --- /dev/null +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -0,0 +1,1210 @@ +package service + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/anthropicfp" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/google/uuid" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + + "github.com/gin-gonic/gin" +) + +type anthropicCacheControlPayload struct { + Type string `json:"type"` + TTL string `json:"ttl,omitempty"` +} + +type anthropicSystemTextBlockPayload struct { + Type string `json:"type"` + Text string `json:"text"` + CacheControl *anthropicCacheControlPayload `json:"cache_control,omitempty"` +} + +type anthropicMetadataPayload struct { + UserID string `json:"user_id"` +} + +// replaceModelInBody 替换请求体中的model字段 +// 优先使用定点修改,尽量保持客户端原始字段顺序。 +func (s *GatewayService) replaceModelInBody(body []byte, newModel string) []byte { + return ReplaceModelInBody(body, newModel) +} + +type claudeOAuthNormalizeOptions struct { + injectMetadata bool + metadataUserID string + stripSystemCacheControl bool +} + +// sanitizeSystemText rewrites only the fixed OpenCode identity sentence (if present). +// We intentionally avoid broad keyword replacement in system prompts to prevent +// accidentally changing user-provided instructions. +func sanitizeSystemText(text string) string { + if text == "" { + return text + } + // Some clients include a fixed OpenCode identity sentence. Anthropic may treat + // this as a non-Claude-Code fingerprint, so rewrite it to the canonical + // Claude Code banner before generic "OpenCode"/"opencode" replacements. + text = strings.ReplaceAll( + text, + "You are OpenCode, the best coding agent on the planet.", + strings.TrimSpace(claudeCodeSystemPrompt), + ) + return text +} + +func marshalAnthropicSystemTextBlock(text string, includeCacheControl bool) ([]byte, error) { + block := anthropicSystemTextBlockPayload{ + Type: "text", + Text: text, + } + if includeCacheControl { + block.CacheControl = &anthropicCacheControlPayload{ + Type: "ephemeral", + TTL: claude.DefaultCacheControlTTL, + } + } + return json.Marshal(block) +} + +func marshalAnthropicSystemTextBlockWithCacheControl(text string, cacheControl any) ([]byte, error) { + block := map[string]any{ + "type": "text", + "text": text, + } + if cacheControl != nil { + block["cache_control"] = cacheControl + } + return json.Marshal(block) +} + +func marshalAnthropicMetadata(userID string) ([]byte, error) { + return json.Marshal(anthropicMetadataPayload{UserID: userID}) +} + +func buildJSONArrayRaw(items [][]byte) []byte { + if len(items) == 0 { + return []byte("[]") + } + + total := 2 + for _, item := range items { + total += len(item) + } + total += len(items) - 1 + + buf := make([]byte, 0, total) + buf = append(buf, '[') + for i, item := range items { + if i > 0 { + buf = append(buf, ',') + } + buf = append(buf, item...) + } + buf = append(buf, ']') + return buf +} + +func setJSONValueBytes(body []byte, path string, value any) ([]byte, bool) { + next, err := sjson.SetBytes(body, path, value) + if err != nil { + return body, false + } + return next, true +} + +func setJSONRawBytes(body []byte, path string, raw []byte) ([]byte, bool) { + next, err := sjson.SetRawBytes(body, path, raw) + if err != nil { + return body, false + } + return next, true +} + +func deleteJSONPathBytes(body []byte, path string) ([]byte, bool) { + next, err := sjson.DeleteBytes(body, path) + if err != nil { + return body, false + } + return next, true +} + +func normalizeClaudeOAuthSystemBody(body []byte, opts claudeOAuthNormalizeOptions) ([]byte, bool) { + sys := gjson.GetBytes(body, "system") + if !sys.Exists() { + return body, false + } + + out := body + modified := false + + switch { + case sys.Type == gjson.String: + sanitized := sanitizeSystemText(sys.String()) + if sanitized != sys.String() { + if next, ok := setJSONValueBytes(out, "system", sanitized); ok { + out = next + modified = true + } + } + case sys.IsArray(): + index := 0 + sys.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "text" { + textResult := item.Get("text") + if textResult.Exists() && textResult.Type == gjson.String { + text := textResult.String() + sanitized := sanitizeSystemText(text) + if sanitized != text { + if next, ok := setJSONValueBytes(out, fmt.Sprintf("system.%d.text", index), sanitized); ok { + out = next + modified = true + } + } + } + } + + if opts.stripSystemCacheControl && item.Get("cache_control").Exists() { + if next, ok := deleteJSONPathBytes(out, fmt.Sprintf("system.%d.cache_control", index)); ok { + out = next + modified = true + } + } + + index++ + return true + }) + } + + return out, modified +} + +func ensureClaudeOAuthMetadataUserID(body []byte, userID string) ([]byte, bool) { + if strings.TrimSpace(userID) == "" { + return body, false + } + + metadata := gjson.GetBytes(body, "metadata") + if !metadata.Exists() || metadata.Type == gjson.Null { + raw, err := marshalAnthropicMetadata(userID) + if err != nil { + return body, false + } + return setJSONRawBytes(body, "metadata", raw) + } + + trimmedRaw := strings.TrimSpace(metadata.Raw) + if strings.HasPrefix(trimmedRaw, "{") { + existing := metadata.Get("user_id") + if existing.Exists() && existing.Type == gjson.String && existing.String() != "" { + return body, false + } + return setJSONValueBytes(body, "metadata.user_id", userID) + } + + raw, err := marshalAnthropicMetadata(userID) + if err != nil { + return body, false + } + return setJSONRawBytes(body, "metadata", raw) +} + +func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAuthNormalizeOptions) ([]byte, string) { + if len(body) == 0 { + return body, modelID + } + + out := body + modified := false + + if next, changed := normalizeClaudeOAuthSystemBody(out, opts); changed { + out = next + modified = true + } + + rawModel := gjson.GetBytes(out, "model") + if rawModel.Exists() && rawModel.Type == gjson.String { + normalized := claude.NormalizeModelID(rawModel.String()) + if normalized != rawModel.String() { + if next, ok := setJSONValueBytes(out, "model", normalized); ok { + out = next + modified = true + } + modelID = normalized + } + } + + // 确保 tools 字段存在(即使为空数组) + if !gjson.GetBytes(out, "tools").Exists() { + if next, ok := setJSONRawBytes(out, "tools", []byte("[]")); ok { + out = next + modified = true + } + } + + if opts.injectMetadata && opts.metadataUserID != "" { + if next, changed := ensureClaudeOAuthMetadataUserID(out, opts.metadataUserID); changed { + out = next + modified = true + } + } + + // temperature:真实 Claude Code CLI 总是发送 temperature(默认 1,客户端可覆盖)。 + // 之前的实现直接 delete 会导致 payload 缺字段,与真实 CLI 字节级不一致。 + // 策略:客户端传了什么就透传;没传则补默认 1。 + if !gjson.GetBytes(out, "temperature").Exists() { + if next, ok := setJSONValueBytes(out, "temperature", 1); ok { + out = next + modified = true + } + } + + // max_tokens:真实 CLI 的默认值是 128000。缺失时补齐以对齐指纹。 + if !gjson.GetBytes(out, "max_tokens").Exists() { + if next, ok := setJSONValueBytes(out, "max_tokens", 128000); ok { + out = next + modified = true + } + } + + // context_management:thinking.type 为 enabled/adaptive 时,真实 CLI 会自动 + // 附带 {"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}。 + // 客户端显式传了就透传;否则按 CLI 行为补齐。 + // + // 注:本函数不按 model 名决定是否保留 context_management。“最终 beta + // header 不含 context-management-2025-06-27 时 strip 字段”的能力维度 + // 对称约束由 sanitizeAnthropicBodyForBetaTokens 在 buildUpstreamRequest / + // buildCountTokensRequest 层统一执行,与 Bedrock 路径的 + // sanitizeBedrockFieldsForBetaTokens 对称。 + if !gjson.GetBytes(out, "context_management").Exists() { + thinkingType := gjson.GetBytes(out, "thinking.type").String() + if thinkingType == "enabled" || thinkingType == "adaptive" { + const cmDefault = `{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}` + if next, ok := setJSONRawBytes(out, "context_management", []byte(cmDefault)); ok { + out = next + modified = true + } + } + } + + // tool_choice:与 Parrot 对齐,不再无条件删除。 + // - 客户端传了 {"type":"tool","name":"X"} → 保留结构,name 由 + // applyToolNameRewriteToBody 同步映射为假名 + // - 其他形态(auto/any/none)原样透传 + // 如果 body 里完全没有 tools(空数组),tool_choice 没意义时才删除 + if !gjson.GetBytes(out, "tools").IsArray() || len(gjson.GetBytes(out, "tools").Array()) == 0 { + if gjson.GetBytes(out, "tool_choice").Exists() { + if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok { + out = next + modified = true + } + } + } + + if !modified { + return body, modelID + } + + return out, modelID +} + +func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account *Account, fp *Fingerprint) string { + if parsed == nil || account == nil { + return "" + } + if parsed.MetadataUserID != "" { + return "" + } + + userID := strings.TrimSpace(account.GetClaudeUserID()) + if userID == "" && fp != nil { + userID = fp.ClientID + } + if userID == "" { + // Fall back to a random, well-formed client id so we can still satisfy + // Claude Code OAuth requirements when account metadata is incomplete. + userID = generateClientID() + } + + // session_id 用"会话级稳定种子"派生(账号 + 客户端区分因子 + 首条 user 文本): + // 随对话在尾部追加 messages 时保持不变,贴近真实 CC 进程级稳定的 session_id。 + // 不复用 GenerateSessionHash —— 后者是粘性路由键、按设计逐轮变化(见其测试)。 + var firstUserText string + if parsed.Body != nil { + firstUserText = extractFirstUserText(parsed.Body.Bytes()) + } + seed := buildStableSessionSeed(account.ID, sessionContextDiscriminator(parsed.SessionContext), firstUserText) + sessionID := generateSessionUUID(seed) + + // 根据指纹 UA 版本选择输出格式 + var uaVersion string + if fp != nil { + uaVersion = ExtractCLIVersion(fp.UserAgent) + } + accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid")) + return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion) +} + +// applyClaudeCodeOAuthMimicryToBody 将"非 Claude Code 客户端 + Claude OAuth 账号" +// 路径上原本只在 /v1/messages 里做的完整伪装应用到任意 body 上。 +// +// 这是 /v1/messages 主路径上 rewriteSystemForNonClaudeCode + +// normalizeClaudeOAuthRequestBody 流程的通用版,供 OpenAI 协议兼容层 +// (ForwardAsChatCompletions / ForwardAsResponses) 复用。 +// +// 未抽离之前,OpenAI 协议兼容层仅做 injectClaudeCodePrompt(前置追加), +// 而仓内 /v1/messages 路径自己的注释明确说过"仅前置追加无法通过 Anthropic +// 第三方检测";那条注释就是本函数存在的根因。 +// +// 参数: +// - ctx / c:用于读取指纹和 gateway settings;c 可为 nil(如 count_tokens)。 +// - account:必须是 OAuth 账号,且调用方已判断不是 Claude Code 客户端。 +// - body:已经 marshal 成 Anthropic /v1/messages 格式的请求体。 +// - systemRaw:body 中原始 system 字段(用于判断是否需要 rewrite)。 +// - model:最终会发给上游的模型 ID(用于 haiku 旁路 + metadata 版本选择)。 +// +// 返回:改写后的 body。即使中间任何一步失败,也会退化成原 body(不会 panic)。 +func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + systemRaw any, + model string, +) []byte { + if account == nil || !account.IsOAuth() || len(body) == 0 { + return body + } + + systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) + systemRewritten := false + if systemPromptInjectionEnabled && !strings.Contains(strings.ToLower(model), "haiku") { + body = rewriteSystemForNonClaudeCodeWithPromptBlocks(body, normalizeSystemParam(systemRaw), systemPrompt, systemPromptBlocks) + systemRewritten = true + } + + normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} + + if s.identityService != nil && c != nil && c.Request != nil { + if fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header); err == nil && fp != nil { + mimicMPT := false + if s.settingService != nil { + _, mimicMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx) + } + if !mimicMPT { + if uid := s.buildOAuthMetadataUserIDFromBody(ctx, account, fp, body); uid != "" { + normalizeOpts.injectMetadata = true + normalizeOpts.metadataUserID = uid + } + } + } + } + + body, _ = normalizeClaudeOAuthRequestBody(body, model, normalizeOpts) + + // Phase D+E+F: messages cache 策略 + 工具名混淆 + tools[-1] 断点 + // 对齐 Parrot transform_request 里剩余的字段级改写。顺序有语义约束: + // 1) messages cache:仅在配置开启时清除客户端断点并注入代理断点 + // 2) tool rewrite:最后改 tools[*].name / tool_choice.name 并在 tools[-1] + // 上打断点;mapping 存入 gin.Context 供响应侧 bytes.Replace 还原。 + body = s.rewriteMessageCacheControlIfEnabled(ctx, body) + + if rw := buildToolNameRewriteFromBody(body); rw != nil { + body = applyToolNameRewriteToBody(body, rw) + if c != nil { + c.Set(toolNameRewriteKey, rw) + } + } else { + body = applyToolsLastCacheBreakpoint(body) + } + + return body +} + +// buildOAuthMetadataUserIDFromBody 是 buildOAuthMetadataUserID 的变体, +// 适用于调用方手上没有 ParsedRequest 的场景(如 OpenAI 协议兼容层)。 +// +// 与 buildOAuthMetadataUserID 的唯一区别: +// - session hash 从 body 本体按同样规则重算,而不是读取 ParsedRequest 缓存值。 +// - 如果 body 里已经存在 metadata.user_id,则返回空(由 ensureClaudeOAuthMetadataUserID +// 自行决定是否覆盖)。 +func (s *GatewayService) buildOAuthMetadataUserIDFromBody( + ctx context.Context, + account *Account, + fp *Fingerprint, + body []byte, +) string { + _ = ctx + if account == nil { + return "" + } + if existing := gjson.GetBytes(body, "metadata.user_id").String(); existing != "" { + return "" + } + + userID := strings.TrimSpace(account.GetClaudeUserID()) + if userID == "" && fp != nil { + userID = fp.ClientID + } + if userID == "" { + userID = generateClientID() + } + + // 与 buildOAuthMetadataUserID 一致:用会话级稳定种子,避免整 body 哈希导致 + // 每轮(甚至每个 token 变化)都重算出不同的 session_id。 + var clientDiscriminator string + if fp != nil { + clientDiscriminator = fp.ClientID + } + seed := buildStableSessionSeed(account.ID, clientDiscriminator, extractFirstUserText(body)) + sessionID := generateSessionUUID(seed) + + var uaVersion string + if fp != nil { + uaVersion = ExtractCLIVersion(fp.UserAgent) + } + accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid")) + return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion) +} + +// buildStableSessionSeed 为伪装路径合成的 metadata.user_id session_id 生成"会话级稳定"种子。 +// +// 真实 Claude Code 的 session_id 是进程级随机 UUID,在一段会话内跨请求保持不变。无状态代理 +// 无法恢复该值,这里用"会话内不变的锚点"近似:账号 ID + 客户端区分因子 + 首条 user 消息文本。 +// 对话在尾部追加 messages 时这三者都不变,因此 generateSessionUUID(seed) 跨轮稳定。 +// +// 注意:粘性路由键 GenerateSessionHash 按设计逐轮变化(见其测试),本函数与之独立、互不影响。 +// accountID 恒存在,故 seed 永不为空 —— 输出始终是确定性 UUID,而非随机值。 +func buildStableSessionSeed(accountID int64, clientDiscriminator, firstUserText string) string { + var b strings.Builder + _, _ = b.WriteString(strconv.FormatInt(accountID, 10)) + _, _ = b.WriteString("::") + _, _ = b.WriteString(clientDiscriminator) + _, _ = b.WriteString("::") + _, _ = b.WriteString(firstUserText) + return b.String() +} + +// sessionContextDiscriminator 把请求上下文(客户端 IP / 归一化 UA / API Key ID)拼成 +// 一个跨客户端的区分因子,避免不同用户的相同首条消息派生出相同 session_id。 +func sessionContextDiscriminator(sc *SessionContext) string { + if sc == nil { + return "" + } + return sc.ClientIP + ":" + NormalizeSessionUserAgent(sc.UserAgent) + ":" + strconv.FormatInt(sc.APIKeyID, 10) +} + +// GenerateSessionUUID creates a deterministic UUID4 from a seed string. +func GenerateSessionUUID(seed string) string { + return generateSessionUUID(seed) +} + +func generateSessionUUID(seed string) string { + if seed == "" { + return uuid.NewString() + } + hash := sha256.Sum256([]byte(seed)) + bytes := hash[:16] + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", + bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:16]) +} + +// normalizeSystemParam 将 json.RawMessage 类型的 system 参数转为标准 Go 类型(string / []any / nil), +// 避免 type switch 中 json.RawMessage(底层 []byte)无法匹配 case string / case []any / case nil 的问题。 +// 这是 Go 的 typed nil 陷阱:(json.RawMessage, nil) ≠ (nil, nil)。 +func normalizeSystemParam(system any) any { + raw, ok := system.(json.RawMessage) + if !ok { + return system + } + if len(raw) == 0 { + return nil + } + var parsed any + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil + } + return parsed +} + +// systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词 +// 使用前缀匹配支持多种变体(标准版、Agent SDK 版等) +func systemIncludesClaudeCodePrompt(system any) bool { + system = normalizeSystemParam(system) + switch v := system.(type) { + case string: + return hasClaudeCodePrefix(v) + case []any: + for _, item := range v { + if m, ok := item.(map[string]any); ok { + if text, ok := m["text"].(string); ok && hasClaudeCodePrefix(text) { + return true + } + } + } + } + return false +} + +// hasClaudeCodePrefix 检查文本是否以 Claude Code 提示词的特征前缀开头 +func hasClaudeCodePrefix(text string) bool { + for _, prefix := range claudeCodePromptPrefixes { + if strings.HasPrefix(text, prefix) { + return true + } + } + return false +} + +// injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词 +// 处理 null、字符串、数组三种格式 +func injectClaudeCodePrompt(body []byte, system any) []byte { + system = normalizeSystemParam(system) + claudeCodeBlock, err := marshalAnthropicSystemTextBlock(claudeCodeSystemPrompt, true) + if err != nil { + logger.LegacyPrintf("service.gateway", "Warning: failed to build Claude Code prompt block: %v", err) + return body + } + // Opencode plugin applies an extra safeguard: it not only prepends the Claude Code + // banner, it also prefixes the next system instruction with the same banner plus + // a blank line. This helps when upstream concatenates system instructions. + claudeCodePrefix := strings.TrimSpace(claudeCodeSystemPrompt) + + var items [][]byte + + switch v := system.(type) { + case nil: + items = [][]byte{claudeCodeBlock} + case string: + // Be tolerant of older/newer clients that may differ only by trailing whitespace/newlines. + if strings.TrimSpace(v) == "" || strings.TrimSpace(v) == strings.TrimSpace(claudeCodeSystemPrompt) { + items = [][]byte{claudeCodeBlock} + } else { + // Mirror opencode behavior: keep the banner as a separate system entry, + // but also prefix the next system text with the banner. + merged := v + if !strings.HasPrefix(v, claudeCodePrefix) { + merged = claudeCodePrefix + "\n\n" + v + } + nextBlock, buildErr := marshalAnthropicSystemTextBlock(merged, false) + if buildErr != nil { + logger.LegacyPrintf("service.gateway", "Warning: failed to build prefixed Claude Code system block: %v", buildErr) + return body + } + items = [][]byte{claudeCodeBlock, nextBlock} + } + case []any: + items = make([][]byte, 0, len(v)+1) + items = append(items, claudeCodeBlock) + prefixedNext := false + systemResult := gjson.GetBytes(body, "system") + if systemResult.IsArray() { + systemResult.ForEach(func(_, item gjson.Result) bool { + textResult := item.Get("text") + if textResult.Exists() && textResult.Type == gjson.String && + strings.TrimSpace(textResult.String()) == strings.TrimSpace(claudeCodeSystemPrompt) { + return true + } + + raw := []byte(item.Raw) + // Prefix the first subsequent text system block once. + if !prefixedNext && item.Get("type").String() == "text" && textResult.Exists() && textResult.Type == gjson.String { + text := textResult.String() + if strings.TrimSpace(text) != "" && !strings.HasPrefix(text, claudeCodePrefix) { + next, setErr := sjson.SetBytes(raw, "text", claudeCodePrefix+"\n\n"+text) + if setErr == nil { + raw = next + prefixedNext = true + } + } + } + items = append(items, raw) + return true + }) + } else { + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + raw, marshalErr := json.Marshal(item) + if marshalErr == nil { + items = append(items, raw) + } + continue + } + if text, ok := m["text"].(string); ok && strings.TrimSpace(text) == strings.TrimSpace(claudeCodeSystemPrompt) { + continue + } + if !prefixedNext { + if blockType, _ := m["type"].(string); blockType == "text" { + if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" && !strings.HasPrefix(text, claudeCodePrefix) { + m["text"] = claudeCodePrefix + "\n\n" + text + prefixedNext = true + } + } + } + raw, marshalErr := json.Marshal(m) + if marshalErr == nil { + items = append(items, raw) + } + } + } + default: + items = [][]byte{claudeCodeBlock} + } + + result, ok := setJSONRawBytes(body, "system", buildJSONArrayRaw(items)) + if !ok { + logger.LegacyPrintf("service.gateway", "Warning: failed to inject Claude Code prompt") + return body + } + return result +} + +// rewriteSystemForNonClaudeCode 将非 Claude Code 客户端的 system prompt 迁移至 messages, +// system 字段仅保留 Claude Code 标识提示词。 +// Anthropic 基于 system 参数内容检测第三方应用,仅前置追加 Claude Code 提示词 +// 无法通过检测,因为后续内容仍为非 Claude Code 格式。 +// 策略:将原始 system prompt 提取并注入为 user/assistant 消息对,system 仅保留 Claude Code 标识。 +func rewriteSystemForNonClaudeCode(body []byte, system any) []byte { + return rewriteSystemForNonClaudeCodeWithPromptBlocks(body, system, "", "") +} + +func rewriteSystemForNonClaudeCodeWithPrompt(body []byte, system any, expansionPrompt string) []byte { + return rewriteSystemForNonClaudeCodeWithPromptBlocks(body, system, expansionPrompt, "") +} + +type claudeOAuthSystemPromptBlockConfig struct { + Enabled *bool `json:"enabled,omitempty"` + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + CacheControl json.RawMessage `json:"cache_control,omitempty"` +} + +type claudeOAuthSystemPromptBlocksEnvelope struct { + Blocks []claudeOAuthSystemPromptBlockConfig `json:"blocks"` +} + +func defaultClaudeOAuthExpansionPrompt(expansionPrompt string) string { + expansionPrompt = strings.TrimSpace(expansionPrompt) + if expansionPrompt == "" { + return claudeCodeSystemPromptExpansion + } + return expansionPrompt +} + +func parseClaudeOAuthSystemPromptBlocksConfig(raw string) ([]claudeOAuthSystemPromptBlockConfig, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + if strings.HasPrefix(raw, "[") { + var blocks []claudeOAuthSystemPromptBlockConfig + if err := json.Unmarshal([]byte(raw), &blocks); err != nil { + return nil, err + } + return blocks, nil + } + var envelope claudeOAuthSystemPromptBlocksEnvelope + if err := json.Unmarshal([]byte(raw), &envelope); err != nil { + return nil, err + } + return envelope.Blocks, nil +} + +func decodeClaudeOAuthSystemPromptCacheControl(raw json.RawMessage) (any, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) || bytes.Equal(trimmed, []byte("false")) { + return nil, nil + } + if bytes.Equal(trimmed, []byte("true")) { + return map[string]string{ + "type": "ephemeral", + "ttl": claude.DefaultCacheControlTTL, + }, nil + } + var value any + if err := json.Unmarshal(trimmed, &value); err != nil { + return nil, err + } + if _, ok := value.(map[string]any); !ok { + return nil, fmt.Errorf("cache_control must be boolean, null, or object") + } + return value, nil +} + +func expandClaudeOAuthSystemPromptTextTemplate(body []byte, text string, expansionPrompt string) (string, error) { + if text == "" { + return "", nil + } + expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt) + billingText, err := buildBillingAttributionText(body, claude.CLICurrentVersion) + if err != nil { + return "", err + } + fp := computeClaudeCodeFingerprint(body, claude.CLICurrentVersion) + replacer := strings.NewReplacer( + "{billing_header}", billingText, + "{cc_version}", claude.CLICurrentVersion, + "{fp}", fp, + "{claude_code_system_prompt}", claudeCodeSystemPrompt, + "{claude_code_expansion_prompt}", expansionPrompt, + ) + return replacer.Replace(text), nil +} + +func defaultClaudeOAuthSystemPromptBlockConfig() []claudeOAuthSystemPromptBlockConfig { + enabled := true + return []claudeOAuthSystemPromptBlockConfig{ + { + Enabled: &enabled, + Type: "text", + Text: "{billing_header}", + }, + { + Enabled: &enabled, + Type: "text", + Text: "{claude_code_system_prompt}", + }, + { + Enabled: &enabled, + Type: "text", + Text: "{claude_code_expansion_prompt}", + CacheControl: json.RawMessage( + fmt.Sprintf(`{"type":"ephemeral","ttl":%q}`, claude.DefaultCacheControlTTL), + ), + }, + } +} + +func buildClaudeOAuthSystemPromptBlocksJSON(body []byte, expansionPrompt string, blocksConfig string) ([][]byte, error) { + blocks, err := parseClaudeOAuthSystemPromptBlocksConfig(blocksConfig) + if err != nil { + return nil, err + } + if len(blocks) == 0 { + blocks = defaultClaudeOAuthSystemPromptBlockConfig() + } + + items := make([][]byte, 0, len(blocks)) + for i, block := range blocks { + if block.Enabled != nil && !*block.Enabled { + continue + } + blockType := strings.TrimSpace(block.Type) + if blockType == "" { + blockType = "text" + } + if blockType != "text" { + return nil, fmt.Errorf("system block %d type %q is not supported", i, block.Type) + } + text, err := expandClaudeOAuthSystemPromptTextTemplate(body, block.Text, expansionPrompt) + if err != nil { + return nil, err + } + if strings.TrimSpace(text) == "" { + continue + } + cacheControl, err := decodeClaudeOAuthSystemPromptCacheControl(block.CacheControl) + if err != nil { + return nil, fmt.Errorf("system block %d cache_control: %w", i, err) + } + raw, err := marshalAnthropicSystemTextBlockWithCacheControl(text, cacheControl) + if err != nil { + return nil, err + } + items = append(items, raw) + } + return items, nil +} + +func ValidateClaudeOAuthSystemPromptBlocksConfig(raw string) error { + if strings.TrimSpace(raw) == "" { + return nil + } + blocks, err := parseClaudeOAuthSystemPromptBlocksConfig(raw) + if err != nil { + return infraerrors.BadRequest("INVALID_CLAUDE_OAUTH_SYSTEM_PROMPT_BLOCKS", "claude oauth system prompt blocks must be valid JSON") + } + for i, block := range blocks { + blockType := strings.TrimSpace(block.Type) + if blockType == "" { + blockType = "text" + } + if blockType != "text" { + return infraerrors.BadRequest("INVALID_CLAUDE_OAUTH_SYSTEM_PROMPT_BLOCKS", fmt.Sprintf("system block %d type must be text", i)) + } + if _, err := decodeClaudeOAuthSystemPromptCacheControl(block.CacheControl); err != nil { + return infraerrors.BadRequest("INVALID_CLAUDE_OAUTH_SYSTEM_PROMPT_BLOCKS", fmt.Sprintf("system block %d cache_control is invalid", i)) + } + } + return nil +} + +func rewriteSystemForNonClaudeCodeWithPromptBlocks(body []byte, system any, expansionPrompt string, blocksConfig string) []byte { + system = normalizeSystemParam(system) + expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt) + + // 1. 提取原始 system prompt 文本 + var originalSystemText string + switch v := system.(type) { + case string: + originalSystemText = strings.TrimSpace(v) + case []any: + var parts []string + for _, item := range v { + if m, ok := item.(map[string]any); ok { + if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" { + parts = append(parts, text) + } + } + } + originalSystemText = strings.Join(parts, "\n\n") + } + + // 2. 构造 system 数组,对齐真实 Claude Code CLI 的 3-block 形态: + // [0] billing attribution block(cc_version={cliVer}.{fp}; cc_entrypoint=cli;) + // [1] "You are Claude Code..." 身份前缀 block(默认不带 cache_control) + // [2] 工具无关的通用提示词扩充 block(带 cache_control 作为稳定缓存断点) + // + // 真实 CC 的 system 在身份前缀之后还有大段提示词,仅有 2 块会在块数/体量上明显 + // 区别于真实 CLI。这里注入 claudeCodeSystemPromptExpansion(中性段落)把形态做到 + // 接近真实,同时不注入会污染被代理用户行为的工具专属指令。 + // + // 缺失 billing block 的系统 payload 是 Anthropic 判定第三方的关键信号之一 + // (真实 CLI 每个请求都带)。新版 CLI 已取消 cch=... 签名字段,故 block 不再注入 + // cch(见 buildBillingAttributionText)。 + systemBlocks, blockErr := buildClaudeOAuthSystemPromptBlocksJSON(body, expansionPrompt, blocksConfig) + if blockErr != nil { + logger.LegacyPrintf("service.gateway", "Warning: failed to build configured Claude OAuth system blocks: %v", blockErr) + systemBlocks, blockErr = buildClaudeOAuthSystemPromptBlocksJSON(body, expansionPrompt, "") + } + if blockErr != nil { + logger.LegacyPrintf("service.gateway", "Warning: failed to build default Claude OAuth system blocks: %v", blockErr) + return body + } + out, ok := setJSONRawBytes(body, "system", buildJSONArrayRaw(systemBlocks)) + if !ok { + logger.LegacyPrintf("service.gateway", "Warning: failed to set Claude Code system prompt") + return body + } + + // 3. 将原始 system prompt 作为 user/assistant 消息对注入到 messages 开头 + // 模型仍通过 messages 接收完整指令,保留客户端功能 + ccPromptTrimmed := strings.TrimSpace(claudeCodeSystemPrompt) + if originalSystemText != "" && originalSystemText != ccPromptTrimmed && !hasClaudeCodePrefix(originalSystemText) { + instrMsg, err1 := json.Marshal(map[string]any{ + "role": "user", + "content": []map[string]any{ + {"type": "text", "text": "[System Instructions]\n" + originalSystemText}, + }, + }) + ackMsg, err2 := json.Marshal(map[string]any{ + "role": "assistant", + "content": []map[string]any{ + {"type": "text", "text": "Understood. I will follow these instructions."}, + }, + }) + if err1 != nil || err2 != nil { + logger.LegacyPrintf("service.gateway", "Warning: failed to marshal system-to-messages injection") + return out + } + + // 重建 messages 数组:[instruction, ack, ...originalMessages] + items := [][]byte{instrMsg, ackMsg} + messagesResult := gjson.GetBytes(out, "messages") + if messagesResult.IsArray() { + messagesResult.ForEach(func(_, msg gjson.Result) bool { + items = append(items, []byte(msg.Raw)) + return true + }) + } + + if next, setOk := setJSONRawBytes(out, "messages", buildJSONArrayRaw(items)); setOk { + out = next + } + } + + return out +} + +type cacheControlPath struct { + path string + log string +} + +func collectCacheControlPaths(body []byte) (invalidThinking []cacheControlPath, messagePaths []string, toolPaths []string, systemPaths []string) { + system := gjson.GetBytes(body, "system") + if system.IsArray() { + sysIndex := 0 + system.ForEach(func(_, item gjson.Result) bool { + if item.Get("cache_control").Exists() { + path := fmt.Sprintf("system.%d.cache_control", sysIndex) + if item.Get("type").String() == "thinking" { + invalidThinking = append(invalidThinking, cacheControlPath{ + path: path, + log: "[Warning] Removed illegal cache_control from thinking block in system", + }) + } else { + systemPaths = append(systemPaths, path) + } + } + sysIndex++ + return true + }) + } + + messages := gjson.GetBytes(body, "messages") + if messages.IsArray() { + msgIndex := 0 + messages.ForEach(func(_, msg gjson.Result) bool { + content := msg.Get("content") + if content.IsArray() { + contentIndex := 0 + content.ForEach(func(_, item gjson.Result) bool { + if item.Get("cache_control").Exists() { + path := fmt.Sprintf("messages.%d.content.%d.cache_control", msgIndex, contentIndex) + if item.Get("type").String() == "thinking" { + invalidThinking = append(invalidThinking, cacheControlPath{ + path: path, + log: fmt.Sprintf("[Warning] Removed illegal cache_control from thinking block in messages[%d].content[%d]", msgIndex, contentIndex), + }) + } else { + messagePaths = append(messagePaths, path) + } + } + contentIndex++ + return true + }) + } + msgIndex++ + return true + }) + } + + tools := gjson.GetBytes(body, "tools") + if tools.IsArray() { + toolIndex := 0 + tools.ForEach(func(_, tool gjson.Result) bool { + if tool.Get("cache_control").Exists() { + toolPaths = append(toolPaths, fmt.Sprintf("tools.%d.cache_control", toolIndex)) + } + toolIndex++ + return true + }) + } + + return invalidThinking, messagePaths, toolPaths, systemPaths +} + +// enforceCacheControlLimit 强制执行 cache_control 块数量限制(最多 4 个) +// 超限时优先移除工具断点,再移除 messages 断点,最后才移除 system 断点。 +func enforceCacheControlLimit(body []byte) []byte { + if len(body) == 0 { + return body + } + + invalidThinking, messagePaths, toolPaths, systemPaths := collectCacheControlPaths(body) + out := body + modified := false + + // 先清理 thinking 块中的非法 cache_control(thinking 块不支持该字段) + for _, item := range invalidThinking { + if !gjson.GetBytes(out, item.path).Exists() { + continue + } + next, ok := deleteJSONPathBytes(out, item.path) + if !ok { + continue + } + out = next + modified = true + logger.LegacyPrintf("service.gateway", "%s", item.log) + } + + count := len(messagePaths) + len(toolPaths) + len(systemPaths) + if count <= maxCacheControlBlocks { + if modified { + return out + } + return body + } + + // 超限:优先从 tools 中移除,再从 messages 中移除,最后才从 system 中移除。 + remaining := count - maxCacheControlBlocks + for i := len(toolPaths) - 1; i >= 0 && remaining > 0; i-- { + path := toolPaths[i] + if !gjson.GetBytes(out, path).Exists() { + continue + } + next, ok := deleteJSONPathBytes(out, path) + if !ok { + continue + } + out = next + modified = true + remaining-- + } + + for _, path := range messagePaths { + if remaining <= 0 { + break + } + if !gjson.GetBytes(out, path).Exists() { + continue + } + next, ok := deleteJSONPathBytes(out, path) + if !ok { + continue + } + out = next + modified = true + remaining-- + } + + for i := len(systemPaths) - 1; i >= 0 && remaining > 0; i-- { + path := systemPaths[i] + if !gjson.GetBytes(out, path).Exists() { + continue + } + next, ok := deleteJSONPathBytes(out, path) + if !ok { + continue + } + out = next + modified = true + remaining-- + } + + if modified { + return out + } + return body +} + +// injectAnthropicCacheControlTTL1h 将已有 ephemeral cache_control 块的 ttl 强制写为 1h。 +// 仅修改已经存在的 cache_control,不新增缓存断点。 +func injectAnthropicCacheControlTTL1h(body []byte) []byte { + return forceEphemeralCacheControlTTL(body, cacheTTLTarget1h) +} + +func forceEphemeralCacheControlTTL(body []byte, ttl string) []byte { + if len(body) == 0 || ttl == "" { + return body + } + out := body + var paths []string + addPath := func(path string, value gjson.Result) { + cc := value.Get("cache_control") + if !cc.Exists() || cc.Get("type").String() != "ephemeral" { + return + } + if cc.Get("ttl").String() == ttl { + return + } + paths = append(paths, path+".cache_control.ttl") + } + + if topCC := gjson.GetBytes(body, "cache_control"); topCC.Exists() && topCC.Get("type").String() == "ephemeral" && topCC.Get("ttl").String() != ttl { + paths = append(paths, "cache_control.ttl") + } + + system := gjson.GetBytes(body, "system") + if system.IsArray() { + idx := -1 + system.ForEach(func(_, block gjson.Result) bool { + idx++ + addPath(fmt.Sprintf("system.%d", idx), block) + return true + }) + } + + messages := gjson.GetBytes(body, "messages") + if messages.IsArray() { + msgIdx := -1 + messages.ForEach(func(_, msg gjson.Result) bool { + msgIdx++ + content := msg.Get("content") + if !content.IsArray() { + return true + } + contentIdx := -1 + content.ForEach(func(_, block gjson.Result) bool { + contentIdx++ + addPath(fmt.Sprintf("messages.%d.content.%d", msgIdx, contentIdx), block) + return true + }) + return true + }) + } + + tools := gjson.GetBytes(body, "tools") + if tools.IsArray() { + idx := -1 + tools.ForEach(func(_, tool gjson.Result) bool { + idx++ + addPath(fmt.Sprintf("tools.%d", idx), tool) + return true + }) + } + + for _, path := range paths { + if next, err := sjson.SetBytes(out, path, ttl); err == nil { + out = next + } + } + return out +} + +func (s *GatewayService) shouldInjectAnthropicCacheTTL1h(ctx context.Context, account *Account) bool { + if account == nil || !account.IsAnthropicOAuthOrSetupToken() || s == nil || s.settingService == nil { + return false + } + return s.settingService.IsAnthropicCacheTTL1hInjectionEnabled(ctx) +} + +// shouldNormalizeClientDateline reports whether the request body's client +// dateline should be normalized before forwarding to Anthropic. The switch is +// scoped to Anthropic OAuth/SetupToken accounts only; API-Key accounts and +// non-Anthropic platforms bypass this step entirely. +func (s *GatewayService) shouldNormalizeClientDateline(ctx context.Context, account *Account) bool { + if account == nil || !account.IsAnthropicOAuthOrSetupToken() || s == nil || s.settingService == nil { + return false + } + return s.settingService.IsClientDatelineNormalizationEnabled(ctx) +} + +// normalizeClientDatelineIfEnabled applies dateline normalization to body when +// the switch is on and the account qualifies. Returns (nextBody, true) only +// when the body actually changed; otherwise returns (nil, false) so callers +// can skip the writeback. +func (s *GatewayService) normalizeClientDatelineIfEnabled(ctx context.Context, account *Account, body []byte) ([]byte, bool) { + if !s.shouldNormalizeClientDateline(ctx, account) { + return nil, false + } + next, _, changed := anthropicfp.NormalizeDateline(body) + if !changed { + return nil, false + } + return next, true +} + +func (s *GatewayService) claudeOAuthSystemPromptInjectionSettings(ctx context.Context) (bool, string, string) { + if s == nil || s.settingService == nil { + return true, "", "" + } + return s.settingService.GetClaudeOAuthSystemPromptInjectionSettings(ctx) +} diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go new file mode 100644 index 0000000000..500f655660 --- /dev/null +++ b/backend/internal/service/gateway_count_tokens.go @@ -0,0 +1,606 @@ +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/tidwall/gjson" + + "github.com/gin-gonic/gin" +) + +// ForwardCountTokens 转发 count_tokens 请求到上游 API +// 特点:不记录使用量、仅支持非流式响应 +func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) error { + if parsed == nil { + s.countTokensError(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty") + return fmt.Errorf("parse request: empty request") + } + + if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() { + passthroughBody := parsed.Body.Bytes() + if reqModel := parsed.Model; reqModel != "" { + if mappedModel := account.GetMappedModel(reqModel); mappedModel != reqModel { + passthroughBody = s.replaceModelInBody(passthroughBody, mappedModel) + logger.LegacyPrintf("service.gateway", "CountTokens passthrough model mapping: %s -> %s (account: %s)", reqModel, mappedModel, account.Name) + } + } + return s.forwardCountTokensAnthropicAPIKeyPassthrough(ctx, c, account, passthroughBody) + } + + // Bedrock 不支持 count_tokens 端点 + if account != nil && account.IsBedrock() { + s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported for Bedrock") + return nil + } + + body := parsed.Body.Bytes() + replaceBody := func(next []byte) error { + if err := parsed.ReplaceBody(next); err != nil { + return fmt.Errorf("rewrite count_tokens body: %w", err) + } + body = parsed.Body.Bytes() + return nil + } + reqModel := parsed.Model + + // Pre-filter: strip empty text blocks to prevent upstream 400. + if err := replaceBody(StripEmptyTextBlocks(body)); err != nil { + return err + } + + isClaudeCodeCT := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) + shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCodeCT + + if shouldMimicClaudeCode { + normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} + var normalizedBody []byte + normalizedBody, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts) + if err := replaceBody(normalizedBody); err != nil { + return err + } + + if err := replaceBody(s.rewriteMessageCacheControlIfEnabled(ctx, body)); err != nil { + return err + } + if rw := buildToolNameRewriteFromBody(body); rw != nil { + if err := replaceBody(applyToolNameRewriteToBody(body, rw)); err != nil { + return err + } + } else { + if err := replaceBody(applyToolsLastCacheBreakpoint(body)); err != nil { + return err + } + } + } + + // Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。 + // 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。 + if account.Platform == PlatformAntigravity { + s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported for this platform") + return nil + } + + // 应用模型映射: + // - APIKey 账号:使用账号级别的显式映射(如果配置),否则透传原始模型名 + // - OAuth/SetupToken 账号:使用 Anthropic 标准映射(短ID → 长ID) + if reqModel != "" { + mappedModel := reqModel + mappingSource := "" + if account.Type == AccountTypeAPIKey { + mappedModel = account.GetMappedModel(reqModel) + if mappedModel != reqModel { + mappingSource = "account" + } + } + if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey { + normalized := claude.NormalizeModelID(reqModel) + if normalized != reqModel { + mappedModel = normalized + mappingSource = "prefix" + } + } + if mappedModel != reqModel { + originalReqModel := reqModel + if err := replaceBody(s.replaceModelInBody(body, mappedModel)); err != nil { + return err + } + reqModel = mappedModel + parsed.Model = mappedModel + logger.LegacyPrintf("service.gateway", "CountTokens model mapping applied: %s -> %s (account: %s, source=%s)", originalReqModel, mappedModel, account.Name, mappingSource) + } + } + + // 获取凭证 + token, tokenType, err := s.GetAccessToken(ctx, account) + if err != nil { + s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to get access token") + return err + } + + // 构建上游请求 + upstreamReq, wireBody, err := s.buildCountTokensRequest(ctx, c, account, body, token, tokenType, reqModel, shouldMimicClaudeCode) + if err != nil { + s.countTokensError(c, http.StatusInternalServerError, "api_error", "Failed to build request") + return err + } + // 先记录首发 wire body;如果后面进入 400 retry,retry 会基于未签名的逻辑 body 重新构建。 + acceptedWireBody := wireBody + + // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递) + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" { + proxyURL = account.Proxy.URL() + } + } + + // 发送请求 + resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + if err != nil { + setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "") + s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed") + return fmt.Errorf("upstream request failed: %w", err) + } + + // 读取响应体 + countTokensTooLarge := func(c *gin.Context) { + s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response too large") + } + respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, countTokensTooLarge) + _ = resp.Body.Close() + if err != nil { + if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) { + s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response") + } + return err + } + + // 检测 thinking block 签名错误(400)并重试一次(过滤 thinking blocks) + if resp.StatusCode == 400 && s.shouldRectifySignatureError(ctx, account, respBody, reqModel) { + logger.LegacyPrintf("service.gateway", "Account %d: detected thinking block signature error on count_tokens, retrying with filtered thinking blocks", account.ID) + + filteredBody := FilterThinkingBlocksForRetry(body, reqModel) + retryReq, retryWireBody, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, shouldMimicClaudeCode) + if buildErr == nil { + retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + if retryErr == nil { + if retryResp.StatusCode < 400 { + // count_tokens 签名重试成功后记录最终 wire body,错误响应仍保留原 body 便于后续处理。 + acceptedWireBody = retryWireBody + } + resp = retryResp + respBody, err = ReadUpstreamResponseBody(resp.Body, s.cfg, c, countTokensTooLarge) + _ = resp.Body.Close() + if err != nil { + if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) { + s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response") + } + return err + } + } + } + } + + if resp.StatusCode < 400 && !bytes.Equal(acceptedWireBody, body) { + // count_tokens 成功后再同步最终 wire body,避免 retry 从已签名 body 派生。 + if err := replaceBody(acceptedWireBody); err != nil { + return err + } + } + + // 处理错误响应 + if resp.StatusCode >= 400 { + // 标记账号状态(429/529等) + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(respBody), maxBytes) + } + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + + // 记录上游错误摘要便于排障(不回显请求内容) + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + logger.LegacyPrintf("service.gateway", + "count_tokens upstream error %d (account=%d platform=%s type=%s): %s", + resp.StatusCode, + account.ID, + account.Platform, + account.Type, + truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), + ) + } + + // 返回简化的错误响应 + errMsg := "Upstream request failed" + switch resp.StatusCode { + case 429: + errMsg = "Rate limit exceeded" + case 529: + errMsg = "Service overloaded" + } + s.countTokensError(c, resp.StatusCode, "upstream_error", errMsg) + if upstreamMsg == "" { + return fmt.Errorf("upstream error: %d", resp.StatusCode) + } + return fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg) + } + + // 透传成功响应 + c.Data(resp.StatusCode, "application/json", respBody) + return nil +} + +func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx context.Context, c *gin.Context, account *Account, body []byte) error { + token, tokenType, err := s.GetAccessToken(ctx, account) + if err != nil { + s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to get access token") + return err + } + if tokenType != "apikey" { + s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Invalid account token type") + return fmt.Errorf("anthropic api key passthrough requires apikey token, got: %s", tokenType) + } + + upstreamReq, err := s.buildCountTokensRequestAnthropicAPIKeyPassthrough(ctx, c, account, body, token) + if err != nil { + s.countTokensError(c, http.StatusInternalServerError, "api_error", "Failed to build request") + return err + } + + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + if err != nil { + setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "") + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: 0, + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), + Passthrough: true, + Kind: "request_error", + Message: sanitizeUpstreamErrorMessage(err.Error()), + }) + s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed") + return fmt.Errorf("upstream request failed: %w", err) + } + + countTokensTooLarge := func(c *gin.Context) { + s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response too large") + } + respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, countTokensTooLarge) + _ = resp.Body.Close() + if err != nil { + if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) { + s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response") + } + return err + } + + if resp.StatusCode >= 400 { + if s.rateLimitService != nil { + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + } + + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + + // 中转站不支持 count_tokens 端点时(404),返回 404 让客户端 fallback 到本地估算。 + // 仅在错误消息明确指向 count_tokens endpoint 不存在时生效,避免误吞其他 404(如错误 base_url)。 + // 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。 + if isCountTokensUnsupported404(resp.StatusCode, respBody) { + logger.LegacyPrintf("service.gateway", + "[count_tokens] Upstream does not support count_tokens (404), returning 404: account=%d name=%s msg=%s", + account.ID, account.Name, truncateString(upstreamMsg, 512)) + s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported by upstream") + return nil + } + + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(respBody), maxBytes) + } + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), + Passthrough: true, + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + + errMsg := "Upstream request failed" + switch resp.StatusCode { + case 429: + errMsg = "Rate limit exceeded" + case 529: + errMsg = "Service overloaded" + } + s.countTokensError(c, resp.StatusCode, "upstream_error", errMsg) + if upstreamMsg == "" { + return fmt.Errorf("upstream error: %d", resp.StatusCode) + } + return fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg) + } + + writeAnthropicPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType == "" { + contentType = "application/json" + } + c.Data(resp.StatusCode, contentType, respBody) + return nil +} + +func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + token string, +) (*http.Request, error) { + targetURL := claudeAPICountTokensURL + baseURL := account.GetBaseURL() + if baseURL != "" { + validatedURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return nil, err + } + targetURL = validatedURL + "/v1/messages/count_tokens?beta=true" + } + body = sanitizeCountTokensRequestBody(body) + + // 同 buildUpstreamRequestAnthropicAPIKeyPassthrough:能力维度 sanitize。 + clientBeta := "" + if c != nil && c.Request != nil { + clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta") + } + // 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准 + if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok { + clientBeta = beta + } + if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed { + body = sanitized + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + if c != nil && c.Request != nil { + for key, values := range c.Request.Header { + lowerKey := strings.ToLower(strings.TrimSpace(key)) + if !allowedHeaders[lowerKey] { + continue + } + wireKey := resolveWireCasing(key) + for _, v := range values { + addHeaderRaw(req.Header, wireKey, v) + } + } + } + + req.Header.Del("authorization") + req.Header.Del("x-api-key") + req.Header.Del("x-goog-api-key") + req.Header.Del("cookie") + setAnthropicAPIKeyAuthHeader(req.Header, account, token) + + if req.Header.Get("content-type") == "" { + req.Header.Set("content-type", "application/json") + } + if req.Header.Get("anthropic-version") == "" { + req.Header.Set("anthropic-version", "2023-06-01") + } + + // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头) + account.ApplyHeaderOverrides(req.Header) + + return req, nil +} + +// buildCountTokensRequest 构建 count_tokens 上游请求 +func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, mimicClaudeCode bool) (*http.Request, []byte, error) { + // 确定目标 URL + targetURL := claudeAPICountTokensURL + if account.Type == AccountTypeAPIKey { + baseURL := account.GetBaseURL() + if baseURL != "" { + validatedURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return nil, nil, err + } + targetURL = validatedURL + "/v1/messages/count_tokens?beta=true" + } + } else if account.IsCustomBaseURLEnabled() { + customURL := account.GetCustomBaseURL() + if customURL == "" { + return nil, nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID) + } + validatedURL, err := s.validateUpstreamBaseURL(customURL) + if err != nil { + return nil, nil, err + } + targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages/count_tokens", account) + } + + clientHeaders := http.Header{} + if c != nil && c.Request != nil { + clientHeaders = c.Request.Header + } + + // OAuth 账号:应用统一指纹和重写 userID(受设置开关控制) + // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 + ctEnableFP, ctEnableMPT := true, false + if s.settingService != nil { + ctEnableFP, ctEnableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx) + } + var ctFingerprint *Fingerprint + if account.IsOAuth() && s.identityService != nil { + fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) + if err == nil { + ctFingerprint = fp + if !ctEnableMPT { + accountUUID := account.GetExtraString("account_uuid") + if accountUUID != "" && fp.ClientID != "" { + if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { + body = newBody + } + } + } + } + } + + // 同步 billing header cc_version 与实际发送的 User-Agent 版本 + if ctFingerprint != nil && ctEnableFP { + body = syncBillingHeaderVersion(body, ctFingerprint.UserAgent) + } + + // === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)=== + // 顺序约束同 buildUpstreamRequest。 + ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account, modelID)) + finalBetaHeader, finalBetaShouldSet := s.computeFinalCountTokensAnthropicBeta( + tokenType, mimicClaudeCode, modelID, clientHeaders, body, ctEffectiveDropSet, + ) + + // 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准 + if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok { + finalBetaHeader, finalBetaShouldSet = beta, true + } + + // 能力维度 body sanitize:与最终 anthropic-beta header 对称 + if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed { + body = sanitized + } + + body = sanitizeCountTokensRequestBody(body) + + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + + // 设置认证头(保持原始大小写) + if tokenType == "oauth" { + setHeaderRaw(req.Header, "authorization", "Bearer "+token) + } else { + setAnthropicAPIKeyAuthHeader(req.Header, account, token) + } + + // 白名单透传 headers(恢复真实 wire casing) + for key, values := range clientHeaders { + lowerKey := strings.ToLower(key) + if allowedHeaders[lowerKey] { + wireKey := resolveWireCasing(key) + for _, v := range values { + addHeaderRaw(req.Header, wireKey, v) + } + } + } + + // OAuth 账号:应用指纹到请求头(受设置开关控制) + if ctEnableFP && ctFingerprint != nil { + s.identityService.ApplyFingerprint(req, ctFingerprint) + } + + // 确保必要的 headers 存在(保持原始大小写) + if getHeaderRaw(req.Header, "content-type") == "" { + setHeaderRaw(req.Header, "content-type", "application/json") + } + if getHeaderRaw(req.Header, "anthropic-version") == "" { + setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") + } + if tokenType == "oauth" { + applyClaudeOAuthHeaderDefaults(req) + } + + // OAuth + mimic Claude Code:强制注入 CLI 指纹 header + if tokenType == "oauth" && mimicClaudeCode { + applyClaudeCodeMimicHeaders(req, false) + } + + // 写入最终 anthropic-beta header(Del 一次避免白名单透传值残留) + deleteHeaderAllForms(req.Header, "anthropic-beta") + if finalBetaShouldSet { + setHeaderRaw(req.Header, "anthropic-beta", finalBetaHeader) + } + + // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖 + if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" { + if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" { + if parsed := ParseMetadataUserID(uid); parsed != nil { + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID) + } + } + } + + // 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op) + account.ApplyHeaderOverrides(req.Header) + + if c != nil && tokenType == "oauth" { + c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode)) + } + if s.debugClaudeMimicEnabled() { + logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode) + } + + return req, body, nil +} + +func sanitizeCountTokensRequestBody(body []byte) []byte { + out := body + for _, path := range []string{ + "temperature", + "top_p", + "top_k", + "stream", + "stop_sequences", + "stop", + } { + if gjson.GetBytes(out, path).Exists() { + if next, ok := deleteJSONPathBytes(out, path); ok { + out = next + } + } + } + return out +} + +// countTokensError 返回 count_tokens 错误响应 +func (s *GatewayService) countTokensError(c *gin.Context, status int, errType, message string) { + c.JSON(status, gin.H{ + "type": "error", + "error": gin.H{ + "type": errType, + "message": message, + }, + }) +} diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go new file mode 100644 index 0000000000..e690aaab22 --- /dev/null +++ b/backend/internal/service/gateway_forward.go @@ -0,0 +1,959 @@ +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + + "github.com/gin-gonic/gin" +) + +// 重试相关常量 +const ( + // 最大尝试次数(包含首次请求)。过多重试会导致请求堆积与资源耗尽。 + maxRetryAttempts = 5 + + // 指数退避:第 N 次失败后的等待 = retryBaseDelay * 2^(N-1),并且上限为 retryMaxDelay。 + retryBaseDelay = 300 * time.Millisecond + retryMaxDelay = 3 * time.Second + + // 最大重试耗时(包含请求本身耗时 + 退避等待时间)。 + // 用于防止极端情况下 goroutine 长时间堆积导致资源耗尽。 + maxRetryElapsed = 10 * time.Second +) + +func (s *GatewayService) shouldRetryUpstreamError(account *Account, statusCode int) bool { + // OAuth/Setup Token 账号:仅 403 重试 + if account.IsOAuth() { + return statusCode == 403 + } + + // API Key 账号:未配置的错误码重试 + return !account.ShouldHandleErrorCode(statusCode) +} + +// shouldFailoverUpstreamError determines whether an upstream error should trigger account failover. +func (s *GatewayService) shouldFailoverUpstreamError(statusCode int) bool { + switch statusCode { + case 401, 403, 429, 529: + return true + default: + return statusCode >= 500 + } +} + +func retryBackoffDelay(attempt int) time.Duration { + // attempt 从 1 开始,表示第 attempt 次请求刚失败,需要等待后进行第 attempt+1 次请求。 + if attempt <= 0 { + return retryBaseDelay + } + delay := retryBaseDelay * time.Duration(1<<(attempt-1)) + if delay > retryMaxDelay { + return retryMaxDelay + } + return delay +} + +func sleepWithContext(ctx context.Context, d time.Duration) error { + if d <= 0 { + return nil + } + timer := time.NewTimer(d) + defer func() { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// Forward 转发请求到Claude API +func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) { + startTime := time.Now() + if parsed == nil { + return nil, fmt.Errorf("parse request: empty request") + } + + // Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应 + if account != nil && s.shouldEmulateWebSearch(ctx, account, parsed.GroupID, parsed.Body.Bytes()) { + return s.handleWebSearchEmulation(ctx, c, account, parsed) + } + + if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() { + passthroughBody := parsed.Body.Bytes() + passthroughModel := parsed.Model + if passthroughModel != "" { + if mappedModel := account.GetMappedModel(passthroughModel); mappedModel != passthroughModel { + passthroughBody = s.replaceModelInBody(passthroughBody, mappedModel) + logger.LegacyPrintf("service.gateway", "Passthrough model mapping: %s -> %s (account: %s)", parsed.Model, mappedModel, account.Name) + passthroughModel = mappedModel + } + } + return s.forwardAnthropicAPIKeyPassthroughWithInput(ctx, c, account, anthropicPassthroughForwardInput{ + Body: passthroughBody, + Parsed: parsed, + RequestModel: passthroughModel, + OriginalModel: parsed.Model, + RequestStream: parsed.Stream, + StartTime: startTime, + }) + } + + if account != nil && account.IsBedrock() { + return s.forwardBedrock(ctx, c, account, parsed, startTime) + } + + // Beta policy: evaluate once; block check + cache filter set for buildUpstreamRequest. + // Always overwrite the cache to prevent stale values from a previous retry with a different account. + if account.Platform == PlatformAnthropic && c != nil { + policy := s.evaluateBetaPolicy(ctx, c.GetHeader("anthropic-beta"), account, parsed.Model) + if policy.blockErr != nil { + return nil, policy.blockErr + } + filterSet := policy.filterSet + if filterSet == nil { + filterSet = map[string]struct{}{} + } + c.Set(betaPolicyFilterSetKey, filterSet) + } + + body := parsed.Body.Bytes() + replaceBody := func(next []byte) error { + if err := parsed.ReplaceBody(next); err != nil { + return fmt.Errorf("rewrite request body: %w", err) + } + body = parsed.Body.Bytes() + return nil + } + reqModel := parsed.Model + reqStream := parsed.Stream + originalModel := reqModel + + // === DEBUG: 打印客户端原始请求(headers + body 摘要)=== + if c != nil { + s.debugLogGatewaySnapshot("CLIENT_ORIGINAL", c.Request.Header, body, map[string]string{ + "account": fmt.Sprintf("%d(%s)", account.ID, account.Name), + "account_type": string(account.Type), + "model": reqModel, + "stream": strconv.FormatBool(reqStream), + }) + } + + // Claude Code 客户端判定:UA 匹配 claude-cli/* 且携带 metadata.user_id。 + // 真正的 Claude Code 客户端自带完整的 system prompt、cache_control 断点和 header, + // 不需要代理做任何 body 级别的 mimicry;强行替换反而会破坏客户端的缓存策略 + // (长 system prompt 被替换为 ~45 tokens 的短 prompt,低于 Anthropic 1024 token + // 最低缓存门槛,导致系统级缓存失效)。 + // + // 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。 + isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) + shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode + + if shouldMimicClaudeCode { + // 与 Parrot 对齐:OAuth 账号无条件重写 system(即使客户端已发了 Claude Code + // 风格的 system prompt)。原因:第三方工具(opencode 等)会发 "You are Claude + // Code..." system prompt 但缺少 billing attribution block,导致 Anthropic + // 检测到"有 CC prompt 但无 billing block"的不一致而判为 third-party。 + // Parrot 的 transform_request 从不检查客户端 system 内容,直接覆盖。 + systemRewritten := false + if !strings.Contains(strings.ToLower(reqModel), "haiku") { + systemRaw, _ := parsed.SystemValue() + systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) + if systemPromptInjectionEnabled { + if err := replaceBody(rewriteSystemForNonClaudeCodeWithPromptBlocks(body, systemRaw, systemPrompt, systemPromptBlocks)); err != nil { + return nil, err + } + systemRewritten = true + } + } + + // system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为); + // 未重写时(haiku / 注入开关关闭)剥离客户端 cache_control,与原有行为一致。 + // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。 + normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} + if s.identityService != nil { + fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) + if err == nil && fp != nil { + // metadata 透传开启时跳过 metadata 注入 + _, mimicMPT, _ := s.settingService.GetGatewayForwardingSettings(ctx) + if !mimicMPT { + if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" { + normalizeOpts.injectMetadata = true + normalizeOpts.metadataUserID = metadataUserID + } + } + } + } + + var normalizedBody []byte + normalizedBody, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts) + if err := replaceBody(normalizedBody); err != nil { + return nil, err + } + + // D/E/F: 可选 messages cache 策略 + 工具名混淆 + tools[-1] 断点 + // 与 forward_as_chat_completions / forward_as_responses 路径对齐, + // 原生 /v1/messages 路径也走同一套可配置字段级改写。 + if err := replaceBody(s.rewriteMessageCacheControlIfEnabled(ctx, body)); err != nil { + return nil, err + } + if rw := buildToolNameRewriteFromBody(body); rw != nil { + if err := replaceBody(applyToolNameRewriteToBody(body, rw)); err != nil { + return nil, err + } + c.Set(toolNameRewriteKey, rw) + } else { + if err := replaceBody(applyToolsLastCacheBreakpoint(body)); err != nil { + return nil, err + } + } + } + + // 客户端 dateline 归一化:仅对 Anthropic OAuth/SetupToken 账号生效。 + // 抹除 "Today's date is …" 语句里可能被注入的隐写指纹(4 种撇号 × 2 种日期 + // 分隔符),还原为 ASCII 撇号 + "-" 分隔符。运行在 mimicry 分支之外, + // 保证真实 Claude Code 客户端注入的指纹同样被清洗。 + if next, ok := s.normalizeClientDatelineIfEnabled(ctx, account, body); ok { + if err := replaceBody(next); err != nil { + return nil, err + } + } + + // 强制执行 cache_control 块数量限制(最多 4 个) + if err := replaceBody(enforceCacheControlLimit(body)); err != nil { + return nil, err + } + + // 应用模型映射: + // - APIKey 账号:使用账号级别的显式映射(如果配置),否则透传原始模型名 + // - OAuth/SetupToken 账号:使用 Anthropic 标准映射(短ID → 长ID) + mappedModel := reqModel + mappingSource := "" + if account.Type == AccountTypeAPIKey { + mappedModel = account.GetMappedModel(reqModel) + if mappedModel != reqModel { + mappingSource = "account" + } + } + if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount { + if candidate, matched := account.ResolveMappedModel(reqModel); matched { + mappedModel = candidate + mappingSource = "account" + } else { + normalized := normalizeVertexAnthropicModelID(claude.NormalizeModelID(reqModel)) + if normalized != reqModel { + mappedModel = normalized + mappingSource = "vertex" + } + } + } + if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey { + normalized := claude.NormalizeModelID(reqModel) + if normalized != reqModel { + mappedModel = normalized + mappingSource = "prefix" + } + } + if mappedModel != reqModel { + // 替换请求体中的模型名 + if err := replaceBody(s.replaceModelInBody(body, mappedModel)); err != nil { + return nil, err + } + reqModel = mappedModel + parsed.Model = mappedModel + logger.LegacyPrintf("service.gateway", "Model mapping applied: %s -> %s (account: %s, source=%s)", originalModel, mappedModel, account.Name, mappingSource) + } + + if s.shouldInjectAnthropicCacheTTL1h(ctx, account) { + if err := replaceBody(injectAnthropicCacheControlTTL1h(body)); err != nil { + return nil, err + } + } + + // 获取凭证 + token, tokenType, err := s.GetAccessToken(ctx, account) + if err != nil { + return nil, err + } + + // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递) + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" { + proxyURL = account.Proxy.URL() + } + } + + // 解析 TLS 指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析) + tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account) + + // 调试日志:记录即将转发的账号信息 + logger.LegacyPrintf("service.gateway", "[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s", + account.ID, account.Name, account.Platform, account.Type, tlsProfile, proxyURL) + // Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400. + if err := replaceBody(StripEmptyTextBlocks(body)); err != nil { + return nil, err + } + // Pre-filter: strip web-search history blocks the upstream cannot accept + // (emulation-synthesized server_tool_use / web_search_tool_result always; + // genuine ones additionally for passback-required upstreams). See + // FilterWebSearchHistoryBlocks. reqModel 此时已是映射后的模型 ID。 + if err := replaceBody(FilterWebSearchHistoryBlocks(body, reqModel)); err != nil { + return nil, err + } + // Pre-filter: remove thinking blocks with missing/invalid signatures before forwarding. + // Clients (e.g. Claude Code) sometimes send multi-turn conversations where a historical + // assistant message contains a thinking block that is missing the required "signature" field, + // causing upstream to reject the request with 400 "thinking.signature: Field required". + // FilterThinkingBlocks removes only the invalid blocks; thinking blocks with valid signatures + // are preserved. This avoids relying solely on the post-error retry path, which can time out + // (maxRetryElapsed = 10s) for long conversations before the retry budget is exhausted. + // + // 仅 anthropic-strict 模型族执行此过滤;passback-required 上游 (DeepSeek/Kimi/GLM 等) + // 要求历史 thinking block 原样回传,过滤反而制造 400。reqModel 此时已是映射后的模型 ID。 + if err := replaceBody(FilterThinkingBlocks(body, reqModel)); err != nil { + return nil, err + } + // Chinese LLM thinking.type 协议差异补正(如 MiniMax 只接受 adaptive;Anthropic-SDK + // 客户端默认发 enabled)。仅对 passback-required 上游生效(claude-* 不会进来)。 + if ResolveThinkingProtocol(reqModel) == ThinkingProtocolPassbackRequired { + if rewritten, applied := NormalizeChineseLLMThinking(body, reqModel); applied { + if err := replaceBody(rewritten); err != nil { + return nil, err + } + logger.LegacyPrintf("service.gateway", "Account %d: rewrote thinking.type for %s (Anthropic-SDK default 'enabled' -> vendor-specific)", account.ID, reqModel) + } + } + + // 重试循环 + var resp *http.Response + lastWireBody := body + retryStart := time.Now() + for attempt := 1; attempt <= maxRetryAttempts; attempt++ { + // 构建上游请求(每次重试需要重新构建,因为请求体需要重新读取) + upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) + upstreamReq, wireBody, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) + releaseUpstreamCtx() + if err != nil { + return nil, err + } + // 记录本次实际发送的 wire body;只有请求成功后才写回 ParsedRequest,避免 400 retry 基于已签名 CCH 再改写。 + lastWireBody = wireBody + + // 发送请求 + resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfile) + if err != nil { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + // Ensure the client receives an error response (handlers assume Forward writes on non-failover errors). + safeErr := sanitizeUpstreamErrorMessage(err.Error()) + setOpsUpstreamError(c, 0, safeErr, "") + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: 0, + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), + Kind: "request_error", + Message: safeErr, + }) + c.JSON(http.StatusBadGateway, gin.H{ + "type": "error", + "error": gin.H{ + "type": "upstream_error", + "message": "Upstream request failed", + }, + }) + return nil, fmt.Errorf("upstream request failed: %s", safeErr) + } + + // 优先检测thinking block签名错误(400)并重试一次 + if resp.StatusCode == 400 { + respBody, readErr := s.readUpstreamErrorBody(resp) + if readErr == nil { + _ = resp.Body.Close() + + if s.shouldRectifySignatureError(ctx, account, respBody, reqModel) { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), + Kind: "signature_error", + Message: extractUpstreamErrorMessage(respBody), + Detail: func() string { + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) + } + return "" + }(), + }) + + looksLikeToolSignatureError := func(msg string) bool { + m := strings.ToLower(msg) + return strings.Contains(m, "tool_use") || + strings.Contains(m, "tool_result") || + strings.Contains(m, "functioncall") || + strings.Contains(m, "function_call") || + strings.Contains(m, "functionresponse") || + strings.Contains(m, "function_response") + } + + // 避免在重试预算已耗尽时再发起额外请求 + if time.Since(retryStart) >= maxRetryElapsed { + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + break + } + logger.LegacyPrintf("service.gateway", "[warn] Account %d: thinking blocks have invalid signature, retrying with filtered blocks", account.ID) + + // Conservative two-stage fallback: + // 1) Disable thinking + thinking->text (preserve content) + // 2) Only if upstream still errors AND error message points to tool/function signature issues: + // also downgrade tool_use/tool_result blocks to text. + + filteredBody := FilterThinkingBlocksForRetry(body, reqModel) + retryCtx, releaseRetryCtx := detachStreamUpstreamContext(ctx, reqStream) + retryReq, retryWireBody, buildErr := s.buildUpstreamRequest(retryCtx, c, account, filteredBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) + releaseRetryCtx() + if buildErr == nil { + retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsProfile) + if retryErr == nil { + if retryResp.StatusCode < 400 { + // 重试请求被上游接受后同步 ParsedRequest,保证 usage/日志看到真实请求体。 + lastWireBody = retryWireBody + if err := replaceBody(retryWireBody); err != nil { + _ = retryResp.Body.Close() + return nil, err + } + logger.LegacyPrintf("service.gateway", "Account %d: thinking block retry succeeded (blocks downgraded)", account.ID) + resp = retryResp + break + } + + retryRespBody, retryReadErr := s.readUpstreamErrorBody(retryResp) + _ = retryResp.Body.Close() + if retryReadErr == nil && retryResp.StatusCode == 400 && s.isSignatureErrorPattern(ctx, account, retryRespBody) { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: retryResp.StatusCode, + UpstreamRequestID: retryResp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(retryReq.URL.String()), + Kind: "signature_retry_thinking", + Message: extractUpstreamErrorMessage(retryRespBody), + Detail: func() string { + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + return truncateString(string(retryRespBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) + } + return "" + }(), + }) + msg2 := extractUpstreamErrorMessage(retryRespBody) + if looksLikeToolSignatureError(msg2) && time.Since(retryStart) < maxRetryElapsed { + logger.LegacyPrintf("service.gateway", "Account %d: signature retry still failing and looks tool-related, retrying with tool blocks downgraded", account.ID) + filteredBody2 := FilterSignatureSensitiveBlocksForRetry(body, reqModel) + retryCtx2, releaseRetryCtx2 := detachStreamUpstreamContext(ctx, reqStream) + retryReq2, retryWireBody2, buildErr2 := s.buildUpstreamRequest(retryCtx2, c, account, filteredBody2, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) + releaseRetryCtx2() + if buildErr2 == nil { + retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsProfile) + if retryErr2 == nil { + if retryResp2.StatusCode < 400 { + // 二阶段工具块降级成功时也必须更新当前 body。 + lastWireBody = retryWireBody2 + if err := replaceBody(retryWireBody2); err != nil { + _ = retryResp2.Body.Close() + return nil, err + } + } + resp = retryResp2 + break + } + if retryResp2 != nil && retryResp2.Body != nil { + _ = retryResp2.Body.Close() + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: 0, + UpstreamURL: safeUpstreamURL(retryReq2.URL.String()), + Kind: "signature_retry_tools_request_error", + Message: sanitizeUpstreamErrorMessage(retryErr2.Error()), + }) + logger.LegacyPrintf("service.gateway", "Account %d: tool-downgrade signature retry failed: %v", account.ID, retryErr2) + } else { + logger.LegacyPrintf("service.gateway", "Account %d: tool-downgrade signature retry build failed: %v", account.ID, buildErr2) + } + } + } + + // Fall back to the original retry response context. + resp = &http.Response{ + StatusCode: retryResp.StatusCode, + Header: retryResp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(retryRespBody)), + } + break + } + if retryResp != nil && retryResp.Body != nil { + _ = retryResp.Body.Close() + } + logger.LegacyPrintf("service.gateway", "Account %d: signature error retry failed: %v", account.ID, retryErr) + } else { + logger.LegacyPrintf("service.gateway", "Account %d: signature error retry build request failed: %v", account.ID, buildErr) + } + + // Retry failed: restore original response body and continue handling. + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + break + } + // 不是签名错误(或整流器已关闭),继续检查 budget 约束 + errMsg := extractUpstreamErrorMessage(respBody) + if isThinkingBudgetConstraintError(errMsg) && s.settingService.IsBudgetRectifierEnabled(ctx) { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), + Kind: "budget_constraint_error", + Message: errMsg, + Detail: func() string { + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) + } + return "" + }(), + }) + + rectifiedBody, applied := RectifyThinkingBudget(body) + if applied && time.Since(retryStart) < maxRetryElapsed { + logger.LegacyPrintf("service.gateway", "Account %d: detected budget_tokens constraint error, retrying with rectified budget (budget_tokens=%d, max_tokens=%d)", account.ID, BudgetRectifyBudgetTokens, BudgetRectifyMaxTokens) + budgetRetryCtx, releaseBudgetRetryCtx := detachStreamUpstreamContext(ctx, reqStream) + budgetRetryReq, budgetWireBody, buildErr := s.buildUpstreamRequest(budgetRetryCtx, c, account, rectifiedBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) + releaseBudgetRetryCtx() + if buildErr == nil { + budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsProfile) + if retryErr == nil { + if budgetRetryResp.StatusCode < 400 { + // budget 修正请求成功后,ParsedRequest 也要描述被接受的修正版。 + lastWireBody = budgetWireBody + if err := replaceBody(budgetWireBody); err != nil { + _ = budgetRetryResp.Body.Close() + return nil, err + } + } + resp = budgetRetryResp + break + } + if budgetRetryResp != nil && budgetRetryResp.Body != nil { + _ = budgetRetryResp.Body.Close() + } + logger.LegacyPrintf("service.gateway", "Account %d: budget rectifier retry failed: %v", account.ID, retryErr) + } else { + logger.LegacyPrintf("service.gateway", "Account %d: budget rectifier retry build failed: %v", account.ID, buildErr) + } + } + } + + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + } + } + + // 检查是否需要通用重试(排除400,因为400已经在上面特殊处理过了) + if resp.StatusCode >= 400 && resp.StatusCode != 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) { + if attempt < maxRetryAttempts { + elapsed := time.Since(retryStart) + if elapsed >= maxRetryElapsed { + break + } + + delay := retryBackoffDelay(attempt) + remaining := maxRetryElapsed - elapsed + if delay > remaining { + delay = remaining + } + if delay <= 0 { + break + } + + respBody, _ := s.readUpstreamErrorBody(resp) + _ = resp.Body.Close() + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), + Kind: "retry", + Message: extractUpstreamErrorMessage(respBody), + Detail: func() string { + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) + } + return "" + }(), + }) + logger.LegacyPrintf("service.gateway", "Account %d: upstream error %d, retry %d/%d after %v (elapsed=%v/%v)", + account.ID, resp.StatusCode, attempt, maxRetryAttempts, delay, elapsed, maxRetryElapsed) + if err := sleepWithContext(ctx, delay); err != nil { + return nil, err + } + continue + } + // 最后一次尝试也失败,跳出循环处理重试耗尽 + break + } + + // 不需要重试(成功或不可重试的错误),跳出循环 + // DEBUG: 输出响应 headers(用于检测 rate limit 信息) + if account.Platform == PlatformGemini && resp.StatusCode < 400 && s.cfg != nil && s.cfg.Gateway.GeminiDebugResponseHeaders { + logger.LegacyPrintf("service.gateway", "[DEBUG] Gemini API Response Headers for account %d:", account.ID) + for k, v := range resp.Header { + logger.LegacyPrintf("service.gateway", "[DEBUG] %s: %v", k, v) + } + } + break + } + if resp == nil || resp.Body == nil { + return nil, errors.New("upstream request failed: empty response") + } + defer func() { _ = resp.Body.Close() }() + + // 处理重试耗尽的情况 + if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) { + if s.shouldFailoverUpstreamError(resp.StatusCode) { + respBody, _ := s.readUpstreamErrorBody(resp) + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + + // 调试日志:打印重试耗尽后的错误响应 + logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d RequestID=%s Body=%s", + account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000)) + + s.handleRetryExhaustedSideEffects(ctx, resp, account) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "retry_exhausted_failover", + Message: extractUpstreamErrorMessage(respBody), + Detail: func() string { + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) + } + return "" + }(), + }) + return nil, &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: respBody, + RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + } + } + return s.handleRetryExhaustedError(ctx, resp, c, account) + } + + // 处理可切换账号的错误 + if resp.StatusCode >= 400 && s.shouldFailoverUpstreamError(resp.StatusCode) { + respBody, _ := s.readUpstreamErrorBody(resp) + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + + // 调试日志:打印上游错误响应 + logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (failover): Account=%d(%s) Status=%d RequestID=%s Body=%s", + account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000)) + + s.handleFailoverSideEffects(ctx, resp, account, reqModel) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "failover", + Message: extractUpstreamErrorMessage(respBody), + Detail: func() string { + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) + } + return "" + }(), + }) + return nil, &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: respBody, + RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + } + } + if resp.StatusCode >= 400 { + // 可选:对部分 400 触发 failover(默认关闭以保持语义) + if resp.StatusCode == 400 && s.cfg != nil && s.cfg.Gateway.FailoverOn400 { + respBody, readErr := s.readUpstreamErrorBody(resp) + if readErr != nil { + // ReadAll failed, fall back to normal error handling without consuming the stream + return s.handleErrorResponse(ctx, resp, c, account, reqModel) + } + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + + if s.shouldFailoverOn400(respBody) { + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(respBody), maxBytes) + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "failover_on_400", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + + if s.cfg.Gateway.LogUpstreamErrorBody { + logger.LegacyPrintf("service.gateway", + "Account %d: 400 error, attempting failover: %s", + account.ID, + truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), + ) + } else { + logger.LegacyPrintf("service.gateway", "Account %d: 400 error, attempting failover", account.ID) + } + s.handleFailoverSideEffects(ctx, resp, account, reqModel) + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody} + } + } + return s.handleErrorResponse(ctx, resp, c, account, reqModel) + } + + // 处理正常响应 + + if !bytes.Equal(lastWireBody, body) { + // 成功后再同步最终 wire body,避免失败重试从已签名 CCH 的 body 继续派生。 + if err := replaceBody(lastWireBody); err != nil { + return nil, err + } + } + + // 触发上游接受回调(提前释放串行锁,不等流完成) + if parsed.OnUpstreamAccepted != nil { + parsed.OnUpstreamAccepted() + } + + var usage *ClaudeUsage + var firstTokenMs *int + var clientDisconnect bool + if reqStream { + streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel, shouldMimicClaudeCode) + if err != nil { + var sseErr *sseStreamErrorEventError + if errors.As(err, &sseErr) { + // 上游 HTTP 200 + SSE 流体内出现 event:error 帧。 + // 保留 StatusCode=403 以兼容既有 failover/客户端响应语义, + // 但补全 ResponseBody 与 ops 上下文,让运维日志能反映上游真实错误。 + body := []byte(sseErr.RawData) + + upstreamMsg := sanitizeUpstreamErrorMessage( + strings.TrimSpace(extractUpstreamErrorMessage(body)), + ) + + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(sseErr.RawData, maxBytes) + } + + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: 403, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "stream_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + + logger.LegacyPrintf("service.gateway", + "[Forward] SSE error event in stream: Account=%d(%s) RequestID=%s Body=%s", + account.ID, account.Name, resp.Header.Get("x-request-id"), + truncateString(sseErr.RawData, 1000), + ) + + return nil, &UpstreamFailoverError{ + StatusCode: 403, + ResponseBody: body, + } + } + return nil, err + } + usage = streamResult.usage + firstTokenMs = streamResult.firstTokenMs + clientDisconnect = streamResult.clientDisconnect + } else { + usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, reqModel) + if err != nil { + return nil, err + } + } + + return &ForwardResult{ + RequestID: resp.Header.Get("x-request-id"), + Usage: *usage, + Model: originalModel, // 使用原始模型用于计费和日志 + UpstreamModel: mappedModel, + Stream: reqStream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, + }, nil +} + +// ResolveChannelMapping 委托渠道服务解析模型映射 +func (s *GatewayService) ResolveChannelMapping(ctx context.Context, groupID int64, model string) ChannelMappingResult { + if s.channelService == nil { + return ChannelMappingResult{MappedModel: model} + } + return s.channelService.ResolveChannelMapping(ctx, groupID, model) +} + +// ReplaceModelInBody 替换请求体中的模型名(导出供 handler 使用) +func (s *GatewayService) ReplaceModelInBody(body []byte, newModel string) []byte { + return ReplaceModelInBody(body, newModel) +} + +// IsModelRestricted 检查模型是否被渠道限制 +func (s *GatewayService) IsModelRestricted(ctx context.Context, groupID int64, model string) bool { + if s.channelService == nil { + return false + } + return s.channelService.IsModelRestricted(ctx, groupID, model) +} + +// ResolveChannelMappingAndRestrict 解析渠道映射。 +// 模型限制检查已移至调度阶段(checkChannelPricingRestriction),restricted 始终返回 false。 +func (s *GatewayService) 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) +} + +// checkChannelPricingRestriction 根据渠道计费基准检查模型是否受定价列表限制。 +// 供调度阶段预检查(requested / channel_mapped)。 +// upstream 需逐账号检查,此处返回 false。 +func (s *GatewayService) 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) +} + +// billingModelForRestriction 根据计费基准确定限制检查使用的模型。 +// upstream 返回空(需逐账号检查)。 +func billingModelForRestriction(source, requestedModel, channelMappedModel string) string { + switch source { + case BillingModelSourceRequested: + return requestedModel + case BillingModelSourceUpstream: + return "" + case BillingModelSourceChannelMapped: + return channelMappedModel + default: + return channelMappedModel + } +} + +// isUpstreamModelRestrictedByChannel 检查账号映射后的上游模型是否受渠道定价限制。 +// 仅在 BillingModelSource="upstream" 且 RestrictModels=true 时由调度循环调用。 +func (s *GatewayService) isUpstreamModelRestrictedByChannel(ctx context.Context, groupID int64, account *Account, requestedModel string) bool { + if s.channelService == nil { + return false + } + upstreamModel := resolveAccountUpstreamModel(account, requestedModel) + if upstreamModel == "" { + return false + } + return s.channelService.IsModelRestricted(ctx, groupID, upstreamModel) +} + +// resolveAccountUpstreamModel 确定账号将请求模型映射为什么上游模型。 +func resolveAccountUpstreamModel(account *Account, requestedModel string) string { + if account.Platform == PlatformAntigravity { + return mapAntigravityModel(account, requestedModel) + } + return account.GetMappedModel(requestedModel) +} + +// needsUpstreamChannelRestrictionCheck 判断是否需要在调度循环中逐账号检查上游模型的渠道限制。 +func (s *GatewayService) 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 channel upstream restriction", "group_id", *groupID, "error", err) + return false + } + if ch == nil || !ch.RestrictModels { + return false + } + return ch.BillingModelSource == BillingModelSourceUpstream +} + +// isStickyAccountUpstreamRestricted 检查粘性会话命中的账号是否受 upstream 渠道限制。 +// 合并 needsUpstreamChannelRestrictionCheck + isUpstreamModelRestrictedByChannel 两步调用, +// 供 sticky session 条件链使用,避免内联多个函数调用导致行过长。 +func (s *GatewayService) isStickyAccountUpstreamRestricted(ctx context.Context, groupID *int64, account *Account, requestedModel string) bool { + if groupID == nil { + return false + } + if !s.needsUpstreamChannelRestrictionCheck(ctx, groupID) { + return false + } + return s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel) +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 79150f5598..c151375623 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1,18 +1,13 @@ package service import ( - "bufio" "bytes" "context" - "crypto/sha256" "encoding/json" "errors" "fmt" - "io" "log/slog" - "net" "net/http" - "net/url" "os" "path/filepath" "regexp" @@ -20,27 +15,16 @@ import ( "strconv" "strings" "sync/atomic" - "syscall" "time" "unsafe" "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/Wei-Shaw/sub2api/internal/pkg/anthropicfp" - "github.com/Wei-Shaw/sub2api/internal/pkg/claude" - "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" - "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" "github.com/cespare/xxhash/v2" - "github.com/google/uuid" gocache "github.com/patrickmn/go-cache" "github.com/tidwall/gjson" - "github.com/tidwall/sjson" "golang.org/x/sync/singleflight" - - "github.com/gin-gonic/gin" ) const ( @@ -1055,509 +1039,6 @@ func (s *GatewayService) hashContent(content string) string { return strconv.FormatUint(h, 36) } -type anthropicCacheControlPayload struct { - Type string `json:"type"` - TTL string `json:"ttl,omitempty"` -} - -type anthropicSystemTextBlockPayload struct { - Type string `json:"type"` - Text string `json:"text"` - CacheControl *anthropicCacheControlPayload `json:"cache_control,omitempty"` -} - -type anthropicMetadataPayload struct { - UserID string `json:"user_id"` -} - -// replaceModelInBody 替换请求体中的model字段 -// 优先使用定点修改,尽量保持客户端原始字段顺序。 -func (s *GatewayService) replaceModelInBody(body []byte, newModel string) []byte { - return ReplaceModelInBody(body, newModel) -} - -type claudeOAuthNormalizeOptions struct { - injectMetadata bool - metadataUserID string - stripSystemCacheControl bool -} - -// sanitizeSystemText rewrites only the fixed OpenCode identity sentence (if present). -// We intentionally avoid broad keyword replacement in system prompts to prevent -// accidentally changing user-provided instructions. -func sanitizeSystemText(text string) string { - if text == "" { - return text - } - // Some clients include a fixed OpenCode identity sentence. Anthropic may treat - // this as a non-Claude-Code fingerprint, so rewrite it to the canonical - // Claude Code banner before generic "OpenCode"/"opencode" replacements. - text = strings.ReplaceAll( - text, - "You are OpenCode, the best coding agent on the planet.", - strings.TrimSpace(claudeCodeSystemPrompt), - ) - return text -} - -func marshalAnthropicSystemTextBlock(text string, includeCacheControl bool) ([]byte, error) { - block := anthropicSystemTextBlockPayload{ - Type: "text", - Text: text, - } - if includeCacheControl { - block.CacheControl = &anthropicCacheControlPayload{ - Type: "ephemeral", - TTL: claude.DefaultCacheControlTTL, - } - } - return json.Marshal(block) -} - -func marshalAnthropicSystemTextBlockWithCacheControl(text string, cacheControl any) ([]byte, error) { - block := map[string]any{ - "type": "text", - "text": text, - } - if cacheControl != nil { - block["cache_control"] = cacheControl - } - return json.Marshal(block) -} - -func marshalAnthropicMetadata(userID string) ([]byte, error) { - return json.Marshal(anthropicMetadataPayload{UserID: userID}) -} - -func buildJSONArrayRaw(items [][]byte) []byte { - if len(items) == 0 { - return []byte("[]") - } - - total := 2 - for _, item := range items { - total += len(item) - } - total += len(items) - 1 - - buf := make([]byte, 0, total) - buf = append(buf, '[') - for i, item := range items { - if i > 0 { - buf = append(buf, ',') - } - buf = append(buf, item...) - } - buf = append(buf, ']') - return buf -} - -func setJSONValueBytes(body []byte, path string, value any) ([]byte, bool) { - next, err := sjson.SetBytes(body, path, value) - if err != nil { - return body, false - } - return next, true -} - -func setJSONRawBytes(body []byte, path string, raw []byte) ([]byte, bool) { - next, err := sjson.SetRawBytes(body, path, raw) - if err != nil { - return body, false - } - return next, true -} - -func deleteJSONPathBytes(body []byte, path string) ([]byte, bool) { - next, err := sjson.DeleteBytes(body, path) - if err != nil { - return body, false - } - return next, true -} - -func normalizeClaudeOAuthSystemBody(body []byte, opts claudeOAuthNormalizeOptions) ([]byte, bool) { - sys := gjson.GetBytes(body, "system") - if !sys.Exists() { - return body, false - } - - out := body - modified := false - - switch { - case sys.Type == gjson.String: - sanitized := sanitizeSystemText(sys.String()) - if sanitized != sys.String() { - if next, ok := setJSONValueBytes(out, "system", sanitized); ok { - out = next - modified = true - } - } - case sys.IsArray(): - index := 0 - sys.ForEach(func(_, item gjson.Result) bool { - if item.Get("type").String() == "text" { - textResult := item.Get("text") - if textResult.Exists() && textResult.Type == gjson.String { - text := textResult.String() - sanitized := sanitizeSystemText(text) - if sanitized != text { - if next, ok := setJSONValueBytes(out, fmt.Sprintf("system.%d.text", index), sanitized); ok { - out = next - modified = true - } - } - } - } - - if opts.stripSystemCacheControl && item.Get("cache_control").Exists() { - if next, ok := deleteJSONPathBytes(out, fmt.Sprintf("system.%d.cache_control", index)); ok { - out = next - modified = true - } - } - - index++ - return true - }) - } - - return out, modified -} - -func ensureClaudeOAuthMetadataUserID(body []byte, userID string) ([]byte, bool) { - if strings.TrimSpace(userID) == "" { - return body, false - } - - metadata := gjson.GetBytes(body, "metadata") - if !metadata.Exists() || metadata.Type == gjson.Null { - raw, err := marshalAnthropicMetadata(userID) - if err != nil { - return body, false - } - return setJSONRawBytes(body, "metadata", raw) - } - - trimmedRaw := strings.TrimSpace(metadata.Raw) - if strings.HasPrefix(trimmedRaw, "{") { - existing := metadata.Get("user_id") - if existing.Exists() && existing.Type == gjson.String && existing.String() != "" { - return body, false - } - return setJSONValueBytes(body, "metadata.user_id", userID) - } - - raw, err := marshalAnthropicMetadata(userID) - if err != nil { - return body, false - } - return setJSONRawBytes(body, "metadata", raw) -} - -func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAuthNormalizeOptions) ([]byte, string) { - if len(body) == 0 { - return body, modelID - } - - out := body - modified := false - - if next, changed := normalizeClaudeOAuthSystemBody(out, opts); changed { - out = next - modified = true - } - - rawModel := gjson.GetBytes(out, "model") - if rawModel.Exists() && rawModel.Type == gjson.String { - normalized := claude.NormalizeModelID(rawModel.String()) - if normalized != rawModel.String() { - if next, ok := setJSONValueBytes(out, "model", normalized); ok { - out = next - modified = true - } - modelID = normalized - } - } - - // 确保 tools 字段存在(即使为空数组) - if !gjson.GetBytes(out, "tools").Exists() { - if next, ok := setJSONRawBytes(out, "tools", []byte("[]")); ok { - out = next - modified = true - } - } - - if opts.injectMetadata && opts.metadataUserID != "" { - if next, changed := ensureClaudeOAuthMetadataUserID(out, opts.metadataUserID); changed { - out = next - modified = true - } - } - - // temperature:真实 Claude Code CLI 总是发送 temperature(默认 1,客户端可覆盖)。 - // 之前的实现直接 delete 会导致 payload 缺字段,与真实 CLI 字节级不一致。 - // 策略:客户端传了什么就透传;没传则补默认 1。 - if !gjson.GetBytes(out, "temperature").Exists() { - if next, ok := setJSONValueBytes(out, "temperature", 1); ok { - out = next - modified = true - } - } - - // max_tokens:真实 CLI 的默认值是 128000。缺失时补齐以对齐指纹。 - if !gjson.GetBytes(out, "max_tokens").Exists() { - if next, ok := setJSONValueBytes(out, "max_tokens", 128000); ok { - out = next - modified = true - } - } - - // context_management:thinking.type 为 enabled/adaptive 时,真实 CLI 会自动 - // 附带 {"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}。 - // 客户端显式传了就透传;否则按 CLI 行为补齐。 - // - // 注:本函数不按 model 名决定是否保留 context_management。“最终 beta - // header 不含 context-management-2025-06-27 时 strip 字段”的能力维度 - // 对称约束由 sanitizeAnthropicBodyForBetaTokens 在 buildUpstreamRequest / - // buildCountTokensRequest 层统一执行,与 Bedrock 路径的 - // sanitizeBedrockFieldsForBetaTokens 对称。 - if !gjson.GetBytes(out, "context_management").Exists() { - thinkingType := gjson.GetBytes(out, "thinking.type").String() - if thinkingType == "enabled" || thinkingType == "adaptive" { - const cmDefault = `{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}` - if next, ok := setJSONRawBytes(out, "context_management", []byte(cmDefault)); ok { - out = next - modified = true - } - } - } - - // tool_choice:与 Parrot 对齐,不再无条件删除。 - // - 客户端传了 {"type":"tool","name":"X"} → 保留结构,name 由 - // applyToolNameRewriteToBody 同步映射为假名 - // - 其他形态(auto/any/none)原样透传 - // 如果 body 里完全没有 tools(空数组),tool_choice 没意义时才删除 - if !gjson.GetBytes(out, "tools").IsArray() || len(gjson.GetBytes(out, "tools").Array()) == 0 { - if gjson.GetBytes(out, "tool_choice").Exists() { - if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok { - out = next - modified = true - } - } - } - - if !modified { - return body, modelID - } - - return out, modelID -} - -func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account *Account, fp *Fingerprint) string { - if parsed == nil || account == nil { - return "" - } - if parsed.MetadataUserID != "" { - return "" - } - - userID := strings.TrimSpace(account.GetClaudeUserID()) - if userID == "" && fp != nil { - userID = fp.ClientID - } - if userID == "" { - // Fall back to a random, well-formed client id so we can still satisfy - // Claude Code OAuth requirements when account metadata is incomplete. - userID = generateClientID() - } - - // session_id 用"会话级稳定种子"派生(账号 + 客户端区分因子 + 首条 user 文本): - // 随对话在尾部追加 messages 时保持不变,贴近真实 CC 进程级稳定的 session_id。 - // 不复用 GenerateSessionHash —— 后者是粘性路由键、按设计逐轮变化(见其测试)。 - var firstUserText string - if parsed.Body != nil { - firstUserText = extractFirstUserText(parsed.Body.Bytes()) - } - seed := buildStableSessionSeed(account.ID, sessionContextDiscriminator(parsed.SessionContext), firstUserText) - sessionID := generateSessionUUID(seed) - - // 根据指纹 UA 版本选择输出格式 - var uaVersion string - if fp != nil { - uaVersion = ExtractCLIVersion(fp.UserAgent) - } - accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid")) - return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion) -} - -// applyClaudeCodeOAuthMimicryToBody 将"非 Claude Code 客户端 + Claude OAuth 账号" -// 路径上原本只在 /v1/messages 里做的完整伪装应用到任意 body 上。 -// -// 这是 /v1/messages 主路径上 rewriteSystemForNonClaudeCode + -// normalizeClaudeOAuthRequestBody 流程的通用版,供 OpenAI 协议兼容层 -// (ForwardAsChatCompletions / ForwardAsResponses) 复用。 -// -// 未抽离之前,OpenAI 协议兼容层仅做 injectClaudeCodePrompt(前置追加), -// 而仓内 /v1/messages 路径自己的注释明确说过"仅前置追加无法通过 Anthropic -// 第三方检测";那条注释就是本函数存在的根因。 -// -// 参数: -// - ctx / c:用于读取指纹和 gateway settings;c 可为 nil(如 count_tokens)。 -// - account:必须是 OAuth 账号,且调用方已判断不是 Claude Code 客户端。 -// - body:已经 marshal 成 Anthropic /v1/messages 格式的请求体。 -// - systemRaw:body 中原始 system 字段(用于判断是否需要 rewrite)。 -// - model:最终会发给上游的模型 ID(用于 haiku 旁路 + metadata 版本选择)。 -// -// 返回:改写后的 body。即使中间任何一步失败,也会退化成原 body(不会 panic)。 -func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( - ctx context.Context, - c *gin.Context, - account *Account, - body []byte, - systemRaw any, - model string, -) []byte { - if account == nil || !account.IsOAuth() || len(body) == 0 { - return body - } - - systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) - systemRewritten := false - if systemPromptInjectionEnabled && !strings.Contains(strings.ToLower(model), "haiku") { - body = rewriteSystemForNonClaudeCodeWithPromptBlocks(body, normalizeSystemParam(systemRaw), systemPrompt, systemPromptBlocks) - systemRewritten = true - } - - normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} - - if s.identityService != nil && c != nil && c.Request != nil { - if fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header); err == nil && fp != nil { - mimicMPT := false - if s.settingService != nil { - _, mimicMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx) - } - if !mimicMPT { - if uid := s.buildOAuthMetadataUserIDFromBody(ctx, account, fp, body); uid != "" { - normalizeOpts.injectMetadata = true - normalizeOpts.metadataUserID = uid - } - } - } - } - - body, _ = normalizeClaudeOAuthRequestBody(body, model, normalizeOpts) - - // Phase D+E+F: messages cache 策略 + 工具名混淆 + tools[-1] 断点 - // 对齐 Parrot transform_request 里剩余的字段级改写。顺序有语义约束: - // 1) messages cache:仅在配置开启时清除客户端断点并注入代理断点 - // 2) tool rewrite:最后改 tools[*].name / tool_choice.name 并在 tools[-1] - // 上打断点;mapping 存入 gin.Context 供响应侧 bytes.Replace 还原。 - body = s.rewriteMessageCacheControlIfEnabled(ctx, body) - - if rw := buildToolNameRewriteFromBody(body); rw != nil { - body = applyToolNameRewriteToBody(body, rw) - if c != nil { - c.Set(toolNameRewriteKey, rw) - } - } else { - body = applyToolsLastCacheBreakpoint(body) - } - - return body -} - -// buildOAuthMetadataUserIDFromBody 是 buildOAuthMetadataUserID 的变体, -// 适用于调用方手上没有 ParsedRequest 的场景(如 OpenAI 协议兼容层)。 -// -// 与 buildOAuthMetadataUserID 的唯一区别: -// - session hash 从 body 本体按同样规则重算,而不是读取 ParsedRequest 缓存值。 -// - 如果 body 里已经存在 metadata.user_id,则返回空(由 ensureClaudeOAuthMetadataUserID -// 自行决定是否覆盖)。 -func (s *GatewayService) buildOAuthMetadataUserIDFromBody( - ctx context.Context, - account *Account, - fp *Fingerprint, - body []byte, -) string { - _ = ctx - if account == nil { - return "" - } - if existing := gjson.GetBytes(body, "metadata.user_id").String(); existing != "" { - return "" - } - - userID := strings.TrimSpace(account.GetClaudeUserID()) - if userID == "" && fp != nil { - userID = fp.ClientID - } - if userID == "" { - userID = generateClientID() - } - - // 与 buildOAuthMetadataUserID 一致:用会话级稳定种子,避免整 body 哈希导致 - // 每轮(甚至每个 token 变化)都重算出不同的 session_id。 - var clientDiscriminator string - if fp != nil { - clientDiscriminator = fp.ClientID - } - seed := buildStableSessionSeed(account.ID, clientDiscriminator, extractFirstUserText(body)) - sessionID := generateSessionUUID(seed) - - var uaVersion string - if fp != nil { - uaVersion = ExtractCLIVersion(fp.UserAgent) - } - accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid")) - return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion) -} - -// buildStableSessionSeed 为伪装路径合成的 metadata.user_id session_id 生成"会话级稳定"种子。 -// -// 真实 Claude Code 的 session_id 是进程级随机 UUID,在一段会话内跨请求保持不变。无状态代理 -// 无法恢复该值,这里用"会话内不变的锚点"近似:账号 ID + 客户端区分因子 + 首条 user 消息文本。 -// 对话在尾部追加 messages 时这三者都不变,因此 generateSessionUUID(seed) 跨轮稳定。 -// -// 注意:粘性路由键 GenerateSessionHash 按设计逐轮变化(见其测试),本函数与之独立、互不影响。 -// accountID 恒存在,故 seed 永不为空 —— 输出始终是确定性 UUID,而非随机值。 -func buildStableSessionSeed(accountID int64, clientDiscriminator, firstUserText string) string { - var b strings.Builder - _, _ = b.WriteString(strconv.FormatInt(accountID, 10)) - _, _ = b.WriteString("::") - _, _ = b.WriteString(clientDiscriminator) - _, _ = b.WriteString("::") - _, _ = b.WriteString(firstUserText) - return b.String() -} - -// sessionContextDiscriminator 把请求上下文(客户端 IP / 归一化 UA / API Key ID)拼成 -// 一个跨客户端的区分因子,避免不同用户的相同首条消息派生出相同 session_id。 -func sessionContextDiscriminator(sc *SessionContext) string { - if sc == nil { - return "" - } - return sc.ClientIP + ":" + NormalizeSessionUserAgent(sc.UserAgent) + ":" + strconv.FormatInt(sc.APIKeyID, 10) -} - -// GenerateSessionUUID creates a deterministic UUID4 from a seed string. -func GenerateSessionUUID(seed string) string { - return generateSessionUUID(seed) -} - -func generateSessionUUID(seed string) string { - if seed == "" { - return uuid.NewString() - } - hash := sha256.Sum256([]byte(seed)) - bytes := hash[:16] - bytes[6] = (bytes[6] & 0x0f) | 0x40 - bytes[8] = (bytes[8] & 0x3f) | 0x80 - return fmt.Sprintf("%x-%x-%x-%x-%x", - bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:16]) -} - // GetAccessToken 获取账号凭证 func (s *GatewayService) GetAccessToken(ctx context.Context, account *Account) (string, string, error) { switch account.Type { @@ -1608,5474 +1089,6 @@ func (s *GatewayService) getOAuthToken(ctx context.Context, account *Account) (s return accessToken, "oauth", nil } -// 重试相关常量 -const ( - // 最大尝试次数(包含首次请求)。过多重试会导致请求堆积与资源耗尽。 - maxRetryAttempts = 5 - - // 指数退避:第 N 次失败后的等待 = retryBaseDelay * 2^(N-1),并且上限为 retryMaxDelay。 - retryBaseDelay = 300 * time.Millisecond - retryMaxDelay = 3 * time.Second - - // 最大重试耗时(包含请求本身耗时 + 退避等待时间)。 - // 用于防止极端情况下 goroutine 长时间堆积导致资源耗尽。 - maxRetryElapsed = 10 * time.Second -) - -func (s *GatewayService) shouldRetryUpstreamError(account *Account, statusCode int) bool { - // OAuth/Setup Token 账号:仅 403 重试 - if account.IsOAuth() { - return statusCode == 403 - } - - // API Key 账号:未配置的错误码重试 - return !account.ShouldHandleErrorCode(statusCode) -} - -// shouldFailoverUpstreamError determines whether an upstream error should trigger account failover. -func (s *GatewayService) shouldFailoverUpstreamError(statusCode int) bool { - switch statusCode { - case 401, 403, 429, 529: - return true - default: - return statusCode >= 500 - } -} - -func retryBackoffDelay(attempt int) time.Duration { - // attempt 从 1 开始,表示第 attempt 次请求刚失败,需要等待后进行第 attempt+1 次请求。 - if attempt <= 0 { - return retryBaseDelay - } - delay := retryBaseDelay * time.Duration(1<<(attempt-1)) - if delay > retryMaxDelay { - return retryMaxDelay - } - return delay -} - -func sleepWithContext(ctx context.Context, d time.Duration) error { - if d <= 0 { - return nil - } - timer := time.NewTimer(d) - defer func() { - if !timer.Stop() { - select { - case <-timer.C: - default: - } - } - }() - - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -// isClaudeCodeClient 判断请求是否来自真正的 Claude Code 客户端。 -// 判定条件: -// 1. User-Agent 匹配 claude-cli/X.Y.Z(大小写不敏感) -// 2. metadata.user_id 符合 Claude Code 格式(legacy 或 JSON 格式) -// -// 只检查 metadata.user_id 非空不够严格:第三方工具(opencode 等)可能伪造 UA -// 并附带任意 metadata.user_id 字符串,从而绕过 mimicry。必须通过 ParseMetadataUserID -// 验证格式才能确认是真正的 Claude Code 客户端。 -func isClaudeCodeClient(userAgent string, metadataUserID string) bool { - if !claudeCliUserAgentRe.MatchString(userAgent) { - return false - } - return ParseMetadataUserID(metadataUserID) != nil -} - -func shouldUseClaudeCodeNoopDeltaKeepalive(userAgent string) bool { - version := ExtractCLIVersion(userAgent) - if version == "" { - return false - } - return CompareVersions(version, claudeCodeNoopDeltaKeepaliveMinVersion) >= 0 -} - -func claudeCodeKeepaliveDeltaTypeForContentBlock(blockType string) string { - switch blockType { - case "text": - return "text_delta" - case "tool_use": - return "input_json_delta" - case "thinking": - return "thinking_delta" - default: - return "" - } -} - -func claudeCodeKeepaliveFieldForDeltaType(deltaType string) string { - switch deltaType { - case "text_delta": - return "text" - case "input_json_delta": - return "partial_json" - case "thinking_delta": - return "thinking" - default: - return "" - } -} - -func buildClaudeCodeNoopDeltaKeepalive(index int, deltaType string) (string, bool) { - fieldName := claudeCodeKeepaliveFieldForDeltaType(deltaType) - if fieldName == "" { - return "", false - } - return fmt.Sprintf("event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"%s\",\"%s\":\"\"}}\n\n", index, deltaType, fieldName), true -} - -func sseEventIndex(event map[string]any) (int, bool) { - switch v := event["index"].(type) { - case float64: - return int(v), true - case int: - return v, true - case int64: - return int(v), true - case json.Number: - i, err := v.Int64() - if err != nil { - return 0, false - } - return int(i), true - default: - return 0, false - } -} - -// normalizeSystemParam 将 json.RawMessage 类型的 system 参数转为标准 Go 类型(string / []any / nil), -// 避免 type switch 中 json.RawMessage(底层 []byte)无法匹配 case string / case []any / case nil 的问题。 -// 这是 Go 的 typed nil 陷阱:(json.RawMessage, nil) ≠ (nil, nil)。 -func normalizeSystemParam(system any) any { - raw, ok := system.(json.RawMessage) - if !ok { - return system - } - if len(raw) == 0 { - return nil - } - var parsed any - if err := json.Unmarshal(raw, &parsed); err != nil { - return nil - } - return parsed -} - -// systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词 -// 使用前缀匹配支持多种变体(标准版、Agent SDK 版等) -func systemIncludesClaudeCodePrompt(system any) bool { - system = normalizeSystemParam(system) - switch v := system.(type) { - case string: - return hasClaudeCodePrefix(v) - case []any: - for _, item := range v { - if m, ok := item.(map[string]any); ok { - if text, ok := m["text"].(string); ok && hasClaudeCodePrefix(text) { - return true - } - } - } - } - return false -} - -// hasClaudeCodePrefix 检查文本是否以 Claude Code 提示词的特征前缀开头 -func hasClaudeCodePrefix(text string) bool { - for _, prefix := range claudeCodePromptPrefixes { - if strings.HasPrefix(text, prefix) { - return true - } - } - return false -} - -// injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词 -// 处理 null、字符串、数组三种格式 -func injectClaudeCodePrompt(body []byte, system any) []byte { - system = normalizeSystemParam(system) - claudeCodeBlock, err := marshalAnthropicSystemTextBlock(claudeCodeSystemPrompt, true) - if err != nil { - logger.LegacyPrintf("service.gateway", "Warning: failed to build Claude Code prompt block: %v", err) - return body - } - // Opencode plugin applies an extra safeguard: it not only prepends the Claude Code - // banner, it also prefixes the next system instruction with the same banner plus - // a blank line. This helps when upstream concatenates system instructions. - claudeCodePrefix := strings.TrimSpace(claudeCodeSystemPrompt) - - var items [][]byte - - switch v := system.(type) { - case nil: - items = [][]byte{claudeCodeBlock} - case string: - // Be tolerant of older/newer clients that may differ only by trailing whitespace/newlines. - if strings.TrimSpace(v) == "" || strings.TrimSpace(v) == strings.TrimSpace(claudeCodeSystemPrompt) { - items = [][]byte{claudeCodeBlock} - } else { - // Mirror opencode behavior: keep the banner as a separate system entry, - // but also prefix the next system text with the banner. - merged := v - if !strings.HasPrefix(v, claudeCodePrefix) { - merged = claudeCodePrefix + "\n\n" + v - } - nextBlock, buildErr := marshalAnthropicSystemTextBlock(merged, false) - if buildErr != nil { - logger.LegacyPrintf("service.gateway", "Warning: failed to build prefixed Claude Code system block: %v", buildErr) - return body - } - items = [][]byte{claudeCodeBlock, nextBlock} - } - case []any: - items = make([][]byte, 0, len(v)+1) - items = append(items, claudeCodeBlock) - prefixedNext := false - systemResult := gjson.GetBytes(body, "system") - if systemResult.IsArray() { - systemResult.ForEach(func(_, item gjson.Result) bool { - textResult := item.Get("text") - if textResult.Exists() && textResult.Type == gjson.String && - strings.TrimSpace(textResult.String()) == strings.TrimSpace(claudeCodeSystemPrompt) { - return true - } - - raw := []byte(item.Raw) - // Prefix the first subsequent text system block once. - if !prefixedNext && item.Get("type").String() == "text" && textResult.Exists() && textResult.Type == gjson.String { - text := textResult.String() - if strings.TrimSpace(text) != "" && !strings.HasPrefix(text, claudeCodePrefix) { - next, setErr := sjson.SetBytes(raw, "text", claudeCodePrefix+"\n\n"+text) - if setErr == nil { - raw = next - prefixedNext = true - } - } - } - items = append(items, raw) - return true - }) - } else { - for _, item := range v { - m, ok := item.(map[string]any) - if !ok { - raw, marshalErr := json.Marshal(item) - if marshalErr == nil { - items = append(items, raw) - } - continue - } - if text, ok := m["text"].(string); ok && strings.TrimSpace(text) == strings.TrimSpace(claudeCodeSystemPrompt) { - continue - } - if !prefixedNext { - if blockType, _ := m["type"].(string); blockType == "text" { - if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" && !strings.HasPrefix(text, claudeCodePrefix) { - m["text"] = claudeCodePrefix + "\n\n" + text - prefixedNext = true - } - } - } - raw, marshalErr := json.Marshal(m) - if marshalErr == nil { - items = append(items, raw) - } - } - } - default: - items = [][]byte{claudeCodeBlock} - } - - result, ok := setJSONRawBytes(body, "system", buildJSONArrayRaw(items)) - if !ok { - logger.LegacyPrintf("service.gateway", "Warning: failed to inject Claude Code prompt") - return body - } - return result -} - -// rewriteSystemForNonClaudeCode 将非 Claude Code 客户端的 system prompt 迁移至 messages, -// system 字段仅保留 Claude Code 标识提示词。 -// Anthropic 基于 system 参数内容检测第三方应用,仅前置追加 Claude Code 提示词 -// 无法通过检测,因为后续内容仍为非 Claude Code 格式。 -// 策略:将原始 system prompt 提取并注入为 user/assistant 消息对,system 仅保留 Claude Code 标识。 -func rewriteSystemForNonClaudeCode(body []byte, system any) []byte { - return rewriteSystemForNonClaudeCodeWithPromptBlocks(body, system, "", "") -} - -func rewriteSystemForNonClaudeCodeWithPrompt(body []byte, system any, expansionPrompt string) []byte { - return rewriteSystemForNonClaudeCodeWithPromptBlocks(body, system, expansionPrompt, "") -} - -type claudeOAuthSystemPromptBlockConfig struct { - Enabled *bool `json:"enabled,omitempty"` - Type string `json:"type,omitempty"` - Text string `json:"text,omitempty"` - CacheControl json.RawMessage `json:"cache_control,omitempty"` -} - -type claudeOAuthSystemPromptBlocksEnvelope struct { - Blocks []claudeOAuthSystemPromptBlockConfig `json:"blocks"` -} - -func defaultClaudeOAuthExpansionPrompt(expansionPrompt string) string { - expansionPrompt = strings.TrimSpace(expansionPrompt) - if expansionPrompt == "" { - return claudeCodeSystemPromptExpansion - } - return expansionPrompt -} - -func parseClaudeOAuthSystemPromptBlocksConfig(raw string) ([]claudeOAuthSystemPromptBlockConfig, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil, nil - } - if strings.HasPrefix(raw, "[") { - var blocks []claudeOAuthSystemPromptBlockConfig - if err := json.Unmarshal([]byte(raw), &blocks); err != nil { - return nil, err - } - return blocks, nil - } - var envelope claudeOAuthSystemPromptBlocksEnvelope - if err := json.Unmarshal([]byte(raw), &envelope); err != nil { - return nil, err - } - return envelope.Blocks, nil -} - -func decodeClaudeOAuthSystemPromptCacheControl(raw json.RawMessage) (any, error) { - trimmed := bytes.TrimSpace(raw) - if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) || bytes.Equal(trimmed, []byte("false")) { - return nil, nil - } - if bytes.Equal(trimmed, []byte("true")) { - return map[string]string{ - "type": "ephemeral", - "ttl": claude.DefaultCacheControlTTL, - }, nil - } - var value any - if err := json.Unmarshal(trimmed, &value); err != nil { - return nil, err - } - if _, ok := value.(map[string]any); !ok { - return nil, fmt.Errorf("cache_control must be boolean, null, or object") - } - return value, nil -} - -func expandClaudeOAuthSystemPromptTextTemplate(body []byte, text string, expansionPrompt string) (string, error) { - if text == "" { - return "", nil - } - expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt) - billingText, err := buildBillingAttributionText(body, claude.CLICurrentVersion) - if err != nil { - return "", err - } - fp := computeClaudeCodeFingerprint(body, claude.CLICurrentVersion) - replacer := strings.NewReplacer( - "{billing_header}", billingText, - "{cc_version}", claude.CLICurrentVersion, - "{fp}", fp, - "{claude_code_system_prompt}", claudeCodeSystemPrompt, - "{claude_code_expansion_prompt}", expansionPrompt, - ) - return replacer.Replace(text), nil -} - -func defaultClaudeOAuthSystemPromptBlockConfig() []claudeOAuthSystemPromptBlockConfig { - enabled := true - return []claudeOAuthSystemPromptBlockConfig{ - { - Enabled: &enabled, - Type: "text", - Text: "{billing_header}", - }, - { - Enabled: &enabled, - Type: "text", - Text: "{claude_code_system_prompt}", - }, - { - Enabled: &enabled, - Type: "text", - Text: "{claude_code_expansion_prompt}", - CacheControl: json.RawMessage( - fmt.Sprintf(`{"type":"ephemeral","ttl":%q}`, claude.DefaultCacheControlTTL), - ), - }, - } -} - -func buildClaudeOAuthSystemPromptBlocksJSON(body []byte, expansionPrompt string, blocksConfig string) ([][]byte, error) { - blocks, err := parseClaudeOAuthSystemPromptBlocksConfig(blocksConfig) - if err != nil { - return nil, err - } - if len(blocks) == 0 { - blocks = defaultClaudeOAuthSystemPromptBlockConfig() - } - - items := make([][]byte, 0, len(blocks)) - for i, block := range blocks { - if block.Enabled != nil && !*block.Enabled { - continue - } - blockType := strings.TrimSpace(block.Type) - if blockType == "" { - blockType = "text" - } - if blockType != "text" { - return nil, fmt.Errorf("system block %d type %q is not supported", i, block.Type) - } - text, err := expandClaudeOAuthSystemPromptTextTemplate(body, block.Text, expansionPrompt) - if err != nil { - return nil, err - } - if strings.TrimSpace(text) == "" { - continue - } - cacheControl, err := decodeClaudeOAuthSystemPromptCacheControl(block.CacheControl) - if err != nil { - return nil, fmt.Errorf("system block %d cache_control: %w", i, err) - } - raw, err := marshalAnthropicSystemTextBlockWithCacheControl(text, cacheControl) - if err != nil { - return nil, err - } - items = append(items, raw) - } - return items, nil -} - -func ValidateClaudeOAuthSystemPromptBlocksConfig(raw string) error { - if strings.TrimSpace(raw) == "" { - return nil - } - blocks, err := parseClaudeOAuthSystemPromptBlocksConfig(raw) - if err != nil { - return infraerrors.BadRequest("INVALID_CLAUDE_OAUTH_SYSTEM_PROMPT_BLOCKS", "claude oauth system prompt blocks must be valid JSON") - } - for i, block := range blocks { - blockType := strings.TrimSpace(block.Type) - if blockType == "" { - blockType = "text" - } - if blockType != "text" { - return infraerrors.BadRequest("INVALID_CLAUDE_OAUTH_SYSTEM_PROMPT_BLOCKS", fmt.Sprintf("system block %d type must be text", i)) - } - if _, err := decodeClaudeOAuthSystemPromptCacheControl(block.CacheControl); err != nil { - return infraerrors.BadRequest("INVALID_CLAUDE_OAUTH_SYSTEM_PROMPT_BLOCKS", fmt.Sprintf("system block %d cache_control is invalid", i)) - } - } - return nil -} - -func rewriteSystemForNonClaudeCodeWithPromptBlocks(body []byte, system any, expansionPrompt string, blocksConfig string) []byte { - system = normalizeSystemParam(system) - expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt) - - // 1. 提取原始 system prompt 文本 - var originalSystemText string - switch v := system.(type) { - case string: - originalSystemText = strings.TrimSpace(v) - case []any: - var parts []string - for _, item := range v { - if m, ok := item.(map[string]any); ok { - if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" { - parts = append(parts, text) - } - } - } - originalSystemText = strings.Join(parts, "\n\n") - } - - // 2. 构造 system 数组,对齐真实 Claude Code CLI 的 3-block 形态: - // [0] billing attribution block(cc_version={cliVer}.{fp}; cc_entrypoint=cli;) - // [1] "You are Claude Code..." 身份前缀 block(默认不带 cache_control) - // [2] 工具无关的通用提示词扩充 block(带 cache_control 作为稳定缓存断点) - // - // 真实 CC 的 system 在身份前缀之后还有大段提示词,仅有 2 块会在块数/体量上明显 - // 区别于真实 CLI。这里注入 claudeCodeSystemPromptExpansion(中性段落)把形态做到 - // 接近真实,同时不注入会污染被代理用户行为的工具专属指令。 - // - // 缺失 billing block 的系统 payload 是 Anthropic 判定第三方的关键信号之一 - // (真实 CLI 每个请求都带)。新版 CLI 已取消 cch=... 签名字段,故 block 不再注入 - // cch(见 buildBillingAttributionText)。 - systemBlocks, blockErr := buildClaudeOAuthSystemPromptBlocksJSON(body, expansionPrompt, blocksConfig) - if blockErr != nil { - logger.LegacyPrintf("service.gateway", "Warning: failed to build configured Claude OAuth system blocks: %v", blockErr) - systemBlocks, blockErr = buildClaudeOAuthSystemPromptBlocksJSON(body, expansionPrompt, "") - } - if blockErr != nil { - logger.LegacyPrintf("service.gateway", "Warning: failed to build default Claude OAuth system blocks: %v", blockErr) - return body - } - out, ok := setJSONRawBytes(body, "system", buildJSONArrayRaw(systemBlocks)) - if !ok { - logger.LegacyPrintf("service.gateway", "Warning: failed to set Claude Code system prompt") - return body - } - - // 3. 将原始 system prompt 作为 user/assistant 消息对注入到 messages 开头 - // 模型仍通过 messages 接收完整指令,保留客户端功能 - ccPromptTrimmed := strings.TrimSpace(claudeCodeSystemPrompt) - if originalSystemText != "" && originalSystemText != ccPromptTrimmed && !hasClaudeCodePrefix(originalSystemText) { - instrMsg, err1 := json.Marshal(map[string]any{ - "role": "user", - "content": []map[string]any{ - {"type": "text", "text": "[System Instructions]\n" + originalSystemText}, - }, - }) - ackMsg, err2 := json.Marshal(map[string]any{ - "role": "assistant", - "content": []map[string]any{ - {"type": "text", "text": "Understood. I will follow these instructions."}, - }, - }) - if err1 != nil || err2 != nil { - logger.LegacyPrintf("service.gateway", "Warning: failed to marshal system-to-messages injection") - return out - } - - // 重建 messages 数组:[instruction, ack, ...originalMessages] - items := [][]byte{instrMsg, ackMsg} - messagesResult := gjson.GetBytes(out, "messages") - if messagesResult.IsArray() { - messagesResult.ForEach(func(_, msg gjson.Result) bool { - items = append(items, []byte(msg.Raw)) - return true - }) - } - - if next, setOk := setJSONRawBytes(out, "messages", buildJSONArrayRaw(items)); setOk { - out = next - } - } - - return out -} - -type cacheControlPath struct { - path string - log string -} - -func collectCacheControlPaths(body []byte) (invalidThinking []cacheControlPath, messagePaths []string, toolPaths []string, systemPaths []string) { - system := gjson.GetBytes(body, "system") - if system.IsArray() { - sysIndex := 0 - system.ForEach(func(_, item gjson.Result) bool { - if item.Get("cache_control").Exists() { - path := fmt.Sprintf("system.%d.cache_control", sysIndex) - if item.Get("type").String() == "thinking" { - invalidThinking = append(invalidThinking, cacheControlPath{ - path: path, - log: "[Warning] Removed illegal cache_control from thinking block in system", - }) - } else { - systemPaths = append(systemPaths, path) - } - } - sysIndex++ - return true - }) - } - - messages := gjson.GetBytes(body, "messages") - if messages.IsArray() { - msgIndex := 0 - messages.ForEach(func(_, msg gjson.Result) bool { - content := msg.Get("content") - if content.IsArray() { - contentIndex := 0 - content.ForEach(func(_, item gjson.Result) bool { - if item.Get("cache_control").Exists() { - path := fmt.Sprintf("messages.%d.content.%d.cache_control", msgIndex, contentIndex) - if item.Get("type").String() == "thinking" { - invalidThinking = append(invalidThinking, cacheControlPath{ - path: path, - log: fmt.Sprintf("[Warning] Removed illegal cache_control from thinking block in messages[%d].content[%d]", msgIndex, contentIndex), - }) - } else { - messagePaths = append(messagePaths, path) - } - } - contentIndex++ - return true - }) - } - msgIndex++ - return true - }) - } - - tools := gjson.GetBytes(body, "tools") - if tools.IsArray() { - toolIndex := 0 - tools.ForEach(func(_, tool gjson.Result) bool { - if tool.Get("cache_control").Exists() { - toolPaths = append(toolPaths, fmt.Sprintf("tools.%d.cache_control", toolIndex)) - } - toolIndex++ - return true - }) - } - - return invalidThinking, messagePaths, toolPaths, systemPaths -} - -// enforceCacheControlLimit 强制执行 cache_control 块数量限制(最多 4 个) -// 超限时优先移除工具断点,再移除 messages 断点,最后才移除 system 断点。 -func enforceCacheControlLimit(body []byte) []byte { - if len(body) == 0 { - return body - } - - invalidThinking, messagePaths, toolPaths, systemPaths := collectCacheControlPaths(body) - out := body - modified := false - - // 先清理 thinking 块中的非法 cache_control(thinking 块不支持该字段) - for _, item := range invalidThinking { - if !gjson.GetBytes(out, item.path).Exists() { - continue - } - next, ok := deleteJSONPathBytes(out, item.path) - if !ok { - continue - } - out = next - modified = true - logger.LegacyPrintf("service.gateway", "%s", item.log) - } - - count := len(messagePaths) + len(toolPaths) + len(systemPaths) - if count <= maxCacheControlBlocks { - if modified { - return out - } - return body - } - - // 超限:优先从 tools 中移除,再从 messages 中移除,最后才从 system 中移除。 - remaining := count - maxCacheControlBlocks - for i := len(toolPaths) - 1; i >= 0 && remaining > 0; i-- { - path := toolPaths[i] - if !gjson.GetBytes(out, path).Exists() { - continue - } - next, ok := deleteJSONPathBytes(out, path) - if !ok { - continue - } - out = next - modified = true - remaining-- - } - - for _, path := range messagePaths { - if remaining <= 0 { - break - } - if !gjson.GetBytes(out, path).Exists() { - continue - } - next, ok := deleteJSONPathBytes(out, path) - if !ok { - continue - } - out = next - modified = true - remaining-- - } - - for i := len(systemPaths) - 1; i >= 0 && remaining > 0; i-- { - path := systemPaths[i] - if !gjson.GetBytes(out, path).Exists() { - continue - } - next, ok := deleteJSONPathBytes(out, path) - if !ok { - continue - } - out = next - modified = true - remaining-- - } - - if modified { - return out - } - return body -} - -// injectAnthropicCacheControlTTL1h 将已有 ephemeral cache_control 块的 ttl 强制写为 1h。 -// 仅修改已经存在的 cache_control,不新增缓存断点。 -func injectAnthropicCacheControlTTL1h(body []byte) []byte { - return forceEphemeralCacheControlTTL(body, cacheTTLTarget1h) -} - -func forceEphemeralCacheControlTTL(body []byte, ttl string) []byte { - if len(body) == 0 || ttl == "" { - return body - } - out := body - var paths []string - addPath := func(path string, value gjson.Result) { - cc := value.Get("cache_control") - if !cc.Exists() || cc.Get("type").String() != "ephemeral" { - return - } - if cc.Get("ttl").String() == ttl { - return - } - paths = append(paths, path+".cache_control.ttl") - } - - if topCC := gjson.GetBytes(body, "cache_control"); topCC.Exists() && topCC.Get("type").String() == "ephemeral" && topCC.Get("ttl").String() != ttl { - paths = append(paths, "cache_control.ttl") - } - - system := gjson.GetBytes(body, "system") - if system.IsArray() { - idx := -1 - system.ForEach(func(_, block gjson.Result) bool { - idx++ - addPath(fmt.Sprintf("system.%d", idx), block) - return true - }) - } - - messages := gjson.GetBytes(body, "messages") - if messages.IsArray() { - msgIdx := -1 - messages.ForEach(func(_, msg gjson.Result) bool { - msgIdx++ - content := msg.Get("content") - if !content.IsArray() { - return true - } - contentIdx := -1 - content.ForEach(func(_, block gjson.Result) bool { - contentIdx++ - addPath(fmt.Sprintf("messages.%d.content.%d", msgIdx, contentIdx), block) - return true - }) - return true - }) - } - - tools := gjson.GetBytes(body, "tools") - if tools.IsArray() { - idx := -1 - tools.ForEach(func(_, tool gjson.Result) bool { - idx++ - addPath(fmt.Sprintf("tools.%d", idx), tool) - return true - }) - } - - for _, path := range paths { - if next, err := sjson.SetBytes(out, path, ttl); err == nil { - out = next - } - } - return out -} - -func (s *GatewayService) shouldInjectAnthropicCacheTTL1h(ctx context.Context, account *Account) bool { - if account == nil || !account.IsAnthropicOAuthOrSetupToken() || s == nil || s.settingService == nil { - return false - } - return s.settingService.IsAnthropicCacheTTL1hInjectionEnabled(ctx) -} - -// shouldNormalizeClientDateline reports whether the request body's client -// dateline should be normalized before forwarding to Anthropic. The switch is -// scoped to Anthropic OAuth/SetupToken accounts only; API-Key accounts and -// non-Anthropic platforms bypass this step entirely. -func (s *GatewayService) shouldNormalizeClientDateline(ctx context.Context, account *Account) bool { - if account == nil || !account.IsAnthropicOAuthOrSetupToken() || s == nil || s.settingService == nil { - return false - } - return s.settingService.IsClientDatelineNormalizationEnabled(ctx) -} - -// normalizeClientDatelineIfEnabled applies dateline normalization to body when -// the switch is on and the account qualifies. Returns (nextBody, true) only -// when the body actually changed; otherwise returns (nil, false) so callers -// can skip the writeback. -func (s *GatewayService) normalizeClientDatelineIfEnabled(ctx context.Context, account *Account, body []byte) ([]byte, bool) { - if !s.shouldNormalizeClientDateline(ctx, account) { - return nil, false - } - next, _, changed := anthropicfp.NormalizeDateline(body) - if !changed { - return nil, false - } - return next, true -} - -func (s *GatewayService) claudeOAuthSystemPromptInjectionSettings(ctx context.Context) (bool, string, string) { - if s == nil || s.settingService == nil { - return true, "", "" - } - return s.settingService.GetClaudeOAuthSystemPromptInjectionSettings(ctx) -} - -// Forward 转发请求到Claude API -func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) { - startTime := time.Now() - if parsed == nil { - return nil, fmt.Errorf("parse request: empty request") - } - - // Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应 - if account != nil && s.shouldEmulateWebSearch(ctx, account, parsed.GroupID, parsed.Body.Bytes()) { - return s.handleWebSearchEmulation(ctx, c, account, parsed) - } - - if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() { - passthroughBody := parsed.Body.Bytes() - passthroughModel := parsed.Model - if passthroughModel != "" { - if mappedModel := account.GetMappedModel(passthroughModel); mappedModel != passthroughModel { - passthroughBody = s.replaceModelInBody(passthroughBody, mappedModel) - logger.LegacyPrintf("service.gateway", "Passthrough model mapping: %s -> %s (account: %s)", parsed.Model, mappedModel, account.Name) - passthroughModel = mappedModel - } - } - return s.forwardAnthropicAPIKeyPassthroughWithInput(ctx, c, account, anthropicPassthroughForwardInput{ - Body: passthroughBody, - Parsed: parsed, - RequestModel: passthroughModel, - OriginalModel: parsed.Model, - RequestStream: parsed.Stream, - StartTime: startTime, - }) - } - - if account != nil && account.IsBedrock() { - return s.forwardBedrock(ctx, c, account, parsed, startTime) - } - - // Beta policy: evaluate once; block check + cache filter set for buildUpstreamRequest. - // Always overwrite the cache to prevent stale values from a previous retry with a different account. - if account.Platform == PlatformAnthropic && c != nil { - policy := s.evaluateBetaPolicy(ctx, c.GetHeader("anthropic-beta"), account, parsed.Model) - if policy.blockErr != nil { - return nil, policy.blockErr - } - filterSet := policy.filterSet - if filterSet == nil { - filterSet = map[string]struct{}{} - } - c.Set(betaPolicyFilterSetKey, filterSet) - } - - body := parsed.Body.Bytes() - replaceBody := func(next []byte) error { - if err := parsed.ReplaceBody(next); err != nil { - return fmt.Errorf("rewrite request body: %w", err) - } - body = parsed.Body.Bytes() - return nil - } - reqModel := parsed.Model - reqStream := parsed.Stream - originalModel := reqModel - - // === DEBUG: 打印客户端原始请求(headers + body 摘要)=== - if c != nil { - s.debugLogGatewaySnapshot("CLIENT_ORIGINAL", c.Request.Header, body, map[string]string{ - "account": fmt.Sprintf("%d(%s)", account.ID, account.Name), - "account_type": string(account.Type), - "model": reqModel, - "stream": strconv.FormatBool(reqStream), - }) - } - - // Claude Code 客户端判定:UA 匹配 claude-cli/* 且携带 metadata.user_id。 - // 真正的 Claude Code 客户端自带完整的 system prompt、cache_control 断点和 header, - // 不需要代理做任何 body 级别的 mimicry;强行替换反而会破坏客户端的缓存策略 - // (长 system prompt 被替换为 ~45 tokens 的短 prompt,低于 Anthropic 1024 token - // 最低缓存门槛,导致系统级缓存失效)。 - // - // 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。 - isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) - shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode - - if shouldMimicClaudeCode { - // 与 Parrot 对齐:OAuth 账号无条件重写 system(即使客户端已发了 Claude Code - // 风格的 system prompt)。原因:第三方工具(opencode 等)会发 "You are Claude - // Code..." system prompt 但缺少 billing attribution block,导致 Anthropic - // 检测到"有 CC prompt 但无 billing block"的不一致而判为 third-party。 - // Parrot 的 transform_request 从不检查客户端 system 内容,直接覆盖。 - systemRewritten := false - if !strings.Contains(strings.ToLower(reqModel), "haiku") { - systemRaw, _ := parsed.SystemValue() - systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) - if systemPromptInjectionEnabled { - if err := replaceBody(rewriteSystemForNonClaudeCodeWithPromptBlocks(body, systemRaw, systemPrompt, systemPromptBlocks)); err != nil { - return nil, err - } - systemRewritten = true - } - } - - // system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为); - // 未重写时(haiku / 注入开关关闭)剥离客户端 cache_control,与原有行为一致。 - // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。 - normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} - if s.identityService != nil { - fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) - if err == nil && fp != nil { - // metadata 透传开启时跳过 metadata 注入 - _, mimicMPT, _ := s.settingService.GetGatewayForwardingSettings(ctx) - if !mimicMPT { - if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" { - normalizeOpts.injectMetadata = true - normalizeOpts.metadataUserID = metadataUserID - } - } - } - } - - var normalizedBody []byte - normalizedBody, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts) - if err := replaceBody(normalizedBody); err != nil { - return nil, err - } - - // D/E/F: 可选 messages cache 策略 + 工具名混淆 + tools[-1] 断点 - // 与 forward_as_chat_completions / forward_as_responses 路径对齐, - // 原生 /v1/messages 路径也走同一套可配置字段级改写。 - if err := replaceBody(s.rewriteMessageCacheControlIfEnabled(ctx, body)); err != nil { - return nil, err - } - if rw := buildToolNameRewriteFromBody(body); rw != nil { - if err := replaceBody(applyToolNameRewriteToBody(body, rw)); err != nil { - return nil, err - } - c.Set(toolNameRewriteKey, rw) - } else { - if err := replaceBody(applyToolsLastCacheBreakpoint(body)); err != nil { - return nil, err - } - } - } - - // 客户端 dateline 归一化:仅对 Anthropic OAuth/SetupToken 账号生效。 - // 抹除 "Today's date is …" 语句里可能被注入的隐写指纹(4 种撇号 × 2 种日期 - // 分隔符),还原为 ASCII 撇号 + "-" 分隔符。运行在 mimicry 分支之外, - // 保证真实 Claude Code 客户端注入的指纹同样被清洗。 - if next, ok := s.normalizeClientDatelineIfEnabled(ctx, account, body); ok { - if err := replaceBody(next); err != nil { - return nil, err - } - } - - // 强制执行 cache_control 块数量限制(最多 4 个) - if err := replaceBody(enforceCacheControlLimit(body)); err != nil { - return nil, err - } - - // 应用模型映射: - // - APIKey 账号:使用账号级别的显式映射(如果配置),否则透传原始模型名 - // - OAuth/SetupToken 账号:使用 Anthropic 标准映射(短ID → 长ID) - mappedModel := reqModel - mappingSource := "" - if account.Type == AccountTypeAPIKey { - mappedModel = account.GetMappedModel(reqModel) - if mappedModel != reqModel { - mappingSource = "account" - } - } - if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount { - if candidate, matched := account.ResolveMappedModel(reqModel); matched { - mappedModel = candidate - mappingSource = "account" - } else { - normalized := normalizeVertexAnthropicModelID(claude.NormalizeModelID(reqModel)) - if normalized != reqModel { - mappedModel = normalized - mappingSource = "vertex" - } - } - } - if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey { - normalized := claude.NormalizeModelID(reqModel) - if normalized != reqModel { - mappedModel = normalized - mappingSource = "prefix" - } - } - if mappedModel != reqModel { - // 替换请求体中的模型名 - if err := replaceBody(s.replaceModelInBody(body, mappedModel)); err != nil { - return nil, err - } - reqModel = mappedModel - parsed.Model = mappedModel - logger.LegacyPrintf("service.gateway", "Model mapping applied: %s -> %s (account: %s, source=%s)", originalModel, mappedModel, account.Name, mappingSource) - } - - if s.shouldInjectAnthropicCacheTTL1h(ctx, account) { - if err := replaceBody(injectAnthropicCacheControlTTL1h(body)); err != nil { - return nil, err - } - } - - // 获取凭证 - token, tokenType, err := s.GetAccessToken(ctx, account) - if err != nil { - return nil, err - } - - // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递) - proxyURL := "" - if account.ProxyID != nil && account.Proxy != nil { - if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" { - proxyURL = account.Proxy.URL() - } - } - - // 解析 TLS 指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析) - tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account) - - // 调试日志:记录即将转发的账号信息 - logger.LegacyPrintf("service.gateway", "[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s", - account.ID, account.Name, account.Platform, account.Type, tlsProfile, proxyURL) - // Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400. - if err := replaceBody(StripEmptyTextBlocks(body)); err != nil { - return nil, err - } - // Pre-filter: strip web-search history blocks the upstream cannot accept - // (emulation-synthesized server_tool_use / web_search_tool_result always; - // genuine ones additionally for passback-required upstreams). See - // FilterWebSearchHistoryBlocks. reqModel 此时已是映射后的模型 ID。 - if err := replaceBody(FilterWebSearchHistoryBlocks(body, reqModel)); err != nil { - return nil, err - } - // Pre-filter: remove thinking blocks with missing/invalid signatures before forwarding. - // Clients (e.g. Claude Code) sometimes send multi-turn conversations where a historical - // assistant message contains a thinking block that is missing the required "signature" field, - // causing upstream to reject the request with 400 "thinking.signature: Field required". - // FilterThinkingBlocks removes only the invalid blocks; thinking blocks with valid signatures - // are preserved. This avoids relying solely on the post-error retry path, which can time out - // (maxRetryElapsed = 10s) for long conversations before the retry budget is exhausted. - // - // 仅 anthropic-strict 模型族执行此过滤;passback-required 上游 (DeepSeek/Kimi/GLM 等) - // 要求历史 thinking block 原样回传,过滤反而制造 400。reqModel 此时已是映射后的模型 ID。 - if err := replaceBody(FilterThinkingBlocks(body, reqModel)); err != nil { - return nil, err - } - // Chinese LLM thinking.type 协议差异补正(如 MiniMax 只接受 adaptive;Anthropic-SDK - // 客户端默认发 enabled)。仅对 passback-required 上游生效(claude-* 不会进来)。 - if ResolveThinkingProtocol(reqModel) == ThinkingProtocolPassbackRequired { - if rewritten, applied := NormalizeChineseLLMThinking(body, reqModel); applied { - if err := replaceBody(rewritten); err != nil { - return nil, err - } - logger.LegacyPrintf("service.gateway", "Account %d: rewrote thinking.type for %s (Anthropic-SDK default 'enabled' -> vendor-specific)", account.ID, reqModel) - } - } - - // 重试循环 - var resp *http.Response - lastWireBody := body - retryStart := time.Now() - for attempt := 1; attempt <= maxRetryAttempts; attempt++ { - // 构建上游请求(每次重试需要重新构建,因为请求体需要重新读取) - upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) - upstreamReq, wireBody, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) - releaseUpstreamCtx() - if err != nil { - return nil, err - } - // 记录本次实际发送的 wire body;只有请求成功后才写回 ParsedRequest,避免 400 retry 基于已签名 CCH 再改写。 - lastWireBody = wireBody - - // 发送请求 - resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfile) - if err != nil { - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - // Ensure the client receives an error response (handlers assume Forward writes on non-failover errors). - safeErr := sanitizeUpstreamErrorMessage(err.Error()) - setOpsUpstreamError(c, 0, safeErr, "") - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), - Kind: "request_error", - Message: safeErr, - }) - c.JSON(http.StatusBadGateway, gin.H{ - "type": "error", - "error": gin.H{ - "type": "upstream_error", - "message": "Upstream request failed", - }, - }) - return nil, fmt.Errorf("upstream request failed: %s", safeErr) - } - - // 优先检测thinking block签名错误(400)并重试一次 - if resp.StatusCode == 400 { - respBody, readErr := s.readUpstreamErrorBody(resp) - if readErr == nil { - _ = resp.Body.Close() - - if s.shouldRectifySignatureError(ctx, account, respBody, reqModel) { - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), - Kind: "signature_error", - Message: extractUpstreamErrorMessage(respBody), - Detail: func() string { - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) - } - return "" - }(), - }) - - looksLikeToolSignatureError := func(msg string) bool { - m := strings.ToLower(msg) - return strings.Contains(m, "tool_use") || - strings.Contains(m, "tool_result") || - strings.Contains(m, "functioncall") || - strings.Contains(m, "function_call") || - strings.Contains(m, "functionresponse") || - strings.Contains(m, "function_response") - } - - // 避免在重试预算已耗尽时再发起额外请求 - if time.Since(retryStart) >= maxRetryElapsed { - resp.Body = io.NopCloser(bytes.NewReader(respBody)) - break - } - logger.LegacyPrintf("service.gateway", "[warn] Account %d: thinking blocks have invalid signature, retrying with filtered blocks", account.ID) - - // Conservative two-stage fallback: - // 1) Disable thinking + thinking->text (preserve content) - // 2) Only if upstream still errors AND error message points to tool/function signature issues: - // also downgrade tool_use/tool_result blocks to text. - - filteredBody := FilterThinkingBlocksForRetry(body, reqModel) - retryCtx, releaseRetryCtx := detachStreamUpstreamContext(ctx, reqStream) - retryReq, retryWireBody, buildErr := s.buildUpstreamRequest(retryCtx, c, account, filteredBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) - releaseRetryCtx() - if buildErr == nil { - retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsProfile) - if retryErr == nil { - if retryResp.StatusCode < 400 { - // 重试请求被上游接受后同步 ParsedRequest,保证 usage/日志看到真实请求体。 - lastWireBody = retryWireBody - if err := replaceBody(retryWireBody); err != nil { - _ = retryResp.Body.Close() - return nil, err - } - logger.LegacyPrintf("service.gateway", "Account %d: thinking block retry succeeded (blocks downgraded)", account.ID) - resp = retryResp - break - } - - retryRespBody, retryReadErr := s.readUpstreamErrorBody(retryResp) - _ = retryResp.Body.Close() - if retryReadErr == nil && retryResp.StatusCode == 400 && s.isSignatureErrorPattern(ctx, account, retryRespBody) { - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: retryResp.StatusCode, - UpstreamRequestID: retryResp.Header.Get("x-request-id"), - UpstreamURL: safeUpstreamURL(retryReq.URL.String()), - Kind: "signature_retry_thinking", - Message: extractUpstreamErrorMessage(retryRespBody), - Detail: func() string { - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - return truncateString(string(retryRespBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) - } - return "" - }(), - }) - msg2 := extractUpstreamErrorMessage(retryRespBody) - if looksLikeToolSignatureError(msg2) && time.Since(retryStart) < maxRetryElapsed { - logger.LegacyPrintf("service.gateway", "Account %d: signature retry still failing and looks tool-related, retrying with tool blocks downgraded", account.ID) - filteredBody2 := FilterSignatureSensitiveBlocksForRetry(body, reqModel) - retryCtx2, releaseRetryCtx2 := detachStreamUpstreamContext(ctx, reqStream) - retryReq2, retryWireBody2, buildErr2 := s.buildUpstreamRequest(retryCtx2, c, account, filteredBody2, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) - releaseRetryCtx2() - if buildErr2 == nil { - retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsProfile) - if retryErr2 == nil { - if retryResp2.StatusCode < 400 { - // 二阶段工具块降级成功时也必须更新当前 body。 - lastWireBody = retryWireBody2 - if err := replaceBody(retryWireBody2); err != nil { - _ = retryResp2.Body.Close() - return nil, err - } - } - resp = retryResp2 - break - } - if retryResp2 != nil && retryResp2.Body != nil { - _ = retryResp2.Body.Close() - } - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - UpstreamURL: safeUpstreamURL(retryReq2.URL.String()), - Kind: "signature_retry_tools_request_error", - Message: sanitizeUpstreamErrorMessage(retryErr2.Error()), - }) - logger.LegacyPrintf("service.gateway", "Account %d: tool-downgrade signature retry failed: %v", account.ID, retryErr2) - } else { - logger.LegacyPrintf("service.gateway", "Account %d: tool-downgrade signature retry build failed: %v", account.ID, buildErr2) - } - } - } - - // Fall back to the original retry response context. - resp = &http.Response{ - StatusCode: retryResp.StatusCode, - Header: retryResp.Header.Clone(), - Body: io.NopCloser(bytes.NewReader(retryRespBody)), - } - break - } - if retryResp != nil && retryResp.Body != nil { - _ = retryResp.Body.Close() - } - logger.LegacyPrintf("service.gateway", "Account %d: signature error retry failed: %v", account.ID, retryErr) - } else { - logger.LegacyPrintf("service.gateway", "Account %d: signature error retry build request failed: %v", account.ID, buildErr) - } - - // Retry failed: restore original response body and continue handling. - resp.Body = io.NopCloser(bytes.NewReader(respBody)) - break - } - // 不是签名错误(或整流器已关闭),继续检查 budget 约束 - errMsg := extractUpstreamErrorMessage(respBody) - if isThinkingBudgetConstraintError(errMsg) && s.settingService.IsBudgetRectifierEnabled(ctx) { - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), - Kind: "budget_constraint_error", - Message: errMsg, - Detail: func() string { - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) - } - return "" - }(), - }) - - rectifiedBody, applied := RectifyThinkingBudget(body) - if applied && time.Since(retryStart) < maxRetryElapsed { - logger.LegacyPrintf("service.gateway", "Account %d: detected budget_tokens constraint error, retrying with rectified budget (budget_tokens=%d, max_tokens=%d)", account.ID, BudgetRectifyBudgetTokens, BudgetRectifyMaxTokens) - budgetRetryCtx, releaseBudgetRetryCtx := detachStreamUpstreamContext(ctx, reqStream) - budgetRetryReq, budgetWireBody, buildErr := s.buildUpstreamRequest(budgetRetryCtx, c, account, rectifiedBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) - releaseBudgetRetryCtx() - if buildErr == nil { - budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsProfile) - if retryErr == nil { - if budgetRetryResp.StatusCode < 400 { - // budget 修正请求成功后,ParsedRequest 也要描述被接受的修正版。 - lastWireBody = budgetWireBody - if err := replaceBody(budgetWireBody); err != nil { - _ = budgetRetryResp.Body.Close() - return nil, err - } - } - resp = budgetRetryResp - break - } - if budgetRetryResp != nil && budgetRetryResp.Body != nil { - _ = budgetRetryResp.Body.Close() - } - logger.LegacyPrintf("service.gateway", "Account %d: budget rectifier retry failed: %v", account.ID, retryErr) - } else { - logger.LegacyPrintf("service.gateway", "Account %d: budget rectifier retry build failed: %v", account.ID, buildErr) - } - } - } - - resp.Body = io.NopCloser(bytes.NewReader(respBody)) - } - } - - // 检查是否需要通用重试(排除400,因为400已经在上面特殊处理过了) - if resp.StatusCode >= 400 && resp.StatusCode != 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) { - if attempt < maxRetryAttempts { - elapsed := time.Since(retryStart) - if elapsed >= maxRetryElapsed { - break - } - - delay := retryBackoffDelay(attempt) - remaining := maxRetryElapsed - elapsed - if delay > remaining { - delay = remaining - } - if delay <= 0 { - break - } - - respBody, _ := s.readUpstreamErrorBody(resp) - _ = resp.Body.Close() - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), - Kind: "retry", - Message: extractUpstreamErrorMessage(respBody), - Detail: func() string { - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) - } - return "" - }(), - }) - logger.LegacyPrintf("service.gateway", "Account %d: upstream error %d, retry %d/%d after %v (elapsed=%v/%v)", - account.ID, resp.StatusCode, attempt, maxRetryAttempts, delay, elapsed, maxRetryElapsed) - if err := sleepWithContext(ctx, delay); err != nil { - return nil, err - } - continue - } - // 最后一次尝试也失败,跳出循环处理重试耗尽 - break - } - - // 不需要重试(成功或不可重试的错误),跳出循环 - // DEBUG: 输出响应 headers(用于检测 rate limit 信息) - if account.Platform == PlatformGemini && resp.StatusCode < 400 && s.cfg != nil && s.cfg.Gateway.GeminiDebugResponseHeaders { - logger.LegacyPrintf("service.gateway", "[DEBUG] Gemini API Response Headers for account %d:", account.ID) - for k, v := range resp.Header { - logger.LegacyPrintf("service.gateway", "[DEBUG] %s: %v", k, v) - } - } - break - } - if resp == nil || resp.Body == nil { - return nil, errors.New("upstream request failed: empty response") - } - defer func() { _ = resp.Body.Close() }() - - // 处理重试耗尽的情况 - if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) { - if s.shouldFailoverUpstreamError(resp.StatusCode) { - respBody, _ := s.readUpstreamErrorBody(resp) - _ = resp.Body.Close() - resp.Body = io.NopCloser(bytes.NewReader(respBody)) - - // 调试日志:打印重试耗尽后的错误响应 - logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d RequestID=%s Body=%s", - account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000)) - - s.handleRetryExhaustedSideEffects(ctx, resp, account) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "retry_exhausted_failover", - Message: extractUpstreamErrorMessage(respBody), - Detail: func() string { - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) - } - return "" - }(), - }) - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), - } - } - return s.handleRetryExhaustedError(ctx, resp, c, account) - } - - // 处理可切换账号的错误 - if resp.StatusCode >= 400 && s.shouldFailoverUpstreamError(resp.StatusCode) { - respBody, _ := s.readUpstreamErrorBody(resp) - _ = resp.Body.Close() - resp.Body = io.NopCloser(bytes.NewReader(respBody)) - - // 调试日志:打印上游错误响应 - logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (failover): Account=%d(%s) Status=%d RequestID=%s Body=%s", - account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000)) - - s.handleFailoverSideEffects(ctx, resp, account, reqModel) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "failover", - Message: extractUpstreamErrorMessage(respBody), - Detail: func() string { - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) - } - return "" - }(), - }) - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), - } - } - if resp.StatusCode >= 400 { - // 可选:对部分 400 触发 failover(默认关闭以保持语义) - if resp.StatusCode == 400 && s.cfg != nil && s.cfg.Gateway.FailoverOn400 { - respBody, readErr := s.readUpstreamErrorBody(resp) - if readErr != nil { - // ReadAll failed, fall back to normal error handling without consuming the stream - return s.handleErrorResponse(ctx, resp, c, account, reqModel) - } - _ = resp.Body.Close() - resp.Body = io.NopCloser(bytes.NewReader(respBody)) - - if s.shouldFailoverOn400(respBody) { - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(respBody), maxBytes) - } - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "failover_on_400", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - - if s.cfg.Gateway.LogUpstreamErrorBody { - logger.LegacyPrintf("service.gateway", - "Account %d: 400 error, attempting failover: %s", - account.ID, - truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), - ) - } else { - logger.LegacyPrintf("service.gateway", "Account %d: 400 error, attempting failover", account.ID) - } - s.handleFailoverSideEffects(ctx, resp, account, reqModel) - return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody} - } - } - return s.handleErrorResponse(ctx, resp, c, account, reqModel) - } - - // 处理正常响应 - - if !bytes.Equal(lastWireBody, body) { - // 成功后再同步最终 wire body,避免失败重试从已签名 CCH 的 body 继续派生。 - if err := replaceBody(lastWireBody); err != nil { - return nil, err - } - } - - // 触发上游接受回调(提前释放串行锁,不等流完成) - if parsed.OnUpstreamAccepted != nil { - parsed.OnUpstreamAccepted() - } - - var usage *ClaudeUsage - var firstTokenMs *int - var clientDisconnect bool - if reqStream { - streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel, shouldMimicClaudeCode) - if err != nil { - var sseErr *sseStreamErrorEventError - if errors.As(err, &sseErr) { - // 上游 HTTP 200 + SSE 流体内出现 event:error 帧。 - // 保留 StatusCode=403 以兼容既有 failover/客户端响应语义, - // 但补全 ResponseBody 与 ops 上下文,让运维日志能反映上游真实错误。 - body := []byte(sseErr.RawData) - - upstreamMsg := sanitizeUpstreamErrorMessage( - strings.TrimSpace(extractUpstreamErrorMessage(body)), - ) - - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(sseErr.RawData, maxBytes) - } - - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 403, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "stream_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - - logger.LegacyPrintf("service.gateway", - "[Forward] SSE error event in stream: Account=%d(%s) RequestID=%s Body=%s", - account.ID, account.Name, resp.Header.Get("x-request-id"), - truncateString(sseErr.RawData, 1000), - ) - - return nil, &UpstreamFailoverError{ - StatusCode: 403, - ResponseBody: body, - } - } - return nil, err - } - usage = streamResult.usage - firstTokenMs = streamResult.firstTokenMs - clientDisconnect = streamResult.clientDisconnect - } else { - usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, reqModel) - if err != nil { - return nil, err - } - } - - return &ForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - Usage: *usage, - Model: originalModel, // 使用原始模型用于计费和日志 - UpstreamModel: mappedModel, - Stream: reqStream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, - }, nil -} - -func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, []byte, error) { - if account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount { - req, err := s.buildUpstreamRequestAnthropicVertex(ctx, c, account, body, token, modelID, reqStream) - return req, body, err - } - - // 确定目标URL - targetURL := claudeAPIURL - if account.Type == AccountTypeAPIKey { - baseURL := account.GetBaseURL() - if baseURL != "" { - validatedURL, err := s.validateUpstreamBaseURL(baseURL) - if err != nil { - return nil, nil, err - } - targetURL = validatedURL + "/v1/messages?beta=true" - } - } else if account.IsCustomBaseURLEnabled() { - customURL := account.GetCustomBaseURL() - if customURL == "" { - return nil, nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID) - } - validatedURL, err := s.validateUpstreamBaseURL(customURL) - if err != nil { - return nil, nil, err - } - targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages", account) - } - - clientHeaders := http.Header{} - if c != nil && c.Request != nil { - clientHeaders = c.Request.Header - } - - // OAuth账号:应用统一指纹和metadata重写(受设置开关控制) - var fingerprint *Fingerprint - enableFP, enableMPT := true, false - if s.settingService != nil { - enableFP, enableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx) - } - if account.IsOAuth() && s.identityService != nil { - // 1. 获取或创建指纹(包含随机生成的ClientID) - fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) - if err != nil { - logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err) - // 失败时降级为透传原始headers - } else { - if enableFP { - fingerprint = fp - } - - // 2. 重写metadata.user_id(需要指纹中的ClientID和账号的account_uuid) - // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 - // 当 metadata 透传开启时跳过重写 - if !enableMPT { - accountUUID := account.GetExtraString("account_uuid") - if accountUUID != "" && fp.ClientID != "" { - if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { - body = newBody - } - } - } - } - } - - // 同步 billing header cc_version 与实际发送的 User-Agent 版本 - if fingerprint != nil { - body = syncBillingHeaderVersion(body, fingerprint.UserAgent) - } - - // === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)=== - // - // 顺序约束: - // 1) 算 finalBeta(纯函数,不依赖 req.Header;mimicry 路径会忽略客户端 beta, - // 与原“OAuth + mimicClaudeCode 跳过白名单透传”行为对齐) - // 2) 按 finalBeta 做能力维度 body sanitize(如 context-management beta 缺失 → - // strip body.context_management,与 Bedrock 路径对称) - // 3) CCH 签名(必须使用 strip 后的 body,否则 hash 与最终 body 不一致 → - // 被 Anthropic 判 third-party) - // 4) NewRequest(body 至此最终敲定) - // 5) 透传白名单 / fingerprint / mimic header / 写入 finalBeta - policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID) - effectiveDropSet := mergeDropSets(policyFilterSet) - finalBetaHeader, finalBetaShouldSet := s.computeFinalAnthropicBeta( - tokenType, mimicClaudeCode, modelID, clientHeaders, body, effectiveDropSet, - ) - - // 账号覆写了 anthropic-beta 时,覆写值即最终上游值(由下方 ApplyHeaderOverrides 写入): - // body 能力净化必须以覆写值为准,否则 header/body 不对称会被上游 400。 - if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok { - finalBetaHeader, finalBetaShouldSet = beta, true - } - - // 能力维度 body sanitize:与最终 anthropic-beta header 对称 - if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed { - body = sanitized - } - - req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) - if err != nil { - return nil, nil, err - } - - // 设置认证头(保持原始大小写) - if tokenType == "oauth" { - setHeaderRaw(req.Header, "authorization", "Bearer "+token) - } else { - setAnthropicAPIKeyAuthHeader(req.Header, account, token) - } - - // 白名单透传 headers - // OAuth mimicry 路径:跳过客户端 header 透传,与 Parrot 对齐。 - // Parrot 的 build_upstream_headers 只发 9 个精确 header,不透传任何客户端 header。 - // 透传客户端 header 会引入不一致的 x-stainless-* / anthropic-beta / user-agent / - // x-claude-code-session-id 等值,和我们注入的伪装 header 冲突,被 Anthropic 判 third-party。 - if tokenType != "oauth" || !mimicClaudeCode { - for key, values := range clientHeaders { - lowerKey := strings.ToLower(key) - if allowedHeaders[lowerKey] { - wireKey := resolveWireCasing(key) - for _, v := range values { - addHeaderRaw(req.Header, wireKey, v) - } - } - } - } - - // OAuth账号:应用缓存的指纹到请求头(覆盖白名单透传的头) - if fingerprint != nil { - s.identityService.ApplyFingerprint(req, fingerprint) - } - - // 确保必要的headers存在(保持原始大小写) - if getHeaderRaw(req.Header, "content-type") == "" { - setHeaderRaw(req.Header, "content-type", "application/json") - } - if getHeaderRaw(req.Header, "anthropic-version") == "" { - setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") - } - if tokenType == "oauth" { - applyClaudeOAuthHeaderDefaults(req) - } - - // OAuth + mimic Claude Code:强制注入 CLI 指纹相关 header - // (user-agent/x-stainless-*/x-app/Accept/x-stainless-helper-method/x-client-request-id) - if tokenType == "oauth" && mimicClaudeCode { - applyClaudeCodeMimicHeaders(req, reqStream) - } - - // 写入最终 anthropic-beta header - // 注:透传分支白名单可能写入了客户端 anthropic-beta,无条件 Del 一次再按 finalBeta - // 决定是否 set,确保 dropSet 过滤后的结果一定覆盖客户端原始值。 - deleteHeaderAllForms(req.Header, "anthropic-beta") - if finalBetaShouldSet { - setHeaderRaw(req.Header, "anthropic-beta", finalBetaHeader) - } - - // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖 - if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" { - if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" { - if parsed := ParseMetadataUserID(uid); parsed != nil { - setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID) - } - } - } - - // 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op)。 - // 放在所有 header 逻辑之后,确保配置值对同名头拥有最终决定权。 - account.ApplyHeaderOverrides(req.Header) - - // === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 === - s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{ - "url": req.URL.String(), - "token_type": tokenType, - "mimic_claude_code": strconv.FormatBool(mimicClaudeCode), - "fingerprint_applied": strconv.FormatBool(fingerprint != nil), - "enable_fp": strconv.FormatBool(enableFP), - "enable_mpt": strconv.FormatBool(enableMPT), - }) - - // Always capture a compact fingerprint line for later error diagnostics. - // We only print it when needed (or when the explicit debug flag is enabled). - if c != nil && tokenType == "oauth" { - c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode)) - } - if s.debugClaudeMimicEnabled() { - logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode) - } - - return req, body, nil -} - -// vertexSupportedBetaTokens 是 Vertex AI 的 Anthropic 端点接受的 anthropic-beta -// 白名单。Vertex 对任何未知 token 直接 HTTP 400,故采用白名单(与 Bedrock 的 -// bedrockSupportedBetaTokens 同思路)而非黑名单:未来 Claude Code 新增的、Vertex 尚未 -// 支持的 token 天然被剥离。当 Vertex 新增支持某 beta 时在此补充。 -// -// 明确排除(issue #3358 中 Vertex 报 400 的 token):advisor-tool-2026-03-01、 -// prompt-caching-scope-2026-01-05、redact-thinking-2026-02-12、 -// thinking-token-count-2026-05-13;以及 claude-code-20250219 / oauth-2025-04-20 等 -// 客户端身份 beta——Vertex service_account 走 Bearer 鉴权,不需要它们。 -var vertexSupportedBetaTokens = map[string]bool{ - "context-1m-2025-08-07": true, - "context-management-2025-06-27": true, - "fine-grained-tool-streaming-2025-05-14": true, - "interleaved-thinking-2025-05-14": true, -} - -// filterVertexBetaTokens 解析 client 的 anthropic-beta header,先剔除 drop 集合中的 -// token(BetaPolicy filter + 默认 drop),再只保留 Vertex 支持的 token,去重后逗号拼接。 -// 返回最终 header(可能为空字符串)。 -func filterVertexBetaTokens(header string, drop map[string]struct{}) string { - tokens := parseAnthropicBetaHeader(header) - if len(tokens) == 0 { - return "" - } - out := make([]string, 0, len(tokens)) - seen := make(map[string]bool, len(tokens)) - for _, t := range tokens { - if _, dropped := drop[t]; dropped { - continue - } - if !vertexSupportedBetaTokens[t] { - continue - } - if seen[t] { - continue - } - seen[t] = true - out = append(out, t) - } - return strings.Join(out, ",") -} - -func (s *GatewayService) buildUpstreamRequestAnthropicVertex( - ctx context.Context, - c *gin.Context, - account *Account, - body []byte, - token string, - modelID string, - reqStream bool, -) (*http.Request, error) { - vertexBody, err := buildVertexAnthropicRequestBody(body) - if err != nil { - return nil, err - } - - // 计算最终 outgoing anthropic-beta。Vertex AI 的 Anthropic 端点只接受一小撮 - // beta token,未知 token 会直接 HTTP 400——近期 Claude Code CLI 透传的 - // advisor-tool-2026-03-01 / prompt-caching-scope-2026-01-05 / - // redact-thinking-2026-02-12 / thinking-token-count-2026-05-13 都不被 Vertex 接受 - // (issue #3358)。这里复用 BetaPolicy 的 block 检查(与 Bedrock 的 - // resolveBedrockBetaTokensForRequest 对称),再按 vertexSupportedBetaTokens 白名单 - // 剥离其余 token,使该路径与 Anthropic 直连 / Bedrock 路径行为一致。 - clientBeta := "" - if c != nil && c.Request != nil { - clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta") - } - policy := s.evaluateBetaPolicy(ctx, clientBeta, account, modelID) - if policy.blockErr != nil { - return nil, policy.blockErr - } - finalBeta := filterVertexBetaTokens(clientBeta, mergeDropSets(policy.filterSet)) - - // 能力维度 sanitize:基于最终 beta(而非原始 client 值)决定是否保留 body 中的 - // context_management,与 Anthropic 直连 / Bedrock 路径对称。 - if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(vertexBody, finalBeta); changed { - vertexBody = sanitized - } - fullURL, err := buildVertexAnthropicURL(account.VertexProjectID(), account.VertexLocation(modelID), modelID, reqStream) - if err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(vertexBody)) - if err != nil { - return nil, err - } - - if c != nil && c.Request != nil { - for key, values := range c.Request.Header { - lowerKey := strings.ToLower(strings.TrimSpace(key)) - if !allowedHeaders[lowerKey] || lowerKey == "anthropic-version" { - continue - } - wireKey := resolveWireCasing(key) - for _, v := range values { - addHeaderRaw(req.Header, wireKey, v) - } - } - } - - req.Header.Del("authorization") - req.Header.Del("x-api-key") - req.Header.Del("x-goog-api-key") - req.Header.Del("cookie") - req.Header.Del("anthropic-version") - setHeaderRaw(req.Header, "authorization", "Bearer "+token) - setHeaderRaw(req.Header, "content-type", "application/json") - - // 覆盖上面白名单 loop 写入的原始 client anthropic-beta,使用过滤后的最终值。 - // finalBeta 为空(全部被剥离)时不下发该 header,与 Vertex 无 beta 请求一致。 - deleteHeaderAllForms(req.Header, "anthropic-beta") - if finalBeta != "" { - setHeaderRaw(req.Header, "anthropic-beta", finalBeta) - } - - s.debugLogGatewaySnapshot("UPSTREAM_FORWARD_VERTEX_ANTHROPIC", req.Header, vertexBody, map[string]string{ - "url": req.URL.String(), - "token_type": "service_account", - "model": modelID, - "stream": strconv.FormatBool(reqStream), - }) - - return req, nil -} - -// getBetaHeader 处理anthropic-beta header -// 对于OAuth账号,需要确保包含oauth-2025-04-20 -func (s *GatewayService) getBetaHeader(modelID string, clientBetaHeader string) string { - // 如果客户端传了anthropic-beta - if clientBetaHeader != "" { - // 已包含oauth beta则直接返回 - if strings.Contains(clientBetaHeader, claude.BetaOAuth) { - return clientBetaHeader - } - - // 需要添加oauth beta - parts := strings.Split(clientBetaHeader, ",") - for i, p := range parts { - parts[i] = strings.TrimSpace(p) - } - - // 在claude-code-20250219后面插入oauth beta - claudeCodeIdx := -1 - for i, p := range parts { - if p == claude.BetaClaudeCode { - claudeCodeIdx = i - break - } - } - - if claudeCodeIdx >= 0 { - // 在claude-code后面插入 - newParts := make([]string, 0, len(parts)+1) - newParts = append(newParts, parts[:claudeCodeIdx+1]...) - newParts = append(newParts, claude.BetaOAuth) - newParts = append(newParts, parts[claudeCodeIdx+1:]...) - return strings.Join(newParts, ",") - } - - // 没有claude-code,放在第一位 - return claude.BetaOAuth + "," + clientBetaHeader - } - - // 客户端没传,根据模型生成 - // haiku 模型不需要 claude-code beta - if strings.Contains(strings.ToLower(modelID), "haiku") { - return claude.HaikuBetaHeader - } - - return claude.DefaultBetaHeader -} - -func requestNeedsBetaFeatures(body []byte) bool { - tools := gjson.GetBytes(body, "tools") - if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 { - return true - } - thinkingType := gjson.GetBytes(body, "thinking.type").String() - if strings.EqualFold(thinkingType, "enabled") || strings.EqualFold(thinkingType, "adaptive") { - return true - } - return false -} - -func defaultAPIKeyBetaHeader(body []byte) string { - modelID := gjson.GetBytes(body, "model").String() - if strings.Contains(strings.ToLower(modelID), "haiku") { - return claude.APIKeyHaikuBetaHeader - } - return claude.APIKeyBetaHeader -} - -func applyClaudeOAuthHeaderDefaults(req *http.Request) { - if req == nil { - return - } - if getHeaderRaw(req.Header, "Accept") == "" { - setHeaderRaw(req.Header, "Accept", "application/json") - } - for key, value := range claude.DefaultHeaders { - if value == "" { - continue - } - if getHeaderRaw(req.Header, key) == "" { - setHeaderRaw(req.Header, resolveWireCasing(key), value) - } - } -} - -func mergeAnthropicBeta(required []string, incoming string) string { - seen := make(map[string]struct{}, len(required)+8) - out := make([]string, 0, len(required)+8) - - add := func(v string) { - v = strings.TrimSpace(v) - if v == "" { - return - } - if _, ok := seen[v]; ok { - return - } - seen[v] = struct{}{} - out = append(out, v) - } - - for _, r := range required { - add(r) - } - for _, p := range strings.Split(incoming, ",") { - add(p) - } - return strings.Join(out, ",") -} - -func mergeAnthropicBetaDropping(required []string, incoming string, drop map[string]struct{}) string { - merged := mergeAnthropicBeta(required, incoming) - if merged == "" || len(drop) == 0 { - return merged - } - out := make([]string, 0, 8) - for _, p := range strings.Split(merged, ",") { - p = strings.TrimSpace(p) - if p == "" { - continue - } - if _, ok := drop[p]; ok { - continue - } - out = append(out, p) - } - return strings.Join(out, ",") -} - -// computeFinalAnthropicBeta 计算发往上游的最终 anthropic-beta header 值。 -// -// 设计动机:将原本在 buildUpstreamRequest 内联在一起、依赖 req.Header 的 -// anthropic-beta 计算逻辑抽成纯函数。这样调用方可以在 NewRequest 之前 -// 就提前拿到最终 beta header,进而能按它对 body 做能力维度 sanitize 后再做 -// CCH 签名——一举修复了以下之前由顺序依赖导致的能力维度 sanitize -// 无法部署的问题(签名与最终 body 不一致可以被判 third-party)。 -// -// 返回 (value, shouldSet): -// - shouldSet=false 意为“不主动设置 anthropic-beta header”,与原代码“ -// API-key 账号 + 客户端未传 anthropic-beta + InjectBetaForAPIKey 未开启或 -// requestNeedsBetaFeatures=false”的行为对齐。 -// - shouldSet=true 时 value 可能为空字符串(例如客户端透传的 beta 被 dropSet -// 全部过滤掉),这与原代码中 setHeaderRaw 的结果一致。 -// -// clientHeaders 是客户端原始 HTTP header(通常为 c.Request.Header);nil 时按“客户端 -// 未传”处理。body 是已经 metadata 重写 / billing version sync 之后但未 sanitize 上游 -// 不兼容字段之前的版本。 -func (s *GatewayService) computeFinalAnthropicBeta( - tokenType string, - mimicClaudeCode bool, - modelID string, - clientHeaders http.Header, - body []byte, - effectiveDropSet map[string]struct{}, -) (string, bool) { - clientBeta := "" - if clientHeaders != nil { - clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta") - } - - if tokenType == "oauth" { - if mimicClaudeCode { - // mimic 路径:原代码跳过白名单透传,incomingBeta 总是空字符串。 - // 这里传空 string 以严格对齐原行为。 - requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking} - if !strings.Contains(strings.ToLower(modelID), "haiku") { - requiredBetas = claude.FullClaudeCodeMimicryBetas() - } - return mergeAnthropicBetaDropping(requiredBetas, "", effectiveDropSet), true - } - // 真 Claude Code 客户端透传路径 - return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true - } - - // API-key accounts - if clientBeta != "" { - return stripBetaTokensWithSet(clientBeta, effectiveDropSet), true - } - if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey { - if requestNeedsBetaFeatures(body) { - if beta := defaultAPIKeyBetaHeader(body); beta != "" { - return beta, true - } - } - } - return "", false -} - -// computeFinalCountTokensAnthropicBeta 是 count_tokens 路径上 anthropic-beta header 的 -// 计算纯函数。语义与 computeFinalAnthropicBeta 对齐,但备份了 count_tokens 独有的 -// 两条特殊规则: -// -// - OAuth mimic:requiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting -// (与 messages 不同的是:不按 haiku 排除;count_tokens 始终携带 token-counting beta) -// - OAuth 透传 + 客户端未传 anthropic-beta:补齐 CountTokensBetaHeader -// - OAuth 透传 + 客户端传了:补齐 BetaTokenCounting(如果未含) -// -// 返回语义同 computeFinalAnthropicBeta。 -func (s *GatewayService) computeFinalCountTokensAnthropicBeta( - tokenType string, - mimicClaudeCode bool, - modelID string, - clientHeaders http.Header, - body []byte, - effectiveDropSet map[string]struct{}, -) (string, bool) { - clientBeta := "" - if clientHeaders != nil { - clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta") - } - - if tokenType == "oauth" { - if mimicClaudeCode { - // 与原代码严格等价:original buildCountTokensRequest 在 count_tokens mimic - // 分支上**不**会跳过白名单透传(与 messages mimic 路径不同),所以 - // incomingBeta = req.Header[anthropic-beta] = 客户端透传过来的 client beta。 - // 重构后直接从 clientHeaders 拿同一个值,保持行为一致。 - requiredBetas := append(claude.FullClaudeCodeMimicryBetas(), claude.BetaTokenCounting) - return mergeAnthropicBetaDropping(requiredBetas, clientBeta, effectiveDropSet), true - } - if clientBeta == "" { - return claude.CountTokensBetaHeader, true - } - beta := s.getBetaHeader(modelID, clientBeta) - if !strings.Contains(beta, claude.BetaTokenCounting) { - beta = beta + "," + claude.BetaTokenCounting - } - return stripBetaTokensWithSet(beta, effectiveDropSet), true - } - - // API-key accounts - if clientBeta != "" { - return stripBetaTokensWithSet(clientBeta, effectiveDropSet), true - } - if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey { - if requestNeedsBetaFeatures(body) { - if beta := defaultAPIKeyBetaHeader(body); beta != "" { - return beta, true - } - } - } - return "", false -} - -// stripBetaTokens removes the given beta tokens from a comma-separated header value. -func stripBetaTokens(header string, tokens []string) string { - if header == "" || len(tokens) == 0 { - return header - } - return stripBetaTokensWithSet(header, buildBetaTokenSet(tokens)) -} - -func stripBetaTokensWithSet(header string, drop map[string]struct{}) string { - if header == "" || len(drop) == 0 { - return header - } - parts := strings.Split(header, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - if _, ok := drop[p]; ok { - continue - } - out = append(out, p) - } - if len(out) == len(parts) { - return header // no change, avoid allocation - } - return strings.Join(out, ",") -} - -// BetaBlockedError indicates a request was blocked by a beta policy rule. -type BetaBlockedError struct { - Message string -} - -func (e *BetaBlockedError) Error() string { return e.Message } - -// betaPolicyResult holds the evaluated result of beta policy rules for a single request. -type betaPolicyResult struct { - blockErr *BetaBlockedError // non-nil if a block rule matched - filterSet map[string]struct{} // tokens to filter (may be nil) -} - -// evaluateBetaPolicy loads settings once and evaluates all rules against the given request. -func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader string, account *Account, model string) betaPolicyResult { - if s.settingService == nil { - return betaPolicyResult{} - } - settings, err := s.settingService.GetBetaPolicySettings(ctx) - if err != nil || settings == nil { - return betaPolicyResult{} - } - isOAuth := account.IsOAuth() - isBedrock := account.IsBedrock() - var result betaPolicyResult - for _, rule := range settings.Rules { - if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) { - continue - } - effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model) - switch effectiveAction { - case BetaPolicyActionBlock: - if result.blockErr == nil && betaHeader != "" && containsBetaToken(betaHeader, rule.BetaToken) { - msg := effectiveErrMsg - if msg == "" { - msg = "beta feature " + rule.BetaToken + " is not allowed" - } - result.blockErr = &BetaBlockedError{Message: msg} - } - case BetaPolicyActionFilter: - if result.filterSet == nil { - result.filterSet = make(map[string]struct{}) - } - result.filterSet[rule.BetaToken] = struct{}{} - } - } - return result -} - -// mergeDropSets merges the static defaultDroppedBetasSet with dynamic policy filter tokens. -// Returns defaultDroppedBetasSet directly when policySet is empty (zero allocation). -func mergeDropSets(policySet map[string]struct{}, extra ...string) map[string]struct{} { - if len(policySet) == 0 && len(extra) == 0 { - return defaultDroppedBetasSet - } - m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(policySet)+len(extra)) - for t := range defaultDroppedBetasSet { - m[t] = struct{}{} - } - for t := range policySet { - m[t] = struct{}{} - } - for _, t := range extra { - m[t] = struct{}{} - } - return m -} - -// betaPolicyFilterSetKey is the gin.Context key for caching the policy filter set within a request. -const betaPolicyFilterSetKey = "betaPolicyFilterSet" - -// getBetaPolicyFilterSet returns the beta policy filter set, using the gin context cache if available. -// In the /v1/messages path, Forward() evaluates the policy first and caches the result; -// buildUpstreamRequest reuses it (zero extra DB calls). In the count_tokens path, this -// evaluates on demand (one DB call). -func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Context, account *Account, model string) map[string]struct{} { - if c != nil { - if v, ok := c.Get(betaPolicyFilterSetKey); ok { - if fs, ok := v.(map[string]struct{}); ok { - return fs - } - } - } - return s.evaluateBetaPolicy(ctx, "", account, model).filterSet -} - -// betaPolicyScopeMatches checks whether a rule's scope matches the current account type. -func betaPolicyScopeMatches(scope string, isOAuth bool, isBedrock bool) bool { - switch scope { - case BetaPolicyScopeAll: - return true - case BetaPolicyScopeOAuth: - return isOAuth - case BetaPolicyScopeAPIKey: - return !isOAuth && !isBedrock - case BetaPolicyScopeBedrock: - return isBedrock - default: - return true // unknown scope → match all (fail-open) - } -} - -// matchModelWhitelist checks if a model matches any pattern in the whitelist. -// Reuses matchModelPattern from group.go which supports exact and wildcard prefix matching. -func matchModelWhitelist(model string, whitelist []string) bool { - for _, pattern := range whitelist { - if matchModelPattern(pattern, model) { - return true - } - } - return false -} - -// resolveRuleAction determines the effective action and error message for a rule given the request model. -// When ModelWhitelist is empty, the rule's primary Action/ErrorMessage applies unconditionally. -// When non-empty, Action applies to matching models; FallbackAction/FallbackErrorMessage applies to others. -func resolveRuleAction(rule BetaPolicyRule, model string) (action, errorMessage string) { - if len(rule.ModelWhitelist) == 0 { - return rule.Action, rule.ErrorMessage - } - if matchModelWhitelist(model, rule.ModelWhitelist) { - return rule.Action, rule.ErrorMessage - } - if rule.FallbackAction != "" { - return rule.FallbackAction, rule.FallbackErrorMessage - } - return BetaPolicyActionPass, "" // default fallback: pass (fail-open) -} - -// droppedBetaSet returns claude.DroppedBetas as a set, with optional extra tokens. -func droppedBetaSet(extra ...string) map[string]struct{} { - m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(extra)) - for t := range defaultDroppedBetasSet { - m[t] = struct{}{} - } - for _, t := range extra { - m[t] = struct{}{} - } - return m -} - -// containsBetaToken checks if a comma-separated header value contains the given token. -func containsBetaToken(header, token string) bool { - if header == "" || token == "" { - return false - } - for _, p := range strings.Split(header, ",") { - if strings.TrimSpace(p) == token { - return true - } - } - return false -} - -func filterBetaTokens(tokens []string, filterSet map[string]struct{}) []string { - if len(tokens) == 0 || len(filterSet) == 0 { - return tokens - } - kept := make([]string, 0, len(tokens)) - for _, token := range tokens { - if _, filtered := filterSet[token]; !filtered { - kept = append(kept, token) - } - } - return kept -} - -func (s *GatewayService) resolveBedrockBetaTokensForRequest( - ctx context.Context, - account *Account, - betaHeader string, - body []byte, - modelID string, -) ([]string, error) { - // 1. 对原始 header 中的 beta token 做 block 检查(快速失败) - policy := s.evaluateBetaPolicy(ctx, betaHeader, account, modelID) - if policy.blockErr != nil { - return nil, policy.blockErr - } - - // 2. 解析 header + body 自动注入 + Bedrock 转换/过滤 - betaTokens := ResolveBedrockBetaTokens(betaHeader, body, modelID) - - // 3. 对最终 token 列表再做 block 检查,捕获通过 body 自动注入绕过 header block 的情况。 - // 例如:管理员 block 了 interleaved-thinking,客户端不在 header 中带该 token, - // 但请求体中包含 thinking 字段 → autoInjectBedrockBetaTokens 会自动补齐 → - // 如果不做此检查,block 规则会被绕过。 - if blockErr := s.checkBetaPolicyBlockForTokens(ctx, betaTokens, account, modelID); blockErr != nil { - return nil, blockErr - } - - return filterBetaTokens(betaTokens, policy.filterSet), nil -} - -// checkBetaPolicyBlockForTokens 检查 token 列表中是否有被管理员 block 规则命中的 token。 -// 用于补充 evaluateBetaPolicy 对 header 的检查,覆盖 body 自动注入的 token。 -func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, tokens []string, account *Account, model string) *BetaBlockedError { - if s.settingService == nil || len(tokens) == 0 { - return nil - } - settings, err := s.settingService.GetBetaPolicySettings(ctx) - if err != nil || settings == nil { - return nil - } - isOAuth := account.IsOAuth() - isBedrock := account.IsBedrock() - tokenSet := buildBetaTokenSet(tokens) - for _, rule := range settings.Rules { - effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model) - if effectiveAction != BetaPolicyActionBlock { - continue - } - if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) { - continue - } - if _, present := tokenSet[rule.BetaToken]; present { - msg := effectiveErrMsg - if msg == "" { - msg = "beta feature " + rule.BetaToken + " is not allowed" - } - return &BetaBlockedError{Message: msg} - } - } - return nil -} - -func buildBetaTokenSet(tokens []string) map[string]struct{} { - m := make(map[string]struct{}, len(tokens)) - for _, t := range tokens { - if t == "" { - continue - } - m[t] = struct{}{} - } - return m -} - -var defaultDroppedBetasSet = buildBetaTokenSet(claude.DroppedBetas) - -// applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers. -// This mirrors opencode-anthropic-auth behavior: do not trust downstream -// headers when using Claude Code-scoped OAuth credentials. -func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { - if req == nil { - return - } - // Start with the standard defaults (fill missing). - applyClaudeOAuthHeaderDefaults(req) - // Then force key headers to match Claude Code fingerprint regardless of what the client sent. - // 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App") - for key, value := range claude.DefaultHeaders { - if value == "" { - continue - } - setHeaderRaw(req.Header, resolveWireCasing(key), value) - } - // Real Claude CLI uses Accept: application/json (even for streaming). - setHeaderRaw(req.Header, "Accept", "application/json") - if isStream { - setHeaderRaw(req.Header, "x-stainless-helper-method", "stream") - } - // Real Claude CLI 每个请求都会生成一个新的 UUID 放在 x-client-request-id。 - // 上游会以此作为会话/请求指纹的一部分,缺失或重复都可能触发第三方判定。 - if getHeaderRaw(req.Header, "x-client-request-id") == "" { - setHeaderRaw(req.Header, "x-client-request-id", uuid.NewString()) - } -} - -func truncateForLog(b []byte, maxBytes int) string { - if maxBytes <= 0 { - maxBytes = 2048 - } - if len(b) > maxBytes { - b = b[:maxBytes] - } - s := string(b) - // 保持一行,避免污染日志格式 - s = strings.ReplaceAll(s, "\n", "\\n") - s = strings.ReplaceAll(s, "\r", "\\r") - return s -} - -// shouldRectifySignatureError 统一判断是否应触发签名整流(strip thinking blocks 并重试)。 -// 根据账号类型检查对应的开关和匹配模式。 -// -// mappedModel 用于按 thinking 协议族分流:passback-required (DeepSeek/Kimi/GLM 等) 上游 -// 的 400 不是签名缺失问题,retry 任何 thinking 变形都会破坏「原样回传」契约——直接透传 -// 错误给客户端。详见 thinking_protocol.go。 -func (s *GatewayService) shouldRectifySignatureError(ctx context.Context, account *Account, respBody []byte, mappedModel string) bool { - if !ShouldRectifyThinkingSignatureError(mappedModel) { - return false - } - if account.Type == AccountTypeAPIKey { - // API Key 账号:独立开关,一次读取配置 - settings, err := s.settingService.GetRectifierSettings(ctx) - if err != nil || !settings.Enabled || !settings.APIKeySignatureEnabled { - return false - } - // 先检查内置模式(同 OAuth),再检查自定义关键词 - if s.isThinkingBlockSignatureError(respBody) { - return true - } - return matchSignaturePatterns(respBody, settings.APIKeySignaturePatterns) - } - // OAuth/SetupToken/Upstream/Bedrock 等:保持原有行为(内置模式 + 原开关) - return s.isThinkingBlockSignatureError(respBody) && s.settingService.IsSignatureRectifierEnabled(ctx) -} - -// isSignatureErrorPattern 仅做模式匹配,不检查开关。 -// 用于已进入重试流程后的二阶段检测(此时开关已在首次调用时验证过)。 -func (s *GatewayService) isSignatureErrorPattern(ctx context.Context, account *Account, respBody []byte) bool { - if s.isThinkingBlockSignatureError(respBody) { - return true - } - if account.Type == AccountTypeAPIKey { - settings, err := s.settingService.GetRectifierSettings(ctx) - if err != nil { - return false - } - return matchSignaturePatterns(respBody, settings.APIKeySignaturePatterns) - } - return false -} - -// matchSignaturePatterns 检查响应体是否匹配自定义关键词列表(不区分大小写)。 -func matchSignaturePatterns(respBody []byte, patterns []string) bool { - if len(patterns) == 0 { - return false - } - bodyLower := strings.ToLower(string(respBody)) - for _, p := range patterns { - p = strings.TrimSpace(p) - if p == "" { - continue - } - if strings.Contains(bodyLower, strings.ToLower(p)) { - return true - } - } - return false -} - -// isThinkingBlockSignatureError 检测是否是thinking block相关错误 -// 这类错误可以通过过滤thinking blocks并重试来解决 -func (s *GatewayService) isThinkingBlockSignatureError(respBody []byte) bool { - msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) - if msg == "" { - return false - } - - // 检测signature相关的错误(更宽松的匹配) - // 例如: "Invalid `signature` in `thinking` block", "***.signature" 等 - if strings.Contains(msg, "signature") { - return true - } - - // 检测 thinking block 顺序/类型错误 - // 例如: "Expected `thinking` or `redacted_thinking`, but found `text`" - if strings.Contains(msg, "expected") && (strings.Contains(msg, "thinking") || strings.Contains(msg, "redacted_thinking")) { - logger.LegacyPrintf("service.gateway", "[SignatureCheck] Detected thinking block type error") - return true - } - - // 检测 thinking block 被修改的错误 - // 例如: "thinking or redacted_thinking blocks in the latest assistant message cannot be modified" - if strings.Contains(msg, "cannot be modified") && (strings.Contains(msg, "thinking") || strings.Contains(msg, "redacted_thinking")) { - logger.LegacyPrintf("service.gateway", "[SignatureCheck] Detected thinking block modification error") - return true - } - - // 检测空消息内容错误(可能是过滤 thinking blocks 后导致的,或客户端发送了空 text block) - // 例如: "all messages must have non-empty content" - // "messages: text content blocks must be non-empty" - if strings.Contains(msg, "non-empty content") || strings.Contains(msg, "empty content") || - strings.Contains(msg, "content blocks must be non-empty") { - logger.LegacyPrintf("service.gateway", "[SignatureCheck] Detected empty content error") - return true - } - - // 检测 thinking block 缺少 thinking 字段的错误(跨模型切换时常见: - // 其他模型回过的 assistant 历史里有 type=thinking 但没有 thinking 文本, - // 喂给开启 extended thinking 的 claude 时会被拒) - // 例如: "messages.1.content.0.thinking: each thinking block must contain thinking" - if strings.Contains(msg, "thinking block must contain") { - logger.LegacyPrintf("service.gateway", "[SignatureCheck] Detected thinking block missing content error") - return true - } - - return false -} - -func (s *GatewayService) shouldFailoverOn400(respBody []byte) bool { - // 只对"可能是兼容性差异导致"的 400 允许切换,避免无意义重试。 - // 默认保守:无法识别则不切换。 - msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) - if msg == "" { - return false - } - - // 缺少/错误的 beta header:换账号/链路可能成功(尤其是混合调度时)。 - // 更精确匹配 beta 相关的兼容性问题,避免误触发切换。 - if strings.Contains(msg, "anthropic-beta") || - strings.Contains(msg, "beta feature") || - strings.Contains(msg, "requires beta") { - return true - } - - // thinking/tool streaming 等兼容性约束(常见于中间转换链路) - if strings.Contains(msg, "thinking") || strings.Contains(msg, "thought_signature") || strings.Contains(msg, "signature") { - return true - } - if strings.Contains(msg, "tool_use") || strings.Contains(msg, "tool_result") || strings.Contains(msg, "tools") { - return true - } - - return false -} - -// sanitizeStreamError 返回不含网络地址的客户端可见错误描述。 -// 默认 (*net.OpError).Error() 会拼接 Source/Addr 字段,泄露内部 IP/端口与上游 -// 服务器地址(例如 "read tcp 10.0.0.1:54321->52.1.2.3:443: read: connection -// reset by peer")。该函数只保留可识别的错误类别,原始 err 仍在调用点写入日志。 -func sanitizeStreamError(err error) string { - if err == nil { - return "" - } - switch { - case errors.Is(err, io.ErrUnexpectedEOF): - return "unexpected EOF" - case errors.Is(err, io.EOF): - return "EOF" - case errors.Is(err, context.Canceled): - return "canceled" - case errors.Is(err, context.DeadlineExceeded): - return "deadline exceeded" - case errors.Is(err, syscall.ECONNRESET): - return "connection reset by peer" - case errors.Is(err, syscall.ECONNABORTED): - return "connection aborted" - case errors.Is(err, syscall.ETIMEDOUT): - return "connection timed out" - case errors.Is(err, syscall.EPIPE): - return "broken pipe" - case errors.Is(err, syscall.ECONNREFUSED): - return "connection refused" - } - var netErr *net.OpError - if errors.As(err, &netErr) { - if netErr.Timeout() { - if netErr.Op != "" { - return netErr.Op + " timeout" - } - return "i/o timeout" - } - if netErr.Op != "" { - return netErr.Op + " network error" - } - } - return "upstream connection error" -} - -// ExtractUpstreamErrorMessage 从上游响应体中提取错误消息 -// 支持 Claude 风格的错误格式:{"type":"error","error":{"type":"...","message":"..."}} -func ExtractUpstreamErrorMessage(body []byte) string { - return extractUpstreamErrorMessage(body) -} - -func extractUpstreamErrorMessage(body []byte) string { - // Claude 风格:{"type":"error","error":{"type":"...","message":"..."}} - if m := gjson.GetBytes(body, "error.message").String(); strings.TrimSpace(m) != "" { - inner := strings.TrimSpace(m) - // 有些上游会把完整 JSON 作为字符串塞进 message - if strings.HasPrefix(inner, "{") { - if innerMsg := gjson.Get(inner, "error.message").String(); strings.TrimSpace(innerMsg) != "" { - return innerMsg - } - } - return m - } - - // ChatGPT 内部 API 风格:{"detail":"..."} - if d := gjson.GetBytes(body, "detail").String(); strings.TrimSpace(d) != "" { - return d - } - - // 兜底:尝试顶层 message - return gjson.GetBytes(body, "message").String() -} - -func extractUpstreamErrorCode(body []byte) string { - if code := strings.TrimSpace(gjson.GetBytes(body, "error.code").String()); code != "" { - return code - } - - inner := strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) - if !strings.HasPrefix(inner, "{") { - return "" - } - - if code := strings.TrimSpace(gjson.Get(inner, "error.code").String()); code != "" { - return code - } - - if lastBrace := strings.LastIndex(inner, "}"); lastBrace >= 0 { - if code := strings.TrimSpace(gjson.Get(inner[:lastBrace+1], "error.code").String()); code != "" { - return code - } - } - - return "" -} - -func isCountTokensUnsupported404(statusCode int, body []byte) bool { - if statusCode != http.StatusNotFound { - return false - } - msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(body))) - if msg == "" { - return false - } - if strings.Contains(msg, "/v1/messages/count_tokens") { - return true - } - return strings.Contains(msg, "count_tokens") && strings.Contains(msg, "not found") -} - -func (s *GatewayService) readUpstreamErrorBody(resp *http.Response) ([]byte, error) { - if resp == nil || resp.Body == nil { - return nil, nil - } - limit := gatewayUpstreamErrorBodyReadLimit - if s != nil && s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody && s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes > int(limit) { - limit = int64(s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) - } - return io.ReadAll(io.LimitReader(resp.Body, limit)) -} - -func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, requestedModel ...string) (*ForwardResult, error) { - body, _ := s.readUpstreamErrorBody(resp) - - // 调试日志:打印上游错误响应 - logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (non-retryable): Account=%d(%s) Status=%d RequestID=%s Body=%s", - account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(body), 1000)) - - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - - // Print a compact upstream request fingerprint when we hit the Claude Code OAuth - // credential scope error. This avoids requiring env-var tweaks in a fixed deploy. - if isClaudeCodeCredentialScopeError(upstreamMsg) && c != nil { - if v, ok := c.Get(claudeMimicDebugInfoKey); ok { - if line, ok := v.(string); ok && strings.TrimSpace(line) != "" { - logger.LegacyPrintf("service.gateway", "[ClaudeMimicDebugOnError] status=%d request_id=%s %s", - resp.StatusCode, - resp.Header.Get("x-request-id"), - line, - ) - } - } - } - - // Enrich Ops error logs with upstream status + message, and optionally a truncated body snippet. - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(body), maxBytes) - } - setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "http_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - - // 处理上游错误,标记账号状态 - shouldDisable := false - if s.rateLimitService != nil { - if len(requestedModel) > 0 { - shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, requestedModel[0]) - } else { - shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body) - } - } - if shouldDisable { - return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: body} - } - - MarkResponseCommitted(c) - - // 记录上游错误响应体摘要便于排障(可选:由配置控制;不回显到客户端) - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - logger.LegacyPrintf("service.gateway", - "Upstream error %d (account=%d platform=%s type=%s): %s", - resp.StatusCode, - account.ID, - account.Platform, - account.Type, - truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), - ) - } - - // 非 failover 错误也支持错误透传规则匹配。 - if status, errType, errMsg, matched := applyErrorPassthroughRule( - c, - account.Platform, - resp.StatusCode, - body, - http.StatusBadGateway, - "upstream_error", - "Upstream request failed", - ); matched { - c.JSON(status, gin.H{ - "type": "error", - "error": gin.H{ - "type": errType, - "message": errMsg, - }, - }) - - summary := upstreamMsg - if summary == "" { - summary = errMsg - } - if summary == "" { - return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, summary) - } - - // 根据状态码返回适当的自定义错误响应(不透传上游详细信息) - var errType, errMsg string - var statusCode int - - switch resp.StatusCode { - case 400: - c.Data(http.StatusBadRequest, "application/json", body) - summary := upstreamMsg - if summary == "" { - summary = truncateForLog(body, 512) - } - if summary == "" { - return nil, fmt.Errorf("upstream error: %d", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, summary) - case 401: - statusCode = http.StatusBadGateway - errType = "upstream_error" - errMsg = "Upstream authentication failed, please contact administrator" - case 403: - statusCode = http.StatusBadGateway - errType = "upstream_error" - errMsg = "Upstream access forbidden, please contact administrator" - case 429: - statusCode = http.StatusTooManyRequests - errType = "rate_limit_error" - errMsg = "Upstream rate limit exceeded, please retry later" - case 529: - statusCode = http.StatusServiceUnavailable - errType = "overloaded_error" - errMsg = "Upstream service overloaded, please retry later" - case 500, 502, 503, 504: - statusCode = http.StatusBadGateway - errType = "upstream_error" - errMsg = "Upstream service temporarily unavailable" - default: - statusCode = http.StatusBadGateway - errType = "upstream_error" - errMsg = "Upstream request failed" - } - - // 返回自定义错误响应 - c.JSON(statusCode, gin.H{ - "type": "error", - "error": gin.H{ - "type": errType, - "message": errMsg, - }, - }) - - if upstreamMsg == "" { - return nil, fmt.Errorf("upstream error: %d", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg) -} - -func (s *GatewayService) handleRetryExhaustedSideEffects(ctx context.Context, resp *http.Response, account *Account) { - body, _ := s.readUpstreamErrorBody(resp) - statusCode := resp.StatusCode - - // OAuth/Setup Token 账号的 403:标记账号异常 - if account.IsOAuth() && statusCode == 403 { - s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body) - logger.LegacyPrintf("service.gateway", "Account %d: marked as error after %d retries for status %d", account.ID, maxRetryAttempts, statusCode) - } else { - // API Key 未配置错误码:不标记账号状态 - logger.LegacyPrintf("service.gateway", "Account %d: upstream error %d after %d retries (not marking account)", account.ID, statusCode, maxRetryAttempts) - } -} - -func (s *GatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, requestedModel ...string) { - body, _ := s.readUpstreamErrorBody(resp) - if len(requestedModel) > 0 { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, requestedModel[0]) - return - } - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body) -} - -// handleRetryExhaustedError 处理重试耗尽后的错误 -// OAuth 403:标记账号异常 -// API Key 未配置错误码:仅返回错误,不标记账号 -func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) { - MarkResponseCommitted(c) - // Capture upstream error body before side-effects consume the stream. - respBody, _ := s.readUpstreamErrorBody(resp) - _ = resp.Body.Close() - resp.Body = io.NopCloser(bytes.NewReader(respBody)) - - s.handleRetryExhaustedSideEffects(ctx, resp, account) - - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - - if isClaudeCodeCredentialScopeError(upstreamMsg) && c != nil { - if v, ok := c.Get(claudeMimicDebugInfoKey); ok { - if line, ok := v.(string); ok && strings.TrimSpace(line) != "" { - logger.LegacyPrintf("service.gateway", "[ClaudeMimicDebugOnError] status=%d request_id=%s %s", - resp.StatusCode, - resp.Header.Get("x-request-id"), - line, - ) - } - } - } - - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(respBody), maxBytes) - } - setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "retry_exhausted", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - logger.LegacyPrintf("service.gateway", - "Upstream error %d retries_exhausted (account=%d platform=%s type=%s): %s", - resp.StatusCode, - account.ID, - account.Platform, - account.Type, - truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), - ) - } - - if status, errType, errMsg, matched := applyErrorPassthroughRule( - c, - account.Platform, - resp.StatusCode, - respBody, - http.StatusBadGateway, - "upstream_error", - "Upstream request failed after retries", - ); matched { - c.JSON(status, gin.H{ - "type": "error", - "error": gin.H{ - "type": errType, - "message": errMsg, - }, - }) - - summary := upstreamMsg - if summary == "" { - summary = errMsg - } - if summary == "" { - return nil, fmt.Errorf("upstream error: %d (retries exhausted, passthrough rule matched)", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d (retries exhausted, passthrough rule matched) message=%s", resp.StatusCode, summary) - } - - // 返回统一的重试耗尽错误响应 - c.JSON(http.StatusBadGateway, gin.H{ - "type": "error", - "error": gin.H{ - "type": "upstream_error", - "message": "Upstream request failed after retries", - }, - }) - - if upstreamMsg == "" { - return nil, fmt.Errorf("upstream error: %d (retries exhausted)", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d (retries exhausted) message=%s", resp.StatusCode, upstreamMsg) -} - -// streamingResult 流式响应结果 -type streamingResult struct { - usage *ClaudeUsage - firstTokenMs *int - clientDisconnect bool // 客户端是否在流式传输过程中断开 -} - -func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string, mimicClaudeCode bool) (*streamingResult, error) { - // 更新5h窗口状态 - s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) - - if s.responseHeaderFilter != nil { - responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - } - - // 设置SSE响应头 - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") - - // 透传其他响应头 - if v := resp.Header.Get("x-request-id"); v != "" { - c.Header("x-request-id", v) - } - - w := c.Writer - flusher, ok := w.(http.Flusher) - if !ok { - return nil, errors.New("streaming not supported") - } - - usage := &ClaudeUsage{} - var firstTokenMs *int - scanner := bufio.NewScanner(resp.Body) - // 设置更大的buffer以处理长行 - maxLineSize := defaultMaxLineSize - if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { - maxLineSize = s.cfg.Gateway.MaxLineSize - } - scanBuf := getSSEScannerBuf64K() - scanner.Buffer(scanBuf[:0], maxLineSize) - - type scanEvent struct { - line string - err error - } - // 独立 goroutine 读取上游,避免读取阻塞导致超时/keepalive无法处理 - events := make(chan scanEvent, 16) - done := make(chan struct{}) - sendEvent := func(ev scanEvent) bool { - select { - case events <- ev: - return true - case <-done: - return false - } - } - var lastReadAt int64 - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - go func(scanBuf *sseScannerBuf64K) { - defer putSSEScannerBuf64K(scanBuf) - defer close(events) - for scanner.Scan() { - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - if !sendEvent(scanEvent{line: scanner.Text()}) { - return - } - } - if err := scanner.Err(); err != nil { - _ = sendEvent(scanEvent{err: err}) - } - }(scanBuf) - defer close(done) - - streamInterval := time.Duration(0) - if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 { - streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second - } - // 仅监控上游数据间隔超时,避免下游写入阻塞导致误判 - var intervalTicker *time.Ticker - if streamInterval > 0 { - intervalTicker = time.NewTicker(streamInterval) - defer intervalTicker.Stop() - } - var intervalCh <-chan time.Time - if intervalTicker != nil { - intervalCh = intervalTicker.C - } - - // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 - keepaliveInterval := time.Duration(0) - if s.cfg != nil && s.cfg.Gateway.StreamKeepaliveInterval > 0 { - keepaliveInterval = time.Duration(s.cfg.Gateway.StreamKeepaliveInterval) * time.Second - } - var keepaliveTimer *time.Timer - if keepaliveInterval > 0 { - keepaliveTimer = time.NewTimer(keepaliveInterval) - defer keepaliveTimer.Stop() - } - var keepaliveCh <-chan time.Time - if keepaliveTimer != nil { - keepaliveCh = keepaliveTimer.C - } - lastDataAt := time.Now() - resetKeepaliveTimer := func() { - if keepaliveTimer == nil { - return - } - if !keepaliveTimer.Stop() { - select { - case <-keepaliveTimer.C: - default: - } - } - keepaliveTimer.Reset(keepaliveInterval) - } - - // 仅发送一次错误事件,避免多次写入导致协议混乱(写失败时尽力通知客户端)。 - // 事件格式遵循 Anthropic SSE 标准:{"type":"error","error":{"type":,"message":}} - // 这样 Anthropic SDK / Claude Code 等客户端能按标准 error 类型解析,UI 能显示具体错误文案, - // 服务端 ExtractUpstreamErrorMessage 也能从透传的 body 中提取 message。 - errorEventSent := false - sendErrorEvent := func(reason, message string) { - if errorEventSent { - return - } - errorEventSent = true - if message == "" { - message = reason - } - body, err := json.Marshal(map[string]any{ - "type": "error", - "error": map[string]string{ - "type": reason, - "message": message, - }, - }) - if err != nil { - // json.Marshal 不可能在已知 string-only 输入上失败,保守 fallback - body = []byte(fmt.Sprintf(`{"type":"error","error":{"type":%q,"message":%q}}`, reason, message)) - } - _, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", body) - flusher.Flush() - } - - needModelReplace := originalModel != mappedModel - clientDisconnected := false // 客户端断开标志,断开后继续读取上游以获取完整usage - sawTerminalEvent := false - useNoopDeltaKeepalive := c != nil && c.Request != nil && shouldUseClaudeCodeNoopDeltaKeepalive(c.GetHeader("User-Agent")) - noopDeltaKeepaliveBlockIndex := -1 - noopDeltaKeepaliveDeltaType := "" - - pendingEventLines := make([]string, 0, 4) - - processSSEEvent := func(lines []string) ([]string, string, *sseUsagePatch, error) { - if len(lines) == 0 { - return nil, "", nil, nil - } - - eventName := "" - dataLine := "" - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "event:") { - eventName = strings.TrimSpace(strings.TrimPrefix(trimmed, "event:")) - continue - } - if dataLine == "" && sseDataRe.MatchString(trimmed) { - dataLine = sseDataRe.ReplaceAllString(trimmed, "") - } - } - - if eventName == "error" { - return nil, dataLine, nil, &sseStreamErrorEventError{RawData: dataLine} - } - - if dataLine == "" { - return []string{strings.Join(lines, "\n") + "\n\n"}, "", nil, nil - } - - if dataLine == "[DONE]" { - sawTerminalEvent = true - block := "" - if eventName != "" { - block = "event: " + eventName + "\n" - } - block += "data: " + dataLine + "\n\n" - return []string{block}, dataLine, nil, nil - } - - var event map[string]any - if err := json.Unmarshal([]byte(dataLine), &event); err != nil { - // JSON 解析失败,直接透传原始数据 - block := "" - if eventName != "" { - block = "event: " + eventName + "\n" - } - block += "data: " + dataLine + "\n\n" - return []string{block}, dataLine, nil, nil - } - - eventType, _ := event["type"].(string) - if eventName == "" { - eventName = eventType - } - eventChanged := false - - if useNoopDeltaKeepalive { - switch eventType { - case "content_block_start": - if idx, ok := sseEventIndex(event); ok { - noopDeltaKeepaliveBlockIndex = -1 - noopDeltaKeepaliveDeltaType = "" - if contentBlock, ok := event["content_block"].(map[string]any); ok { - blockType, _ := contentBlock["type"].(string) - if deltaType := claudeCodeKeepaliveDeltaTypeForContentBlock(blockType); deltaType != "" { - noopDeltaKeepaliveBlockIndex = idx - noopDeltaKeepaliveDeltaType = deltaType - } - } - } - case "content_block_delta": - if idx, ok := sseEventIndex(event); ok { - if delta, ok := event["delta"].(map[string]any); ok { - deltaType, _ := delta["type"].(string) - if claudeCodeKeepaliveFieldForDeltaType(deltaType) != "" { - noopDeltaKeepaliveBlockIndex = idx - noopDeltaKeepaliveDeltaType = deltaType - } - } - } - case "content_block_stop": - if idx, ok := sseEventIndex(event); ok && idx == noopDeltaKeepaliveBlockIndex { - noopDeltaKeepaliveBlockIndex = -1 - noopDeltaKeepaliveDeltaType = "" - } - case "message_stop": - noopDeltaKeepaliveBlockIndex = -1 - noopDeltaKeepaliveDeltaType = "" - } - } - - // 兼容 Kimi cached_tokens → cache_read_input_tokens - if eventType == "message_start" { - if msg, ok := event["message"].(map[string]any); ok { - if u, ok := msg["usage"].(map[string]any); ok { - eventChanged = reconcileCachedTokens(u) || eventChanged - } - } - } - if eventType == "message_delta" { - if u, ok := event["usage"].(map[string]any); ok { - eventChanged = reconcileCachedTokens(u) || eventChanged - } - } - - // Cache TTL Override: 重写 SSE 事件中的 cache_creation 分类。 - // 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。 - if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok { - if eventType == "message_start" { - if msg, ok := event["message"].(map[string]any); ok { - if u, ok := msg["usage"].(map[string]any); ok { - eventChanged = rewriteCacheCreationJSON(u, overrideTarget) || eventChanged - } - } - } - if eventType == "message_delta" { - if u, ok := event["usage"].(map[string]any); ok { - eventChanged = rewriteCacheCreationJSON(u, overrideTarget) || eventChanged - } - } - } - - if needModelReplace { - if msg, ok := event["message"].(map[string]any); ok { - if model, ok := msg["model"].(string); ok && model == mappedModel { - msg["model"] = originalModel - eventChanged = true - } - } - } - - usagePatch := s.extractSSEUsagePatch(event) - if anthropicStreamEventIsTerminal(eventName, dataLine) { - sawTerminalEvent = true - } - if !eventChanged { - block := "" - if eventName != "" { - block = "event: " + eventName + "\n" - } - block += "data: " + dataLine + "\n\n" - return []string{block}, dataLine, usagePatch, nil - } - - newData, err := json.Marshal(event) - if err != nil { - // 序列化失败,直接透传原始数据 - block := "" - if eventName != "" { - block = "event: " + eventName + "\n" - } - block += "data: " + dataLine + "\n\n" - return []string{block}, dataLine, usagePatch, nil - } - - block := "" - if eventName != "" { - block = "event: " + eventName + "\n" - } - block += "data: " + string(newData) + "\n\n" - return []string{block}, string(newData), usagePatch, nil - } - - for { - select { - case ev, ok := <-events: - if !ok { - // 上游完成,返回结果 - if !sawTerminalEvent { - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, fmt.Errorf("stream usage incomplete: missing terminal event") - } - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, nil - } - if ev.err != nil { - if sawTerminalEvent { - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, nil - } - // 检测 context 取消(客户端断开会导致 context 取消,进而影响上游读取) - if errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded) { - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, fmt.Errorf("stream usage incomplete: %w", ev.err) - } - // 客户端已通过写入失败检测到断开,上游也出错了,返回已收集的 usage - if clientDisconnected { - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, fmt.Errorf("stream usage incomplete after disconnect: %w", ev.err) - } - // 客户端未断开,正常的错误处理 - if errors.Is(ev.err, bufio.ErrTooLong) { - logger.LegacyPrintf("service.gateway", "SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, ev.err) - sendErrorEvent("response_too_large", fmt.Sprintf("upstream SSE line exceeded %d bytes", maxLineSize)) - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, ev.err - } - // 上游中途读错误(unexpected EOF / connection reset 等,常见于 HTTP/2 GOAWAY): - // 若尚未向客户端写过任何字节,包成 UpstreamFailoverError 让 handler 层走 failover/重试。 - // 已经开始写流时 SSE 协议无 resume,只能透传错误事件给客户端。 - // 注意:面向客户端的 disconnectMsg 必须用 sanitizeStreamError 剥离地址, - // 默认 *net.OpError 的 Error() 会泄露内部 IP/端口和上游地址。完整 ev.err - // 仅在下方 LegacyPrintf 内部日志中保留供运维诊断。 - disconnectMsg := "upstream stream disconnected: " + sanitizeStreamError(ev.err) - if !c.Writer.Written() { - logger.LegacyPrintf("service.gateway", "Upstream stream read error before any client output (account=%d), failing over: %v", account.ID, ev.err) - body, _ := json.Marshal(map[string]any{ - "type": "error", - "error": map[string]string{ - "type": "upstream_disconnected", - "message": disconnectMsg, - }, - }) - return nil, &UpstreamFailoverError{ - StatusCode: http.StatusBadGateway, - ResponseBody: body, - RetryableOnSameAccount: true, - } - } - sendErrorEvent("stream_read_error", disconnectMsg) - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream read error: %w", ev.err) - } - line := ev.line - trimmed := strings.TrimSpace(line) - - if trimmed == "" { - if len(pendingEventLines) == 0 { - continue - } - - outputBlocks, data, usagePatch, err := processSSEEvent(pendingEventLines) - pendingEventLines = pendingEventLines[:0] - if err != nil { - if clientDisconnected { - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil - } - return nil, err - } - - for _, block := range outputBlocks { - if !clientDisconnected { - restored := reverseToolNamesIfPresent(c, []byte(block)) - if _, werr := fmt.Fprint(w, string(restored)); werr != nil { - clientDisconnected = true - logger.LegacyPrintf("service.gateway", "Client disconnected during streaming, continuing to drain upstream for billing") - break - } - flusher.Flush() - lastDataAt = time.Now() - resetKeepaliveTimer() - } - if data != "" { - if firstTokenMs == nil && data != "[DONE]" { - ms := int(time.Since(startTime).Milliseconds()) - firstTokenMs = &ms - } - if usagePatch != nil { - mergeSSEUsagePatch(usage, usagePatch) - } - } - } - continue - } - - pendingEventLines = append(pendingEventLines, line) - - case <-intervalCh: - lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) - if time.Since(lastRead) < streamInterval { - continue - } - if clientDisconnected { - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, fmt.Errorf("stream usage incomplete after timeout") - } - logger.LegacyPrintf("service.gateway", "Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval) - // 处理流超时,可能标记账户为临时不可调度或错误状态 - if s.rateLimitService != nil { - s.rateLimitService.HandleStreamTimeout(ctx, account, originalModel) - } - sendErrorEvent("stream_timeout", fmt.Sprintf("upstream stream idle for %s", streamInterval)) - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout") - - case <-keepaliveCh: - if clientDisconnected { - continue - } - if time.Since(lastDataAt) < keepaliveInterval { - resetKeepaliveTimer() - continue - } - keepaliveBlock := "event: ping\ndata: {\"type\": \"ping\"}\n\n" - if useNoopDeltaKeepalive && noopDeltaKeepaliveBlockIndex >= 0 { - if block, ok := buildClaudeCodeNoopDeltaKeepalive(noopDeltaKeepaliveBlockIndex, noopDeltaKeepaliveDeltaType); ok { - keepaliveBlock = block - } - } - if _, werr := fmt.Fprint(w, keepaliveBlock); werr != nil { - clientDisconnected = true - logger.LegacyPrintf("service.gateway", "Client disconnected during keepalive ping, continuing to drain upstream for billing") - continue - } - flusher.Flush() - lastDataAt = time.Now() - resetKeepaliveTimer() - } - } - -} - -func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) { - if usage == nil { - return - } - - var event map[string]any - if err := json.Unmarshal([]byte(data), &event); err != nil { - return - } - - if patch := s.extractSSEUsagePatch(event); patch != nil { - mergeSSEUsagePatch(usage, patch) - } -} - -type sseUsagePatch struct { - inputTokens int - hasInputTokens bool - outputTokens int - hasOutputTokens bool - cacheCreationInputTokens int - hasCacheCreationInput bool - cacheReadInputTokens int - hasCacheReadInput bool - cacheCreation5mTokens int - hasCacheCreation5m bool - cacheCreation1hTokens int - hasCacheCreation1h bool -} - -func (s *GatewayService) extractSSEUsagePatch(event map[string]any) *sseUsagePatch { - if len(event) == 0 { - return nil - } - - eventType, _ := event["type"].(string) - switch eventType { - case "message_start": - msg, _ := event["message"].(map[string]any) - usageObj, _ := msg["usage"].(map[string]any) - if len(usageObj) == 0 { - return nil - } - - patch := &sseUsagePatch{} - patch.hasInputTokens = true - if v, ok := parseSSEUsageInt(usageObj["input_tokens"]); ok { - patch.inputTokens = v - } - patch.hasCacheCreationInput = true - if v, ok := parseSSEUsageInt(usageObj["cache_creation_input_tokens"]); ok { - patch.cacheCreationInputTokens = v - } - patch.hasCacheReadInput = true - if v, ok := parseSSEUsageInt(usageObj["cache_read_input_tokens"]); ok { - patch.cacheReadInputTokens = v - } - if cc, ok := usageObj["cache_creation"].(map[string]any); ok { - if v, exists := parseSSEUsageInt(cc["ephemeral_5m_input_tokens"]); exists { - patch.cacheCreation5mTokens = v - patch.hasCacheCreation5m = true - } - if v, exists := parseSSEUsageInt(cc["ephemeral_1h_input_tokens"]); exists { - patch.cacheCreation1hTokens = v - patch.hasCacheCreation1h = true - } - } - return patch - - case "message_delta": - usageObj, _ := event["usage"].(map[string]any) - if len(usageObj) == 0 { - return nil - } - - patch := &sseUsagePatch{} - if v, ok := parseSSEUsageInt(usageObj["input_tokens"]); ok && v > 0 { - patch.inputTokens = v - patch.hasInputTokens = true - } - if v, ok := parseSSEUsageInt(usageObj["output_tokens"]); ok && v > 0 { - patch.outputTokens = v - patch.hasOutputTokens = true - } - if v, ok := parseSSEUsageInt(usageObj["cache_creation_input_tokens"]); ok && v > 0 { - patch.cacheCreationInputTokens = v - patch.hasCacheCreationInput = true - } - if v, ok := parseSSEUsageInt(usageObj["cache_read_input_tokens"]); ok && v > 0 { - patch.cacheReadInputTokens = v - patch.hasCacheReadInput = true - } - if cc, ok := usageObj["cache_creation"].(map[string]any); ok { - if v, exists := parseSSEUsageInt(cc["ephemeral_5m_input_tokens"]); exists && v > 0 { - patch.cacheCreation5mTokens = v - patch.hasCacheCreation5m = true - } - if v, exists := parseSSEUsageInt(cc["ephemeral_1h_input_tokens"]); exists && v > 0 { - patch.cacheCreation1hTokens = v - patch.hasCacheCreation1h = true - } - } - return patch - } - - return nil -} - -func mergeSSEUsagePatch(usage *ClaudeUsage, patch *sseUsagePatch) { - if usage == nil || patch == nil { - return - } - - if patch.hasInputTokens { - usage.InputTokens = patch.inputTokens - } - if patch.hasCacheCreationInput { - usage.CacheCreationInputTokens = patch.cacheCreationInputTokens - } - if patch.hasCacheReadInput { - usage.CacheReadInputTokens = patch.cacheReadInputTokens - } - if patch.hasOutputTokens { - usage.OutputTokens = patch.outputTokens - } - if patch.hasCacheCreation5m { - usage.CacheCreation5mTokens = patch.cacheCreation5mTokens - } - if patch.hasCacheCreation1h { - usage.CacheCreation1hTokens = patch.cacheCreation1hTokens - } -} - -func parseSSEUsageInt(value any) (int, bool) { - switch v := value.(type) { - case float64: - return int(v), true - case float32: - return int(v), true - case int: - return v, true - case int64: - return int(v), true - case int32: - return int(v), true - case json.Number: - if i, err := v.Int64(); err == nil { - return int(i), true - } - if f, err := v.Float64(); err == nil { - return int(f), true - } - case string: - if parsed, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { - return parsed, true - } - } - return 0, false -} - -// applyCacheTTLOverride 将所有 cache creation tokens 归入指定的 TTL 类型。 -// target 为 "5m" 或 "1h"。返回 true 表示发生了变更。 -func applyCacheTTLOverride(usage *ClaudeUsage, target string) bool { - // Fallback: 如果只有聚合字段但无 5m/1h 明细,将聚合字段归入 5m 默认类别 - if usage.CacheCreation5mTokens == 0 && usage.CacheCreation1hTokens == 0 && usage.CacheCreationInputTokens > 0 { - usage.CacheCreation5mTokens = usage.CacheCreationInputTokens - } - - total := usage.CacheCreation5mTokens + usage.CacheCreation1hTokens - if total == 0 { - return false - } - switch target { - case "1h": - if usage.CacheCreation1hTokens == total { - return false // 已经全是 1h - } - usage.CacheCreation1hTokens = total - usage.CacheCreation5mTokens = 0 - default: // "5m" - if usage.CacheCreation5mTokens == total { - return false // 已经全是 5m - } - usage.CacheCreation5mTokens = total - usage.CacheCreation1hTokens = 0 - } - return true -} - -// rewriteCacheCreationJSON 在 JSON usage 对象中重写 cache_creation 嵌套对象的 TTL 分类。 -// usageObj 是 usage JSON 对象(map[string]any)。 -func rewriteCacheCreationJSON(usageObj map[string]any, target string) bool { - ccObj, ok := usageObj["cache_creation"].(map[string]any) - if !ok { - return false - } - v5m, _ := parseSSEUsageInt(ccObj["ephemeral_5m_input_tokens"]) - v1h, _ := parseSSEUsageInt(ccObj["ephemeral_1h_input_tokens"]) - total := v5m + v1h - if total == 0 { - return false - } - switch target { - case "1h": - if v1h == total { - return false - } - ccObj["ephemeral_1h_input_tokens"] = float64(total) - ccObj["ephemeral_5m_input_tokens"] = float64(0) - default: // "5m" - if v5m == total { - return false - } - ccObj["ephemeral_5m_input_tokens"] = float64(total) - ccObj["ephemeral_1h_input_tokens"] = float64(0) - } - return true -} - -func (s *GatewayService) resolveCacheTTLUsageOverrideTarget(ctx context.Context, account *Account) (string, bool) { - if account == nil { - return "", false - } - if account.IsCacheTTLOverrideEnabled() { - return account.GetCacheTTLOverrideTarget(), true - } - if account.IsAnthropicOAuthOrSetupToken() && s != nil && s.settingService != nil && s.settingService.IsAnthropicCacheTTL1hInjectionEnabled(ctx) { - return cacheTTLTarget5m, true - } - return "", false -} - -func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*ClaudeUsage, error) { - // 更新5h窗口状态 - s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) - - body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, anthropicTooLargeError) - if err != nil { - return nil, err - } - - // 解析usage - var response struct { - Usage ClaudeUsage `json:"usage"` - } - if err := json.Unmarshal(body, &response); err != nil { - if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { - return nil, s.invalidNonStreamingJSONFailoverError(ctx, resp, account, body, err, mappedModel) - } - return nil, fmt.Errorf("parse response: %w", err) - } - - // 解析嵌套的 cache_creation 对象中的 5m/1h 明细 - cc5m := gjson.GetBytes(body, "usage.cache_creation.ephemeral_5m_input_tokens") - cc1h := gjson.GetBytes(body, "usage.cache_creation.ephemeral_1h_input_tokens") - if cc5m.Exists() || cc1h.Exists() { - response.Usage.CacheCreation5mTokens = int(cc5m.Int()) - response.Usage.CacheCreation1hTokens = int(cc1h.Int()) - } - - // 兼容 Kimi cached_tokens → cache_read_input_tokens - if response.Usage.CacheReadInputTokens == 0 { - cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int() - if cachedTokens > 0 { - response.Usage.CacheReadInputTokens = int(cachedTokens) - if newBody, err := sjson.SetBytes(body, "usage.cache_read_input_tokens", cachedTokens); err == nil { - body = newBody - } - } - } - - // Cache TTL Override: 重写 non-streaming 响应中的 cache_creation 分类。 - // 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。 - if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok { - if applyCacheTTLOverride(&response.Usage, overrideTarget) { - // 同步更新 body JSON 中的嵌套 cache_creation 对象 - if newBody, err := sjson.SetBytes(body, "usage.cache_creation.ephemeral_5m_input_tokens", response.Usage.CacheCreation5mTokens); err == nil { - body = newBody - } - if newBody, err := sjson.SetBytes(body, "usage.cache_creation.ephemeral_1h_input_tokens", response.Usage.CacheCreation1hTokens); err == nil { - body = newBody - } - } - } - - // 如果有模型映射,替换响应中的model字段 - if originalModel != mappedModel { - body = s.replaceModelInResponseBody(body, mappedModel, originalModel) - } - - responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - - contentType := "application/json" - if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled { - if upstreamType := resp.Header.Get("Content-Type"); upstreamType != "" { - contentType = upstreamType - } - } - - body = reverseToolNamesIfPresent(c, body) - - // 写入响应 - c.Data(resp.StatusCode, contentType, body) - - return &response.Usage, nil -} - -// replaceModelInResponseBody 替换响应体中的model字段 -// 使用 gjson/sjson 精确替换,避免全量 JSON 反序列化 -func (s *GatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte { - if m := gjson.GetBytes(body, "model"); m.Exists() && m.Str == fromModel { - newBody, err := sjson.SetBytes(body, "model", toModel) - if err != nil { - return body - } - return newBody - } - return body -} - -func (s *GatewayService) getUserGroupRateMultiplier(ctx context.Context, userID, groupID int64, groupDefaultMultiplier float64) float64 { - if s == nil { - return groupDefaultMultiplier - } - resolver := s.userGroupRateResolver - if resolver == nil { - resolver = newUserGroupRateResolver( - s.userGroupRateRepo, - s.userGroupRateCache, - resolveUserGroupRateCacheTTL(s.cfg), - &s.userGroupRateSF, - "service.gateway", - ) - } - return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier) -} - -// RecordUsageInput 记录使用量的输入参数。 -// 异步 worker 只接收计费所需快照,不能持有 ParsedRequest/RequestBodyRef 这类大请求体引用。 -type RecordUsageInput struct { - Result *ForwardResult - APIKey *APIKey - User *User - Account *Account - Subscription *UserSubscription // 可选:订阅信息 - InboundEndpoint string // 入站端点(客户端请求路径) - UpstreamEndpoint string // 上游端点(标准化后的上游路径) - UserAgent string // 请求的 User-Agent - IPAddress string // 请求的客户端 IP 地址 - RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险 - ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换) - APIKeyService APIKeyQuotaUpdater // 可选:用于更新API Key配额 - QuotaPlatform string // user×platform 配额计量平台:handler 在请求 ctx 内经 QuotaPlatform() 算定后传入(后扣运行在 worker 池 background ctx 上,取不到 ForcePlatform) - - ChannelUsageFields // 渠道映射信息(由 handler 在 Forward 前解析) -} - -// APIKeyQuotaUpdater defines the interface for updating API Key quota and rate limit usage -type APIKeyQuotaUpdater interface { - UpdateQuotaUsed(ctx context.Context, apiKeyID int64, cost float64) error - UpdateRateLimitUsage(ctx context.Context, apiKeyID int64, cost float64) error -} - -type apiKeyAuthCacheInvalidator interface { - InvalidateAuthCacheByKey(ctx context.Context, key string) -} - -type usageLogBestEffortWriter interface { - CreateBestEffort(ctx context.Context, log *UsageLog) error -} - -// postUsageBillingParams 统一扣费所需的参数 -type postUsageBillingParams struct { - Cost *CostBreakdown - User *User - APIKey *APIKey - Account *Account - Subscription *UserSubscription - RequestPayloadHash string - IsSubscriptionBill bool - AccountRateMultiplier float64 - APIKeyService APIKeyQuotaUpdater - Platform string // 来自 APIKey 关联 Group 的平台标识 -} - -// PlatformFromAPIKey 从 APIKey 关联的 Group 推导 platform 名称。 -// apiKey 为 nil 或 Group 信息缺失时返回空串(调用方据此 short-circuit quota 累加)。 -// 导出供 handler 层调用。 -func PlatformFromAPIKey(apiKey *APIKey) string { - if apiKey == nil || apiKey.Group == nil { - return "" - } - return apiKey.Group.Platform -} - -// QuotaPlatform 返回 user×platform 配额计量使用的平台标识。 -// 强制平台路由(如 /antigravity)优先按 ctx 中的 ForcePlatform 计量,否则回退到 -// APIKey 关联 Group 的平台。 -// -// 注意:必须用带 ForcePlatform 的请求 context 调用(如 handler 的 c.Request.Context())。 -// 后扣运行在 worker 池的 background ctx 上没有 ForcePlatform,因此后扣平台由 handler -// 预先算定、经 RecordUsageInput.QuotaPlatform 传入,不要在后扣链路用 worker ctx 调用本函数。 -func QuotaPlatform(ctx context.Context, apiKey *APIKey) string { - if fp, ok := ctx.Value(ctxkey.ForcePlatform).(string); ok && fp != "" { - return fp - } - return PlatformFromAPIKey(apiKey) -} - -func (p *postUsageBillingParams) shouldDeductAPIKeyQuota() bool { - return p.Cost.ActualCost > 0 && p.APIKey.Quota > 0 && p.APIKeyService != nil -} - -func (p *postUsageBillingParams) shouldUpdateRateLimits() bool { - return p.Cost.ActualCost > 0 && p.APIKey.HasRateLimits() && p.APIKeyService != nil -} - -func (p *postUsageBillingParams) shouldUpdateAccountQuota() bool { - return p.Cost.TotalCost > 0 && p.Account.IsAPIKeyOrBedrock() && p.Account.HasAnyQuotaLimit() -} - -// postUsageBilling is the legacy fallback billing path used when the unified -// billing repo is unavailable (nil). Production uses applyUsageBilling → repo.Apply -// for atomic billing. This path only runs in tests or degraded mode. -func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *billingDeps) { - billingCtx, cancel := detachedBillingContext(ctx) - defer cancel() - - cost := p.Cost - - if p.IsSubscriptionBill { - // Subscription usage tracked by ActualCost so group rate multiplier - // consumes the quota at the expected speed. - if cost.ActualCost > 0 { - if err := deps.userSubRepo.IncrementUsage(billingCtx, p.Subscription.ID, cost.ActualCost); err != nil { - slog.Error("increment subscription usage failed", "subscription_id", p.Subscription.ID, "error", err) - } - } - } else { - if cost.ActualCost > 0 { - if err := deps.userRepo.DeductBalance(billingCtx, p.User.ID, cost.ActualCost); err != nil { - slog.Error("deduct balance failed", "user_id", p.User.ID, "error", err) - } else if deps.billingCacheService != nil { - if err := deps.billingCacheService.InvalidateUserBalance(billingCtx, p.User.ID); err != nil { - slog.Warn("invalidate balance cache after legacy deduction failed", "user_id", p.User.ID, "error", err) - } - } - } - } - - if p.shouldDeductAPIKeyQuota() { - if err := p.APIKeyService.UpdateQuotaUsed(billingCtx, p.APIKey.ID, cost.ActualCost); err != nil { - slog.Error("update api key quota failed", "api_key_id", p.APIKey.ID, "error", err) - } - } - - if p.shouldUpdateRateLimits() { - if err := p.APIKeyService.UpdateRateLimitUsage(billingCtx, p.APIKey.ID, cost.ActualCost); err != nil { - slog.Error("update api key rate limit usage failed", "api_key_id", p.APIKey.ID, "error", err) - } - } - - if p.shouldUpdateAccountQuota() { - accountCost := cost.TotalCost * p.AccountRateMultiplier - if err := deps.accountRepo.IncrementQuotaUsed(billingCtx, p.Account.ID, accountCost); err != nil { - slog.Error("increment account quota used failed", "account_id", p.Account.ID, "cost", accountCost, "error", err) - } - } - - // Platform quota 累加(legacy 兜底路径):仅对 standard(余额)模式生效;订阅模式豁免;仅对有 limit 的用户写 - // - HasUserPlatformQuotaLimit 守卫:与正常路径对齐,无 limit 公司跳过 - // - 新增 Redis 同步写:enforcement 走 Redis,legacy 路径也必须同步写,否则 preflight 看不到消费 - // - flusher_enabled=false(降级):保留原有同步直写 DB - // - flusher_enabled=true:跳过直写 DB,由 flusher 异步批量刷(markDirty 在 IncrementUserPlatformQuotaUsage 内部完成) - // - 失败仅记 ALERT log + counter,不阻断主扣费流程 - if !p.IsSubscriptionBill && p.Platform != "" && cost.ActualCost > 0 && p.User != nil && deps.userPlatformQuotaRepo != nil { - if deps.billingCacheService.HasUserPlatformQuotaLimit(billingCtx, p.User.ID, p.Platform) { - deps.billingCacheService.IncrementUserPlatformQuotaUsage(p.User.ID, p.Platform, cost.ActualCost) - if deps.cfg == nil || !deps.cfg.Database.UserPlatformQuotaFlusherEnabled { - // 降级路径:flusher 未启用时保留原有同步直写 DB - if err := deps.userPlatformQuotaRepo.IncrementUsageWithReset(billingCtx, p.User.ID, p.Platform, cost.ActualCost, time.Now().UTC()); err != nil { - userPlatformQuotaDBIncrLegacyErrorTotal.Add(1) - logger.LegacyPrintf("service.gateway", "ALERT: legacy incr user platform quota DB failed user=%d platform=%s cost=%f: %v", p.User.ID, p.Platform, cost.ActualCost, err) - } - } - // flusher_enabled=true:不直写 DB,flusher 异步批量刷 - } - } - - // NOTE: finalizePostUsageBilling is NOT called here to avoid double-queuing - // cache updates. The legacy path does DB writes directly; the finalize path - // does cache queue + notifications. Notifications are dispatched separately - // by the caller after recording the usage log. -} - -func resolveUsageBillingRequestID(ctx context.Context, upstreamRequestID string) string { - if ctx != nil { - if clientRequestID, _ := ctx.Value(ctxkey.ClientRequestID).(string); strings.TrimSpace(clientRequestID) != "" { - return "client:" + strings.TrimSpace(clientRequestID) - } - if requestID, _ := ctx.Value(ctxkey.RequestID).(string); strings.TrimSpace(requestID) != "" { - return "local:" + strings.TrimSpace(requestID) - } - } - if requestID := strings.TrimSpace(upstreamRequestID); requestID != "" { - return requestID - } - return "generated:" + generateRequestID() -} - -func resolveUsageBillingPayloadFingerprint(ctx context.Context, requestPayloadHash string) string { - if payloadHash := strings.TrimSpace(requestPayloadHash); payloadHash != "" { - return payloadHash - } - if ctx != nil { - if clientRequestID, _ := ctx.Value(ctxkey.ClientRequestID).(string); strings.TrimSpace(clientRequestID) != "" { - return "client:" + strings.TrimSpace(clientRequestID) - } - if requestID, _ := ctx.Value(ctxkey.RequestID).(string); strings.TrimSpace(requestID) != "" { - return "local:" + strings.TrimSpace(requestID) - } - } - return "" -} - -func buildUsageBillingCommand(requestID string, usageLog *UsageLog, p *postUsageBillingParams) *UsageBillingCommand { - if p == nil || p.Cost == nil || p.APIKey == nil || p.User == nil || p.Account == nil { - return nil - } - - cmd := &UsageBillingCommand{ - RequestID: requestID, - APIKeyID: p.APIKey.ID, - UserID: p.User.ID, - AccountID: p.Account.ID, - AccountType: p.Account.Type, - RequestPayloadHash: strings.TrimSpace(p.RequestPayloadHash), - } - if usageLog != nil { - cmd.Model = usageLog.Model - cmd.BillingType = usageLog.BillingType - cmd.InputTokens = usageLog.InputTokens - cmd.OutputTokens = usageLog.OutputTokens - cmd.CacheCreationTokens = usageLog.CacheCreationTokens - cmd.CacheReadTokens = usageLog.CacheReadTokens - cmd.ImageCount = usageLog.ImageCount - if usageLog.ServiceTier != nil { - cmd.ServiceTier = *usageLog.ServiceTier - } - if usageLog.ReasoningEffort != nil { - cmd.ReasoningEffort = *usageLog.ReasoningEffort - } - if usageLog.SubscriptionID != nil { - cmd.SubscriptionID = usageLog.SubscriptionID - } - } - - // Record subscription / balance cost using ActualCost so the group (and any - // user-specific) rate multiplier consumes subscription quota at the expected - // speed. TotalCost remains the raw (pre-multiplier) value; downstream guards - // on "> 0" still correctly skip free subscriptions (RateMultiplier == 0). - if p.IsSubscriptionBill && p.Subscription != nil && p.Cost.TotalCost > 0 { - cmd.SubscriptionID = &p.Subscription.ID - cmd.SubscriptionCost = p.Cost.ActualCost - } else if p.Cost.ActualCost > 0 { - cmd.BalanceCost = p.Cost.ActualCost - } - - if p.shouldDeductAPIKeyQuota() { - cmd.APIKeyQuotaCost = p.Cost.ActualCost - } - if p.shouldUpdateRateLimits() { - cmd.APIKeyRateLimitCost = p.Cost.ActualCost - } - if p.shouldUpdateAccountQuota() { - cmd.AccountQuotaCost = p.Cost.TotalCost * p.AccountRateMultiplier - } - - cmd.Normalize() - return cmd -} - -func applyUsageBilling(ctx context.Context, requestID string, usageLog *UsageLog, p *postUsageBillingParams, deps *billingDeps, repo UsageBillingRepository) (bool, error) { - if p == nil || deps == nil { - return false, nil - } - - cmd := buildUsageBillingCommand(requestID, usageLog, p) - if cmd == nil || cmd.RequestID == "" || repo == nil { - postUsageBilling(ctx, p, deps) - return true, nil - } - - billingCtx, cancel := detachedBillingContext(ctx) - defer cancel() - - result, err := repo.Apply(billingCtx, cmd) - if err != nil { - return false, err - } - - if result == nil || !result.Applied { - deps.deferredService.ScheduleLastUsedUpdate(p.Account.ID) - return false, nil - } - - if result.APIKeyQuotaExhausted { - if invalidator, ok := p.APIKeyService.(apiKeyAuthCacheInvalidator); ok && p.APIKey != nil && p.APIKey.Key != "" { - invalidator.InvalidateAuthCacheByKey(billingCtx, p.APIKey.Key) - } - } - - finalizePostUsageBilling(billingCtx, p, deps, result) - return true, nil -} - -func finalizePostUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) { - if p == nil || p.Cost == nil || deps == nil { - return - } - - if p.IsSubscriptionBill { - if p.Cost.ActualCost > 0 && p.User != nil && p.APIKey != nil && p.APIKey.GroupID != nil { - deps.billingCacheService.QueueUpdateSubscriptionUsage(p.User.ID, *p.APIKey.GroupID, p.Cost.ActualCost) - } - } else if p.Cost.ActualCost > 0 && p.User != nil { - syncBalanceCacheAfterDeduction(ctx, p, deps, result) - } - - if p.Cost.ActualCost > 0 && p.APIKey != nil && p.APIKey.HasRateLimits() { - deps.billingCacheService.QueueUpdateAPIKeyRateLimitUsage(p.APIKey.ID, p.Cost.ActualCost) - } - - deps.deferredService.ScheduleLastUsedUpdate(p.Account.ID) - - // Platform quota 累加:仅在 standard(余额)模式生效;订阅模式豁免;仅对有 limit 的用户写 - // Redis 同步写 + DB 异步持久化(flag=false 降级)或 flusher 异步刷(flag=true): - // - HasUserPlatformQuotaLimit 守卫:无 limit 的公司跳过,避免无效写入 + 浪费 Redis 容量 - // - Redis 同步:确保下次 preflight 立即看到最新 usage,把 TOCTOU 超支窗口 - // 限制在并发 in-flight 请求数量内(旧实现的异步入队会让超支无限累积直到 worker 处理) - // - DB 异步(flusher_enabled=false):在独立 goroutine 中走 detached context,失败用 ALERT log 触发 oncall 对账 - // - flusher_enabled=true:不直写 DB,由 flusher 异步批量刷(markDirty 已在 IncrementUserPlatformQuotaUsage 内部完成) - if !p.IsSubscriptionBill && p.Platform != "" && p.Cost.ActualCost > 0 && p.User != nil && deps.userPlatformQuotaRepo != nil { - if deps.billingCacheService.HasUserPlatformQuotaLimit(ctx, p.User.ID, p.Platform) { - deps.billingCacheService.IncrementUserPlatformQuotaUsage(p.User.ID, p.Platform, p.Cost.ActualCost) - if deps.cfg == nil || !deps.cfg.Database.UserPlatformQuotaFlusherEnabled { - // 降级路径:flusher 未启用时保留原有异步直写 DB - dbCtx, dbCancel := detachUpstreamContext(ctx) - userID, platform, cost := p.User.ID, p.Platform, p.Cost.ActualCost - go func() { - defer func() { - if r := recover(); r != nil { - logger.LegacyPrintf("service.gateway", "ALERT: panic in user platform quota incr goroutine user=%d platform=%s: %v", userID, platform, r) - } - }() - defer dbCancel() - if err := deps.userPlatformQuotaRepo.IncrementUsageWithReset(dbCtx, userID, platform, cost, time.Now().UTC()); err != nil { - // 失败计数器:暴露给 GatewayUserPlatformQuotaIncrStats(),由 ops 面板做斜率告警。 - userPlatformQuotaDBIncrErrorTotal.Add(1) - // ALERT 级别:DB 持久化失败意味着 Redis cache 失效后该笔 cost 永久丢失, - // 用户配额视图与实际消费会偏差,oncall 需要据此对账或人工补录。 - logger.LegacyPrintf("service.gateway", "ALERT: incr user platform quota DB failed user=%d platform=%s cost=%f: %v", userID, platform, cost, err) - } - }() - } - // flusher_enabled=true:不直写 DB,flusher 异步批量刷 - } - } - - // Notification checks run async — all parameters are already captured, - // no dependency on the request context or upstream connection. - go notifyBalanceLow(p, deps, result) - go notifyAccountQuota(p, deps, result) -} - -func syncBalanceCacheAfterDeduction(ctx context.Context, p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) { - if p == nil || p.Cost == nil || p.User == nil || deps == nil || deps.billingCacheService == nil { - return - } - if result != nil && result.NewBalance != nil && deps.billingCacheService.balanceBelowEligibilityThreshold(*result.NewBalance) { - if err := deps.billingCacheService.InvalidateUserBalance(ctx, p.User.ID); err != nil { - slog.Warn("invalidate balance cache after exhausted deduction failed", - "user_id", p.User.ID, - "new_balance", *result.NewBalance, - "balance_overdrafted", result.BalanceOverdrafted, - "error", err, - ) - } - return - } - deps.billingCacheService.QueueDeductBalance(p.User.ID, p.Cost.ActualCost) -} - -// notifyBalanceLow sends balance low notification after deduction. -// When result.NewBalance is available (from DB transaction RETURNING), it is used directly -// to reconstruct oldBalance, avoiding stale Redis reads and concurrent-deduction races. -func notifyBalanceLow(p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) { - defer func() { - if r := recover(); r != nil { - slog.Error("panic in notifyBalanceLow", "recover", r) - } - }() - if p.IsSubscriptionBill || p.Cost.ActualCost <= 0 || p.User == nil || deps.balanceNotifyService == nil { - slog.Debug("notifyBalanceLow: skipped", - "is_subscription", p.IsSubscriptionBill, - "actual_cost", p.Cost.ActualCost, - "user_nil", p.User == nil, - "service_nil", deps.balanceNotifyService == nil, - ) - return - } - - oldBalance := resolveOldBalance(p, result) - slog.Debug("notifyBalanceLow: calling CheckBalanceAfterDeduction", - "user_id", p.User.ID, - "old_balance", oldBalance, - "cost", p.Cost.ActualCost, - "notify_enabled", p.User.BalanceNotifyEnabled, - "threshold", p.User.BalanceNotifyThreshold, - "result_has_new_balance", result != nil && result.NewBalance != nil, - ) - deps.balanceNotifyService.CheckBalanceAfterDeduction(context.Background(), p.User, oldBalance, p.Cost.ActualCost) -} - -// resolveOldBalance returns the pre-deduction balance. -// Prefers the DB transaction result (newBalance + cost) over snapshot. -func resolveOldBalance(p *postUsageBillingParams, result *UsageBillingApplyResult) float64 { - if result != nil && result.NewBalance != nil { - return *result.NewBalance + p.Cost.ActualCost - } - // Legacy fallback: snapshot balance from request context - return p.User.Balance -} - -// notifyAccountQuota sends account quota threshold notification after increment. -// When result.QuotaState is available (from DB transaction RETURNING), it is passed directly -// to avoid a separate DB read that may see stale or concurrently-modified data. -func notifyAccountQuota(p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) { - defer func() { - if r := recover(); r != nil { - slog.Error("panic in notifyAccountQuota", "recover", r) - } - }() - if p.Cost.TotalCost <= 0 || p.Account == nil || !p.Account.IsAPIKeyOrBedrock() || deps.balanceNotifyService == nil { - slog.Debug("notifyAccountQuota: skipped", - "total_cost", p.Cost.TotalCost, - "account_nil", p.Account == nil, - "is_apikey_or_bedrock", p.Account != nil && p.Account.IsAPIKeyOrBedrock(), - "service_nil", deps.balanceNotifyService == nil, - ) - return - } - accountCost := p.Cost.TotalCost * p.AccountRateMultiplier - var quotaState *AccountQuotaState - if result != nil { - quotaState = result.QuotaState - } - slog.Debug("notifyAccountQuota: calling CheckAccountQuotaAfterIncrement", - "account_id", p.Account.ID, - "account_cost", accountCost, - "has_quota_state", quotaState != nil, - ) - deps.balanceNotifyService.CheckAccountQuotaAfterIncrement(context.Background(), p.Account, accountCost, quotaState) -} - -func detachedBillingContext(ctx context.Context) (context.Context, context.CancelFunc) { - base := context.Background() - if ctx != nil { - base = context.WithoutCancel(ctx) - } - return context.WithTimeout(base, postUsageBillingTimeout) -} - -func detachStreamUpstreamContext(ctx context.Context, stream bool) (context.Context, context.CancelFunc) { - if ctx == nil { - return context.Background(), func() {} - } - if !stream { - return ctx, func() {} - } - return context.WithoutCancel(ctx), func() {} -} - -func detachUpstreamContext(ctx context.Context) (context.Context, context.CancelFunc) { - if ctx == nil { - return context.Background(), func() {} - } - return context.WithoutCancel(ctx), func() {} -} - -// billingDeps 扣费逻辑依赖的服务(由各 gateway service 提供) -type billingDeps struct { - accountRepo AccountRepository - userRepo UserRepository - userSubRepo UserSubscriptionRepository - billingCacheService *BillingCacheService - deferredService *DeferredService - balanceNotifyService *BalanceNotifyService - userPlatformQuotaRepo UserPlatformQuotaRepository - cfg *config.Config -} - -func (s *GatewayService) 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, - cfg: s.cfg, - } -} - -func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usageLog *UsageLog, logKey string) { - if repo == nil || usageLog == nil { - return - } - usageCtx, cancel := detachedBillingContext(ctx) - defer cancel() - - if writer, ok := repo.(usageLogBestEffortWriter); ok { - if err := writer.CreateBestEffort(usageCtx, usageLog); err != nil { - logger.LegacyPrintf(logKey, "Create usage log failed: %v", err) - // 计费已在此前完成,日志必须落库:dropped(批处理队列超时)同样走同步兜底, - // 否则会出现“已扣费但无 usage_log”的对账缺口(issue #3656)。 - // 重复写入由 usage_logs 的 ON CONFLICT (request_id, api_key_id) DO NOTHING 防护。 - fallbackCtx := usageCtx - if usageCtx.Err() != nil { - // usageCtx 已耗尽(best-effort 入队阻塞到期限):换新的 detached 窗口,避免兜底必然失败。 - var fallbackCancel context.CancelFunc - fallbackCtx, fallbackCancel = detachedBillingContext(context.Background()) - defer fallbackCancel() - } - if _, syncErr := repo.Create(fallbackCtx, usageLog); syncErr != nil { - logger.LegacyPrintf(logKey, "Create usage log sync fallback failed: %v", syncErr) - } - } - return - } - - if _, err := repo.Create(usageCtx, usageLog); err != nil { - logger.LegacyPrintf(logKey, "Create usage log failed: %v", err) - } -} - -// recordUsageOpts 内部选项,参数化普通计费与长上下文计费的差异点。 -type recordUsageOpts struct { - // 长上下文计费(仅 Gemini 路径需要) - LongContextThreshold int - LongContextMultiplier float64 -} - -// RecordUsage 记录使用量并扣费(或更新订阅用量) -func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInput) error { - return s.recordUsageCore(ctx, &recordUsageCoreInput{ - Result: input.Result, - APIKey: input.APIKey, - User: input.User, - Account: input.Account, - Subscription: input.Subscription, - InboundEndpoint: input.InboundEndpoint, - UpstreamEndpoint: input.UpstreamEndpoint, - UserAgent: input.UserAgent, - IPAddress: input.IPAddress, - RequestPayloadHash: input.RequestPayloadHash, - ForceCacheBilling: input.ForceCacheBilling, - APIKeyService: input.APIKeyService, - QuotaPlatform: input.QuotaPlatform, - ChannelUsageFields: input.ChannelUsageFields, - }, &recordUsageOpts{}) -} - -// RecordUsageLongContextInput 记录使用量的输入参数(支持长上下文双倍计费) -type RecordUsageLongContextInput struct { - Result *ForwardResult - APIKey *APIKey - User *User - Account *Account - Subscription *UserSubscription // 可选:订阅信息 - InboundEndpoint string // 入站端点(客户端请求路径) - UpstreamEndpoint string // 上游端点(标准化后的上游路径) - UserAgent string // 请求的 User-Agent - IPAddress string // 请求的客户端 IP 地址 - RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险 - LongContextThreshold int // 长上下文阈值(如 200000) - LongContextMultiplier float64 // 超出阈值部分的倍率(如 2.0) - ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换) - APIKeyService APIKeyQuotaUpdater // API Key 配额服务(可选) - QuotaPlatform string // user×platform 配额计量平台:handler 在请求 ctx 内经 QuotaPlatform() 算定后传入(后扣运行在 worker 池 background ctx 上,取不到 ForcePlatform) - - ChannelUsageFields // 渠道映射信息(由 handler 在 Forward 前解析) -} - -// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini) -func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *RecordUsageLongContextInput) error { - return s.recordUsageCore(ctx, &recordUsageCoreInput{ - Result: input.Result, - APIKey: input.APIKey, - User: input.User, - Account: input.Account, - Subscription: input.Subscription, - InboundEndpoint: input.InboundEndpoint, - UpstreamEndpoint: input.UpstreamEndpoint, - UserAgent: input.UserAgent, - IPAddress: input.IPAddress, - RequestPayloadHash: input.RequestPayloadHash, - ForceCacheBilling: input.ForceCacheBilling, - APIKeyService: input.APIKeyService, - QuotaPlatform: input.QuotaPlatform, - ChannelUsageFields: input.ChannelUsageFields, - }, &recordUsageOpts{ - LongContextThreshold: input.LongContextThreshold, - LongContextMultiplier: input.LongContextMultiplier, - }) -} - -// recordUsageCoreInput 是 recordUsageCore 的公共输入字段,从两种输入结构体中提取。 -type recordUsageCoreInput struct { - Result *ForwardResult - APIKey *APIKey - User *User - Account *Account - Subscription *UserSubscription - InboundEndpoint string - UpstreamEndpoint string - UserAgent string - IPAddress string - RequestPayloadHash string - ForceCacheBilling bool - APIKeyService APIKeyQuotaUpdater - QuotaPlatform string - ChannelUsageFields -} - -// recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。 -// LongContextThreshold > 0 时 Token 计费回退走 CalculateCostWithLongContext。 -func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error { - result := input.Result - apiKey := input.APIKey - user := input.User - account := input.Account - subscription := input.Subscription - ApplyForwardImageBillingResolution(result) - - // 强制缓存计费:将 input_tokens 转为 cache_read_input_tokens - // 用于粘性会话切换时的特殊计费处理 - if input.ForceCacheBilling && result.Usage.InputTokens > 0 { - logger.LegacyPrintf("service.gateway", "force_cache_billing: %d input_tokens → cache_read_input_tokens (account=%d)", - result.Usage.InputTokens, account.ID) - result.Usage.CacheReadInputTokens += result.Usage.InputTokens - result.Usage.InputTokens = 0 - } - - // Cache TTL Override: 确保计费时 token 分类与账号设置一致。 - // 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。 - cacheTTLOverridden := false - if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok { - applyCacheTTLOverride(&result.Usage, overrideTarget) - cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0 - } - - // 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认) - multiplier := 1.0 - if s.cfg != nil { - multiplier = s.cfg.Default.RateMultiplier - } - if apiKey.GroupID != nil && apiKey.Group != nil { - groupDefault := apiKey.Group.RateMultiplier - multiplier = s.getUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault) - } - // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算, - // 不并入上面的 getUserGroupRateMultiplier,以免污染 user:group 倍率缓存。 - multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, timezone.Now()) - - // 确定计费模型 - billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel) - if input.BillingModelSource == BillingModelSourceChannelMapped && input.ChannelMappedModel != "" { - billingModel = input.ChannelMappedModel - } - if input.BillingModelSource == BillingModelSourceRequested && input.OriginalModel != "" { - billingModel = input.OriginalModel - } - - // 确定 RequestedModel(渠道映射前的原始模型) - requestedModel := result.Model - if input.OriginalModel != "" { - requestedModel = input.OriginalModel - } - - // 计算费用 - cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, opts) - - // 判断计费方式:订阅模式 vs 余额模式 - isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() - billingType := BillingTypeBalance - if isSubscriptionBilling { - billingType = BillingTypeSubscription - } - - // 创建使用日志 - accountRateMultiplier := account.BillingRateMultiplier() - usageLog := s.buildRecordUsageLog(ctx, input, result, apiKey, user, account, subscription, - requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost, opts) - - // 计算账号统计定价费用(使用最终上游模型匹配自定义规则) - if apiKey.GroupID != nil { - applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService, - account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model, - // Anthropic's input_tokens excludes cache_read and cache_creation (billed separately); - // OpenAI gateway uses actualInputTokens which also excludes cache_read for the same reason. - UsageTokens{ - InputTokens: result.Usage.InputTokens, - OutputTokens: result.Usage.OutputTokens, - CacheCreationTokens: result.Usage.CacheCreationInputTokens, - CacheReadTokens: result.Usage.CacheReadInputTokens, - ImageOutputTokens: result.Usage.ImageOutputTokens, - }, - cost.TotalCost, - ) - } - - if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { - writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway") - logger.LegacyPrintf("service.gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens()) - s.deferredService.ScheduleLastUsedUpdate(account.ID) - return nil - } - - // 配额平台由 handler 在请求 ctx 内经 QuotaPlatform() 算定并通过 input 传入; - // 后扣运行在 worker 池的 background ctx 上,无法再从 ctx 取 ForcePlatform。 - // 缺省(未设置)时回退到分组平台,保持对其它调用方的兼容。 - quotaPlatform := input.QuotaPlatform - if quotaPlatform == "" { - quotaPlatform = PlatformFromAPIKey(apiKey) - } - requestID := usageLog.RequestID - _, billingErr := applyUsageBilling(ctx, requestID, usageLog, &postUsageBillingParams{ - Cost: cost, - User: user, - APIKey: apiKey, - Account: account, - Subscription: subscription, - RequestPayloadHash: resolveUsageBillingPayloadFingerprint(ctx, input.RequestPayloadHash), - IsSubscriptionBill: isSubscriptionBilling, - AccountRateMultiplier: accountRateMultiplier, - APIKeyService: input.APIKeyService, - Platform: quotaPlatform, - }, s.billingDeps(), s.usageBillingRepo) - - if billingErr != nil { - return billingErr - } - writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway") - - return nil -} - -// calculateRecordUsageCost 根据请求类型和选项计算费用。 -func (s *GatewayService) calculateRecordUsageCost( - ctx context.Context, - result *ForwardResult, - apiKey *APIKey, - billingModel string, - multiplier float64, - imageMultiplier float64, - opts *recordUsageOpts, -) *CostBreakdown { - // 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费 - if result.ImageCount > 0 { - if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && resolved.Mode == BillingModeToken { - return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts) - } - return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier) - } - - // Token 计费 - return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts) -} - -// resolveChannelPricing 检查指定模型是否存在渠道级别定价。 -// 返回非 nil 的 ResolvedPricing 表示有渠道定价,nil 表示走默认定价路径。 -func (s *GatewayService) resolveChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing { - if s.resolver == nil || apiKey.Group == nil { - return nil - } - gid := apiKey.Group.ID - resolved := s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid}) - if resolved.Source == PricingSourceChannel { - return resolved - } - return nil -} - -// calculateImageCost 计算图片生成费用:渠道级别定价优先,否则走按次计费。 -func (s *GatewayService) calculateImageCost( - ctx context.Context, - result *ForwardResult, - apiKey *APIKey, - billingModel string, - multiplier float64, -) *CostBreakdown { - sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize) - if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil { - tokens := UsageTokens{ - InputTokens: result.Usage.InputTokens, - OutputTokens: result.Usage.OutputTokens, - ImageOutputTokens: result.Usage.ImageOutputTokens, - } - gid := apiKey.Group.ID - cost, err := s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, - Model: billingModel, - GroupID: &gid, - Tokens: tokens, - RequestCount: result.ImageCount, - SizeTier: sizeTier, - RateMultiplier: multiplier, - Resolver: s.resolver, - Resolved: resolved, - }) - if err != nil { - logger.LegacyPrintf("service.gateway", "Calculate image token cost failed: %v", err) - return &CostBreakdown{ActualCost: 0} - } - return cost - } - - var groupConfig *ImagePriceConfig - if apiKey.Group != nil { - groupConfig = &ImagePriceConfig{ - Price1K: apiKey.Group.ImagePrice1K, - Price2K: apiKey.Group.ImagePrice2K, - Price4K: apiKey.Group.ImagePrice4K, - } - } - return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) -} - -// calculateTokenCost 计算 Token 计费:根据 opts 决定走普通/长上下文/渠道统一计费。 -func (s *GatewayService) calculateTokenCost( - ctx context.Context, - result *ForwardResult, - apiKey *APIKey, - billingModel string, - multiplier float64, - opts *recordUsageOpts, -) *CostBreakdown { - tokens := UsageTokens{ - InputTokens: result.Usage.InputTokens, - OutputTokens: result.Usage.OutputTokens, - CacheCreationTokens: result.Usage.CacheCreationInputTokens, - CacheReadTokens: result.Usage.CacheReadInputTokens, - CacheCreation5mTokens: result.Usage.CacheCreation5mTokens, - CacheCreation1hTokens: result.Usage.CacheCreation1hTokens, - ImageOutputTokens: result.Usage.ImageOutputTokens, - } - - var cost *CostBreakdown - var err error - - // 优先尝试渠道定价 → CalculateCostUnified - if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil { - gid := apiKey.Group.ID - cost, err = s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, - Model: billingModel, - GroupID: &gid, - Tokens: tokens, - RequestCount: 1, - RateMultiplier: multiplier, - Resolver: s.resolver, - Resolved: resolved, - }) - } else if opts.LongContextThreshold > 0 { - // 长上下文双倍计费(如 Gemini 200K 阈值) - cost, err = s.billingService.CalculateCostWithLongContext(billingModel, tokens, multiplier, opts.LongContextThreshold, opts.LongContextMultiplier) - } else { - cost, err = s.billingService.CalculateCost(billingModel, tokens, multiplier) - } - if err != nil { - logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err) - return &CostBreakdown{ActualCost: 0} - } - return cost -} - -// buildRecordUsageLog 构建使用日志并设置计费模式。 -func (s *GatewayService) buildRecordUsageLog( - ctx context.Context, - input *recordUsageCoreInput, - result *ForwardResult, - apiKey *APIKey, - user *User, - account *Account, - subscription *UserSubscription, - requestedModel string, - multiplier float64, - imageMultiplier float64, - accountRateMultiplier float64, - billingType int8, - cacheTTLOverridden bool, - cost *CostBreakdown, - opts *recordUsageOpts, -) *UsageLog { - durationMs := int(result.Duration.Milliseconds()) - requestID := resolveUsageBillingRequestID(ctx, result.RequestID) - usageLog := &UsageLog{ - UserID: user.ID, - APIKeyID: apiKey.ID, - AccountID: account.ID, - RequestID: requestID, - Model: result.Model, - RequestedModel: requestedModel, - UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model), - ReasoningEffort: result.ReasoningEffort, - InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), - UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), - InputTokens: result.Usage.InputTokens, - OutputTokens: result.Usage.OutputTokens, - CacheCreationTokens: result.Usage.CacheCreationInputTokens, - CacheReadTokens: result.Usage.CacheReadInputTokens, - CacheCreation5mTokens: result.Usage.CacheCreation5mTokens, - CacheCreation1hTokens: result.Usage.CacheCreation1hTokens, - ImageOutputTokens: result.Usage.ImageOutputTokens, - RateMultiplier: multiplier, - AccountRateMultiplier: &accountRateMultiplier, - BillingType: billingType, - BillingMode: resolveBillingMode(result, cost), - Stream: result.Stream, - DurationMs: &durationMs, - FirstTokenMs: result.FirstTokenMs, - ImageCount: result.ImageCount, - ImageSize: optionalTrimmedStringPtr(result.ImageSize), - ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize), - ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize), - ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource), - ImageSizeBreakdown: result.ImageSizeBreakdown, - CacheTTLOverridden: cacheTTLOverridden, - ChannelID: optionalInt64Ptr(input.ChannelID), - ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain), - UserAgent: optionalTrimmedStringPtr(input.UserAgent), - IPAddress: optionalTrimmedStringPtr(input.IPAddress), - GroupID: apiKey.GroupID, - SubscriptionID: optionalSubscriptionID(subscription), - CreatedAt: time.Now(), - } - if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) { - usageLog.RateMultiplier = imageMultiplier - } - if cost != nil { - usageLog.InputCost = cost.InputCost - usageLog.OutputCost = cost.OutputCost - usageLog.ImageOutputCost = cost.ImageOutputCost - usageLog.CacheCreationCost = cost.CacheCreationCost - usageLog.CacheReadCost = cost.CacheReadCost - usageLog.TotalCost = cost.TotalCost - usageLog.ActualCost = cost.ActualCost - } - - return usageLog -} - -// resolveBillingMode 根据计费结果和请求类型确定计费模式。 -func resolveBillingMode(result *ForwardResult, cost *CostBreakdown) *string { - var mode string - switch { - case cost != nil && cost.BillingMode != "": - mode = cost.BillingMode - case result.ImageCount > 0: - mode = string(BillingModeImage) - default: - mode = string(BillingModeToken) - } - return &mode -} - -func optionalSubscriptionID(subscription *UserSubscription) *int64 { - if subscription != nil { - return &subscription.ID - } - return nil -} - -// ResolveChannelMapping 委托渠道服务解析模型映射 -func (s *GatewayService) ResolveChannelMapping(ctx context.Context, groupID int64, model string) ChannelMappingResult { - if s.channelService == nil { - return ChannelMappingResult{MappedModel: model} - } - return s.channelService.ResolveChannelMapping(ctx, groupID, model) -} - -// ReplaceModelInBody 替换请求体中的模型名(导出供 handler 使用) -func (s *GatewayService) ReplaceModelInBody(body []byte, newModel string) []byte { - return ReplaceModelInBody(body, newModel) -} - -// IsModelRestricted 检查模型是否被渠道限制 -func (s *GatewayService) IsModelRestricted(ctx context.Context, groupID int64, model string) bool { - if s.channelService == nil { - return false - } - return s.channelService.IsModelRestricted(ctx, groupID, model) -} - -// ResolveChannelMappingAndRestrict 解析渠道映射。 -// 模型限制检查已移至调度阶段(checkChannelPricingRestriction),restricted 始终返回 false。 -func (s *GatewayService) 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) -} - -// checkChannelPricingRestriction 根据渠道计费基准检查模型是否受定价列表限制。 -// 供调度阶段预检查(requested / channel_mapped)。 -// upstream 需逐账号检查,此处返回 false。 -func (s *GatewayService) 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) -} - -// billingModelForRestriction 根据计费基准确定限制检查使用的模型。 -// upstream 返回空(需逐账号检查)。 -func billingModelForRestriction(source, requestedModel, channelMappedModel string) string { - switch source { - case BillingModelSourceRequested: - return requestedModel - case BillingModelSourceUpstream: - return "" - case BillingModelSourceChannelMapped: - return channelMappedModel - default: - return channelMappedModel - } -} - -// isUpstreamModelRestrictedByChannel 检查账号映射后的上游模型是否受渠道定价限制。 -// 仅在 BillingModelSource="upstream" 且 RestrictModels=true 时由调度循环调用。 -func (s *GatewayService) isUpstreamModelRestrictedByChannel(ctx context.Context, groupID int64, account *Account, requestedModel string) bool { - if s.channelService == nil { - return false - } - upstreamModel := resolveAccountUpstreamModel(account, requestedModel) - if upstreamModel == "" { - return false - } - return s.channelService.IsModelRestricted(ctx, groupID, upstreamModel) -} - -// resolveAccountUpstreamModel 确定账号将请求模型映射为什么上游模型。 -func resolveAccountUpstreamModel(account *Account, requestedModel string) string { - if account.Platform == PlatformAntigravity { - return mapAntigravityModel(account, requestedModel) - } - return account.GetMappedModel(requestedModel) -} - -// needsUpstreamChannelRestrictionCheck 判断是否需要在调度循环中逐账号检查上游模型的渠道限制。 -func (s *GatewayService) 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 channel upstream restriction", "group_id", *groupID, "error", err) - return false - } - if ch == nil || !ch.RestrictModels { - return false - } - return ch.BillingModelSource == BillingModelSourceUpstream -} - -// isStickyAccountUpstreamRestricted 检查粘性会话命中的账号是否受 upstream 渠道限制。 -// 合并 needsUpstreamChannelRestrictionCheck + isUpstreamModelRestrictedByChannel 两步调用, -// 供 sticky session 条件链使用,避免内联多个函数调用导致行过长。 -func (s *GatewayService) isStickyAccountUpstreamRestricted(ctx context.Context, groupID *int64, account *Account, requestedModel string) bool { - if groupID == nil { - return false - } - if !s.needsUpstreamChannelRestrictionCheck(ctx, groupID) { - return false - } - return s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel) -} - -// ForwardCountTokens 转发 count_tokens 请求到上游 API -// 特点:不记录使用量、仅支持非流式响应 -func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) error { - if parsed == nil { - s.countTokensError(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty") - return fmt.Errorf("parse request: empty request") - } - - if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() { - passthroughBody := parsed.Body.Bytes() - if reqModel := parsed.Model; reqModel != "" { - if mappedModel := account.GetMappedModel(reqModel); mappedModel != reqModel { - passthroughBody = s.replaceModelInBody(passthroughBody, mappedModel) - logger.LegacyPrintf("service.gateway", "CountTokens passthrough model mapping: %s -> %s (account: %s)", reqModel, mappedModel, account.Name) - } - } - return s.forwardCountTokensAnthropicAPIKeyPassthrough(ctx, c, account, passthroughBody) - } - - // Bedrock 不支持 count_tokens 端点 - if account != nil && account.IsBedrock() { - s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported for Bedrock") - return nil - } - - body := parsed.Body.Bytes() - replaceBody := func(next []byte) error { - if err := parsed.ReplaceBody(next); err != nil { - return fmt.Errorf("rewrite count_tokens body: %w", err) - } - body = parsed.Body.Bytes() - return nil - } - reqModel := parsed.Model - - // Pre-filter: strip empty text blocks to prevent upstream 400. - if err := replaceBody(StripEmptyTextBlocks(body)); err != nil { - return err - } - - isClaudeCodeCT := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) - shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCodeCT - - if shouldMimicClaudeCode { - normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} - var normalizedBody []byte - normalizedBody, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts) - if err := replaceBody(normalizedBody); err != nil { - return err - } - - if err := replaceBody(s.rewriteMessageCacheControlIfEnabled(ctx, body)); err != nil { - return err - } - if rw := buildToolNameRewriteFromBody(body); rw != nil { - if err := replaceBody(applyToolNameRewriteToBody(body, rw)); err != nil { - return err - } - } else { - if err := replaceBody(applyToolsLastCacheBreakpoint(body)); err != nil { - return err - } - } - } - - // Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。 - // 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。 - if account.Platform == PlatformAntigravity { - s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported for this platform") - return nil - } - - // 应用模型映射: - // - APIKey 账号:使用账号级别的显式映射(如果配置),否则透传原始模型名 - // - OAuth/SetupToken 账号:使用 Anthropic 标准映射(短ID → 长ID) - if reqModel != "" { - mappedModel := reqModel - mappingSource := "" - if account.Type == AccountTypeAPIKey { - mappedModel = account.GetMappedModel(reqModel) - if mappedModel != reqModel { - mappingSource = "account" - } - } - if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey { - normalized := claude.NormalizeModelID(reqModel) - if normalized != reqModel { - mappedModel = normalized - mappingSource = "prefix" - } - } - if mappedModel != reqModel { - originalReqModel := reqModel - if err := replaceBody(s.replaceModelInBody(body, mappedModel)); err != nil { - return err - } - reqModel = mappedModel - parsed.Model = mappedModel - logger.LegacyPrintf("service.gateway", "CountTokens model mapping applied: %s -> %s (account: %s, source=%s)", originalReqModel, mappedModel, account.Name, mappingSource) - } - } - - // 获取凭证 - token, tokenType, err := s.GetAccessToken(ctx, account) - if err != nil { - s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to get access token") - return err - } - - // 构建上游请求 - upstreamReq, wireBody, err := s.buildCountTokensRequest(ctx, c, account, body, token, tokenType, reqModel, shouldMimicClaudeCode) - if err != nil { - s.countTokensError(c, http.StatusInternalServerError, "api_error", "Failed to build request") - return err - } - // 先记录首发 wire body;如果后面进入 400 retry,retry 会基于未签名的逻辑 body 重新构建。 - acceptedWireBody := wireBody - - // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递) - proxyURL := "" - if account.ProxyID != nil && account.Proxy != nil { - if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" { - proxyURL = account.Proxy.URL() - } - } - - // 发送请求 - resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) - if err != nil { - setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "") - s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed") - return fmt.Errorf("upstream request failed: %w", err) - } - - // 读取响应体 - countTokensTooLarge := func(c *gin.Context) { - s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response too large") - } - respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, countTokensTooLarge) - _ = resp.Body.Close() - if err != nil { - if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) { - s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response") - } - return err - } - - // 检测 thinking block 签名错误(400)并重试一次(过滤 thinking blocks) - if resp.StatusCode == 400 && s.shouldRectifySignatureError(ctx, account, respBody, reqModel) { - logger.LegacyPrintf("service.gateway", "Account %d: detected thinking block signature error on count_tokens, retrying with filtered thinking blocks", account.ID) - - filteredBody := FilterThinkingBlocksForRetry(body, reqModel) - retryReq, retryWireBody, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, shouldMimicClaudeCode) - if buildErr == nil { - retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) - if retryErr == nil { - if retryResp.StatusCode < 400 { - // count_tokens 签名重试成功后记录最终 wire body,错误响应仍保留原 body 便于后续处理。 - acceptedWireBody = retryWireBody - } - resp = retryResp - respBody, err = ReadUpstreamResponseBody(resp.Body, s.cfg, c, countTokensTooLarge) - _ = resp.Body.Close() - if err != nil { - if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) { - s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response") - } - return err - } - } - } - } - - if resp.StatusCode < 400 && !bytes.Equal(acceptedWireBody, body) { - // count_tokens 成功后再同步最终 wire body,避免 retry 从已签名 body 派生。 - if err := replaceBody(acceptedWireBody); err != nil { - return err - } - } - - // 处理错误响应 - if resp.StatusCode >= 400 { - // 标记账号状态(429/529等) - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) - - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(respBody), maxBytes) - } - setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) - - // 记录上游错误摘要便于排障(不回显请求内容) - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - logger.LegacyPrintf("service.gateway", - "count_tokens upstream error %d (account=%d platform=%s type=%s): %s", - resp.StatusCode, - account.ID, - account.Platform, - account.Type, - truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), - ) - } - - // 返回简化的错误响应 - errMsg := "Upstream request failed" - switch resp.StatusCode { - case 429: - errMsg = "Rate limit exceeded" - case 529: - errMsg = "Service overloaded" - } - s.countTokensError(c, resp.StatusCode, "upstream_error", errMsg) - if upstreamMsg == "" { - return fmt.Errorf("upstream error: %d", resp.StatusCode) - } - return fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg) - } - - // 透传成功响应 - c.Data(resp.StatusCode, "application/json", respBody) - return nil -} - -func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx context.Context, c *gin.Context, account *Account, body []byte) error { - token, tokenType, err := s.GetAccessToken(ctx, account) - if err != nil { - s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to get access token") - return err - } - if tokenType != "apikey" { - s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Invalid account token type") - return fmt.Errorf("anthropic api key passthrough requires apikey token, got: %s", tokenType) - } - - upstreamReq, err := s.buildCountTokensRequestAnthropicAPIKeyPassthrough(ctx, c, account, body, token) - if err != nil { - s.countTokensError(c, http.StatusInternalServerError, "api_error", "Failed to build request") - return err - } - - proxyURL := "" - if account.ProxyID != nil && account.Proxy != nil { - proxyURL = account.Proxy.URL() - } - - resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) - if err != nil { - setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "") - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), - Passthrough: true, - Kind: "request_error", - Message: sanitizeUpstreamErrorMessage(err.Error()), - }) - s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed") - return fmt.Errorf("upstream request failed: %w", err) - } - - countTokensTooLarge := func(c *gin.Context) { - s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response too large") - } - respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, countTokensTooLarge) - _ = resp.Body.Close() - if err != nil { - if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) { - s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response") - } - return err - } - - if resp.StatusCode >= 400 { - if s.rateLimitService != nil { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) - } - - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - - // 中转站不支持 count_tokens 端点时(404),返回 404 让客户端 fallback 到本地估算。 - // 仅在错误消息明确指向 count_tokens endpoint 不存在时生效,避免误吞其他 404(如错误 base_url)。 - // 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。 - if isCountTokensUnsupported404(resp.StatusCode, respBody) { - logger.LegacyPrintf("service.gateway", - "[count_tokens] Upstream does not support count_tokens (404), returning 404: account=%d name=%s msg=%s", - account.ID, account.Name, truncateString(upstreamMsg, 512)) - s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported by upstream") - return nil - } - - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(respBody), maxBytes) - } - setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), - Passthrough: true, - Kind: "http_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - - errMsg := "Upstream request failed" - switch resp.StatusCode { - case 429: - errMsg = "Rate limit exceeded" - case 529: - errMsg = "Service overloaded" - } - s.countTokensError(c, resp.StatusCode, "upstream_error", errMsg) - if upstreamMsg == "" { - return fmt.Errorf("upstream error: %d", resp.StatusCode) - } - return fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg) - } - - writeAnthropicPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) - if contentType == "" { - contentType = "application/json" - } - c.Data(resp.StatusCode, contentType, respBody) - return nil -} - -func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( - ctx context.Context, - c *gin.Context, - account *Account, - body []byte, - token string, -) (*http.Request, error) { - targetURL := claudeAPICountTokensURL - baseURL := account.GetBaseURL() - if baseURL != "" { - validatedURL, err := s.validateUpstreamBaseURL(baseURL) - if err != nil { - return nil, err - } - targetURL = validatedURL + "/v1/messages/count_tokens?beta=true" - } - body = sanitizeCountTokensRequestBody(body) - - // 同 buildUpstreamRequestAnthropicAPIKeyPassthrough:能力维度 sanitize。 - clientBeta := "" - if c != nil && c.Request != nil { - clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta") - } - // 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准 - if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok { - clientBeta = beta - } - if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed { - body = sanitized - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) - if err != nil { - return nil, err - } - - if c != nil && c.Request != nil { - for key, values := range c.Request.Header { - lowerKey := strings.ToLower(strings.TrimSpace(key)) - if !allowedHeaders[lowerKey] { - continue - } - wireKey := resolveWireCasing(key) - for _, v := range values { - addHeaderRaw(req.Header, wireKey, v) - } - } - } - - req.Header.Del("authorization") - req.Header.Del("x-api-key") - req.Header.Del("x-goog-api-key") - req.Header.Del("cookie") - setAnthropicAPIKeyAuthHeader(req.Header, account, token) - - if req.Header.Get("content-type") == "" { - req.Header.Set("content-type", "application/json") - } - if req.Header.Get("anthropic-version") == "" { - req.Header.Set("anthropic-version", "2023-06-01") - } - - // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头) - account.ApplyHeaderOverrides(req.Header) - - return req, nil -} - -// buildCountTokensRequest 构建 count_tokens 上游请求 -func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, mimicClaudeCode bool) (*http.Request, []byte, error) { - // 确定目标 URL - targetURL := claudeAPICountTokensURL - if account.Type == AccountTypeAPIKey { - baseURL := account.GetBaseURL() - if baseURL != "" { - validatedURL, err := s.validateUpstreamBaseURL(baseURL) - if err != nil { - return nil, nil, err - } - targetURL = validatedURL + "/v1/messages/count_tokens?beta=true" - } - } else if account.IsCustomBaseURLEnabled() { - customURL := account.GetCustomBaseURL() - if customURL == "" { - return nil, nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID) - } - validatedURL, err := s.validateUpstreamBaseURL(customURL) - if err != nil { - return nil, nil, err - } - targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages/count_tokens", account) - } - - clientHeaders := http.Header{} - if c != nil && c.Request != nil { - clientHeaders = c.Request.Header - } - - // OAuth 账号:应用统一指纹和重写 userID(受设置开关控制) - // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 - ctEnableFP, ctEnableMPT := true, false - if s.settingService != nil { - ctEnableFP, ctEnableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx) - } - var ctFingerprint *Fingerprint - if account.IsOAuth() && s.identityService != nil { - fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) - if err == nil { - ctFingerprint = fp - if !ctEnableMPT { - accountUUID := account.GetExtraString("account_uuid") - if accountUUID != "" && fp.ClientID != "" { - if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { - body = newBody - } - } - } - } - } - - // 同步 billing header cc_version 与实际发送的 User-Agent 版本 - if ctFingerprint != nil && ctEnableFP { - body = syncBillingHeaderVersion(body, ctFingerprint.UserAgent) - } - - // === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)=== - // 顺序约束同 buildUpstreamRequest。 - ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account, modelID)) - finalBetaHeader, finalBetaShouldSet := s.computeFinalCountTokensAnthropicBeta( - tokenType, mimicClaudeCode, modelID, clientHeaders, body, ctEffectiveDropSet, - ) - - // 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准 - if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok { - finalBetaHeader, finalBetaShouldSet = beta, true - } - - // 能力维度 body sanitize:与最终 anthropic-beta header 对称 - if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed { - body = sanitized - } - - body = sanitizeCountTokensRequestBody(body) - - req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) - if err != nil { - return nil, nil, err - } - - // 设置认证头(保持原始大小写) - if tokenType == "oauth" { - setHeaderRaw(req.Header, "authorization", "Bearer "+token) - } else { - setAnthropicAPIKeyAuthHeader(req.Header, account, token) - } - - // 白名单透传 headers(恢复真实 wire casing) - for key, values := range clientHeaders { - lowerKey := strings.ToLower(key) - if allowedHeaders[lowerKey] { - wireKey := resolveWireCasing(key) - for _, v := range values { - addHeaderRaw(req.Header, wireKey, v) - } - } - } - - // OAuth 账号:应用指纹到请求头(受设置开关控制) - if ctEnableFP && ctFingerprint != nil { - s.identityService.ApplyFingerprint(req, ctFingerprint) - } - - // 确保必要的 headers 存在(保持原始大小写) - if getHeaderRaw(req.Header, "content-type") == "" { - setHeaderRaw(req.Header, "content-type", "application/json") - } - if getHeaderRaw(req.Header, "anthropic-version") == "" { - setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") - } - if tokenType == "oauth" { - applyClaudeOAuthHeaderDefaults(req) - } - - // OAuth + mimic Claude Code:强制注入 CLI 指纹 header - if tokenType == "oauth" && mimicClaudeCode { - applyClaudeCodeMimicHeaders(req, false) - } - - // 写入最终 anthropic-beta header(Del 一次避免白名单透传值残留) - deleteHeaderAllForms(req.Header, "anthropic-beta") - if finalBetaShouldSet { - setHeaderRaw(req.Header, "anthropic-beta", finalBetaHeader) - } - - // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖 - if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" { - if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" { - if parsed := ParseMetadataUserID(uid); parsed != nil { - setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID) - } - } - } - - // 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op) - account.ApplyHeaderOverrides(req.Header) - - if c != nil && tokenType == "oauth" { - c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode)) - } - if s.debugClaudeMimicEnabled() { - logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode) - } - - return req, body, nil -} - -func sanitizeCountTokensRequestBody(body []byte) []byte { - out := body - for _, path := range []string{ - "temperature", - "top_p", - "top_k", - "stream", - "stop_sequences", - "stop", - } { - if gjson.GetBytes(out, path).Exists() { - if next, ok := deleteJSONPathBytes(out, path); ok { - out = next - } - } - } - return out -} - -// countTokensError 返回 count_tokens 错误响应 -func (s *GatewayService) countTokensError(c *gin.Context, status int, errType, message string) { - c.JSON(status, gin.H{ - "type": "error", - "error": gin.H{ - "type": errType, - "message": message, - }, - }) -} - -// buildCustomRelayURL 构建自定义中继转发 URL -// 在 path 后附加 beta=true 和可选的 proxy 查询参数 -func (s *GatewayService) buildCustomRelayURL(baseURL, path string, account *Account) string { - u := strings.TrimRight(baseURL, "/") + path + "?beta=true" - if account.ProxyID != nil && account.Proxy != nil { - proxyURL := account.Proxy.URL() - if proxyURL != "" { - u += "&proxy=" + url.QueryEscape(proxyURL) - } - } - return u -} - -func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) { - if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled { - normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) - if err != nil { - return "", fmt.Errorf("invalid base_url: %w", err) - } - return normalized, nil - } - normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{ - AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts, - RequireAllowlist: true, - AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts, - }) - if err != nil { - return "", fmt.Errorf("invalid base_url: %w", err) - } - return normalized, nil -} - // GetAvailableModels returns the list of models available for a group // It aggregates model_mapping keys from all schedulable accounts in the group func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, platform string) []string { @@ -7183,24 +1196,6 @@ func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform } } -// reconcileCachedTokens 兼容 Kimi 等上游: -// 将 OpenAI 风格的 cached_tokens 映射到 Claude 标准的 cache_read_input_tokens -func reconcileCachedTokens(usage map[string]any) bool { - if usage == nil { - return false - } - cacheRead, _ := usage["cache_read_input_tokens"].(float64) - if cacheRead > 0 { - return false // 已有标准字段,无需处理 - } - cached, _ := usage["cached_tokens"].(float64) - if cached <= 0 { - return false - } - usage["cache_read_input_tokens"] = cached - return true -} - const debugGatewayBodyDefaultFilename = "gateway_debug.log" // initDebugGatewayBodyFile 初始化网关调试日志文件。 diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go new file mode 100644 index 0000000000..ac13f0be59 --- /dev/null +++ b/backend/internal/service/gateway_upstream_request.go @@ -0,0 +1,923 @@ +package service + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" + "github.com/google/uuid" + "github.com/tidwall/gjson" + + "github.com/gin-gonic/gin" +) + +func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, []byte, error) { + if account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount { + req, err := s.buildUpstreamRequestAnthropicVertex(ctx, c, account, body, token, modelID, reqStream) + return req, body, err + } + + // 确定目标URL + targetURL := claudeAPIURL + if account.Type == AccountTypeAPIKey { + baseURL := account.GetBaseURL() + if baseURL != "" { + validatedURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return nil, nil, err + } + targetURL = validatedURL + "/v1/messages?beta=true" + } + } else if account.IsCustomBaseURLEnabled() { + customURL := account.GetCustomBaseURL() + if customURL == "" { + return nil, nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID) + } + validatedURL, err := s.validateUpstreamBaseURL(customURL) + if err != nil { + return nil, nil, err + } + targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages", account) + } + + clientHeaders := http.Header{} + if c != nil && c.Request != nil { + clientHeaders = c.Request.Header + } + + // OAuth账号:应用统一指纹和metadata重写(受设置开关控制) + var fingerprint *Fingerprint + enableFP, enableMPT := true, false + if s.settingService != nil { + enableFP, enableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx) + } + if account.IsOAuth() && s.identityService != nil { + // 1. 获取或创建指纹(包含随机生成的ClientID) + fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) + if err != nil { + logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err) + // 失败时降级为透传原始headers + } else { + if enableFP { + fingerprint = fp + } + + // 2. 重写metadata.user_id(需要指纹中的ClientID和账号的account_uuid) + // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 + // 当 metadata 透传开启时跳过重写 + if !enableMPT { + accountUUID := account.GetExtraString("account_uuid") + if accountUUID != "" && fp.ClientID != "" { + if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { + body = newBody + } + } + } + } + } + + // 同步 billing header cc_version 与实际发送的 User-Agent 版本 + if fingerprint != nil { + body = syncBillingHeaderVersion(body, fingerprint.UserAgent) + } + + // === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)=== + // + // 顺序约束: + // 1) 算 finalBeta(纯函数,不依赖 req.Header;mimicry 路径会忽略客户端 beta, + // 与原“OAuth + mimicClaudeCode 跳过白名单透传”行为对齐) + // 2) 按 finalBeta 做能力维度 body sanitize(如 context-management beta 缺失 → + // strip body.context_management,与 Bedrock 路径对称) + // 3) CCH 签名(必须使用 strip 后的 body,否则 hash 与最终 body 不一致 → + // 被 Anthropic 判 third-party) + // 4) NewRequest(body 至此最终敲定) + // 5) 透传白名单 / fingerprint / mimic header / 写入 finalBeta + policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID) + effectiveDropSet := mergeDropSets(policyFilterSet) + finalBetaHeader, finalBetaShouldSet := s.computeFinalAnthropicBeta( + tokenType, mimicClaudeCode, modelID, clientHeaders, body, effectiveDropSet, + ) + + // 账号覆写了 anthropic-beta 时,覆写值即最终上游值(由下方 ApplyHeaderOverrides 写入): + // body 能力净化必须以覆写值为准,否则 header/body 不对称会被上游 400。 + if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok { + finalBetaHeader, finalBetaShouldSet = beta, true + } + + // 能力维度 body sanitize:与最终 anthropic-beta header 对称 + if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed { + body = sanitized + } + + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + + // 设置认证头(保持原始大小写) + if tokenType == "oauth" { + setHeaderRaw(req.Header, "authorization", "Bearer "+token) + } else { + setAnthropicAPIKeyAuthHeader(req.Header, account, token) + } + + // 白名单透传 headers + // OAuth mimicry 路径:跳过客户端 header 透传,与 Parrot 对齐。 + // Parrot 的 build_upstream_headers 只发 9 个精确 header,不透传任何客户端 header。 + // 透传客户端 header 会引入不一致的 x-stainless-* / anthropic-beta / user-agent / + // x-claude-code-session-id 等值,和我们注入的伪装 header 冲突,被 Anthropic 判 third-party。 + if tokenType != "oauth" || !mimicClaudeCode { + for key, values := range clientHeaders { + lowerKey := strings.ToLower(key) + if allowedHeaders[lowerKey] { + wireKey := resolveWireCasing(key) + for _, v := range values { + addHeaderRaw(req.Header, wireKey, v) + } + } + } + } + + // OAuth账号:应用缓存的指纹到请求头(覆盖白名单透传的头) + if fingerprint != nil { + s.identityService.ApplyFingerprint(req, fingerprint) + } + + // 确保必要的headers存在(保持原始大小写) + if getHeaderRaw(req.Header, "content-type") == "" { + setHeaderRaw(req.Header, "content-type", "application/json") + } + if getHeaderRaw(req.Header, "anthropic-version") == "" { + setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") + } + if tokenType == "oauth" { + applyClaudeOAuthHeaderDefaults(req) + } + + // OAuth + mimic Claude Code:强制注入 CLI 指纹相关 header + // (user-agent/x-stainless-*/x-app/Accept/x-stainless-helper-method/x-client-request-id) + if tokenType == "oauth" && mimicClaudeCode { + applyClaudeCodeMimicHeaders(req, reqStream) + } + + // 写入最终 anthropic-beta header + // 注:透传分支白名单可能写入了客户端 anthropic-beta,无条件 Del 一次再按 finalBeta + // 决定是否 set,确保 dropSet 过滤后的结果一定覆盖客户端原始值。 + deleteHeaderAllForms(req.Header, "anthropic-beta") + if finalBetaShouldSet { + setHeaderRaw(req.Header, "anthropic-beta", finalBetaHeader) + } + + // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖 + if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" { + if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" { + if parsed := ParseMetadataUserID(uid); parsed != nil { + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID) + } + } + } + + // 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op)。 + // 放在所有 header 逻辑之后,确保配置值对同名头拥有最终决定权。 + account.ApplyHeaderOverrides(req.Header) + + // === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 === + s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{ + "url": req.URL.String(), + "token_type": tokenType, + "mimic_claude_code": strconv.FormatBool(mimicClaudeCode), + "fingerprint_applied": strconv.FormatBool(fingerprint != nil), + "enable_fp": strconv.FormatBool(enableFP), + "enable_mpt": strconv.FormatBool(enableMPT), + }) + + // Always capture a compact fingerprint line for later error diagnostics. + // We only print it when needed (or when the explicit debug flag is enabled). + if c != nil && tokenType == "oauth" { + c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode)) + } + if s.debugClaudeMimicEnabled() { + logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode) + } + + return req, body, nil +} + +// vertexSupportedBetaTokens 是 Vertex AI 的 Anthropic 端点接受的 anthropic-beta +// 白名单。Vertex 对任何未知 token 直接 HTTP 400,故采用白名单(与 Bedrock 的 +// bedrockSupportedBetaTokens 同思路)而非黑名单:未来 Claude Code 新增的、Vertex 尚未 +// 支持的 token 天然被剥离。当 Vertex 新增支持某 beta 时在此补充。 +// +// 明确排除(issue #3358 中 Vertex 报 400 的 token):advisor-tool-2026-03-01、 +// prompt-caching-scope-2026-01-05、redact-thinking-2026-02-12、 +// thinking-token-count-2026-05-13;以及 claude-code-20250219 / oauth-2025-04-20 等 +// 客户端身份 beta——Vertex service_account 走 Bearer 鉴权,不需要它们。 +var vertexSupportedBetaTokens = map[string]bool{ + "context-1m-2025-08-07": true, + "context-management-2025-06-27": true, + "fine-grained-tool-streaming-2025-05-14": true, + "interleaved-thinking-2025-05-14": true, +} + +// filterVertexBetaTokens 解析 client 的 anthropic-beta header,先剔除 drop 集合中的 +// token(BetaPolicy filter + 默认 drop),再只保留 Vertex 支持的 token,去重后逗号拼接。 +// 返回最终 header(可能为空字符串)。 +func filterVertexBetaTokens(header string, drop map[string]struct{}) string { + tokens := parseAnthropicBetaHeader(header) + if len(tokens) == 0 { + return "" + } + out := make([]string, 0, len(tokens)) + seen := make(map[string]bool, len(tokens)) + for _, t := range tokens { + if _, dropped := drop[t]; dropped { + continue + } + if !vertexSupportedBetaTokens[t] { + continue + } + if seen[t] { + continue + } + seen[t] = true + out = append(out, t) + } + return strings.Join(out, ",") +} + +func (s *GatewayService) buildUpstreamRequestAnthropicVertex( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + token string, + modelID string, + reqStream bool, +) (*http.Request, error) { + vertexBody, err := buildVertexAnthropicRequestBody(body) + if err != nil { + return nil, err + } + + // 计算最终 outgoing anthropic-beta。Vertex AI 的 Anthropic 端点只接受一小撮 + // beta token,未知 token 会直接 HTTP 400——近期 Claude Code CLI 透传的 + // advisor-tool-2026-03-01 / prompt-caching-scope-2026-01-05 / + // redact-thinking-2026-02-12 / thinking-token-count-2026-05-13 都不被 Vertex 接受 + // (issue #3358)。这里复用 BetaPolicy 的 block 检查(与 Bedrock 的 + // resolveBedrockBetaTokensForRequest 对称),再按 vertexSupportedBetaTokens 白名单 + // 剥离其余 token,使该路径与 Anthropic 直连 / Bedrock 路径行为一致。 + clientBeta := "" + if c != nil && c.Request != nil { + clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta") + } + policy := s.evaluateBetaPolicy(ctx, clientBeta, account, modelID) + if policy.blockErr != nil { + return nil, policy.blockErr + } + finalBeta := filterVertexBetaTokens(clientBeta, mergeDropSets(policy.filterSet)) + + // 能力维度 sanitize:基于最终 beta(而非原始 client 值)决定是否保留 body 中的 + // context_management,与 Anthropic 直连 / Bedrock 路径对称。 + if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(vertexBody, finalBeta); changed { + vertexBody = sanitized + } + fullURL, err := buildVertexAnthropicURL(account.VertexProjectID(), account.VertexLocation(modelID), modelID, reqStream) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(vertexBody)) + if err != nil { + return nil, err + } + + if c != nil && c.Request != nil { + for key, values := range c.Request.Header { + lowerKey := strings.ToLower(strings.TrimSpace(key)) + if !allowedHeaders[lowerKey] || lowerKey == "anthropic-version" { + continue + } + wireKey := resolveWireCasing(key) + for _, v := range values { + addHeaderRaw(req.Header, wireKey, v) + } + } + } + + req.Header.Del("authorization") + req.Header.Del("x-api-key") + req.Header.Del("x-goog-api-key") + req.Header.Del("cookie") + req.Header.Del("anthropic-version") + setHeaderRaw(req.Header, "authorization", "Bearer "+token) + setHeaderRaw(req.Header, "content-type", "application/json") + + // 覆盖上面白名单 loop 写入的原始 client anthropic-beta,使用过滤后的最终值。 + // finalBeta 为空(全部被剥离)时不下发该 header,与 Vertex 无 beta 请求一致。 + deleteHeaderAllForms(req.Header, "anthropic-beta") + if finalBeta != "" { + setHeaderRaw(req.Header, "anthropic-beta", finalBeta) + } + + s.debugLogGatewaySnapshot("UPSTREAM_FORWARD_VERTEX_ANTHROPIC", req.Header, vertexBody, map[string]string{ + "url": req.URL.String(), + "token_type": "service_account", + "model": modelID, + "stream": strconv.FormatBool(reqStream), + }) + + return req, nil +} + +// getBetaHeader 处理anthropic-beta header +// 对于OAuth账号,需要确保包含oauth-2025-04-20 +func (s *GatewayService) getBetaHeader(modelID string, clientBetaHeader string) string { + // 如果客户端传了anthropic-beta + if clientBetaHeader != "" { + // 已包含oauth beta则直接返回 + if strings.Contains(clientBetaHeader, claude.BetaOAuth) { + return clientBetaHeader + } + + // 需要添加oauth beta + parts := strings.Split(clientBetaHeader, ",") + for i, p := range parts { + parts[i] = strings.TrimSpace(p) + } + + // 在claude-code-20250219后面插入oauth beta + claudeCodeIdx := -1 + for i, p := range parts { + if p == claude.BetaClaudeCode { + claudeCodeIdx = i + break + } + } + + if claudeCodeIdx >= 0 { + // 在claude-code后面插入 + newParts := make([]string, 0, len(parts)+1) + newParts = append(newParts, parts[:claudeCodeIdx+1]...) + newParts = append(newParts, claude.BetaOAuth) + newParts = append(newParts, parts[claudeCodeIdx+1:]...) + return strings.Join(newParts, ",") + } + + // 没有claude-code,放在第一位 + return claude.BetaOAuth + "," + clientBetaHeader + } + + // 客户端没传,根据模型生成 + // haiku 模型不需要 claude-code beta + if strings.Contains(strings.ToLower(modelID), "haiku") { + return claude.HaikuBetaHeader + } + + return claude.DefaultBetaHeader +} + +func requestNeedsBetaFeatures(body []byte) bool { + tools := gjson.GetBytes(body, "tools") + if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 { + return true + } + thinkingType := gjson.GetBytes(body, "thinking.type").String() + if strings.EqualFold(thinkingType, "enabled") || strings.EqualFold(thinkingType, "adaptive") { + return true + } + return false +} + +func defaultAPIKeyBetaHeader(body []byte) string { + modelID := gjson.GetBytes(body, "model").String() + if strings.Contains(strings.ToLower(modelID), "haiku") { + return claude.APIKeyHaikuBetaHeader + } + return claude.APIKeyBetaHeader +} + +func applyClaudeOAuthHeaderDefaults(req *http.Request) { + if req == nil { + return + } + if getHeaderRaw(req.Header, "Accept") == "" { + setHeaderRaw(req.Header, "Accept", "application/json") + } + for key, value := range claude.DefaultHeaders { + if value == "" { + continue + } + if getHeaderRaw(req.Header, key) == "" { + setHeaderRaw(req.Header, resolveWireCasing(key), value) + } + } +} + +func mergeAnthropicBeta(required []string, incoming string) string { + seen := make(map[string]struct{}, len(required)+8) + out := make([]string, 0, len(required)+8) + + add := func(v string) { + v = strings.TrimSpace(v) + if v == "" { + return + } + if _, ok := seen[v]; ok { + return + } + seen[v] = struct{}{} + out = append(out, v) + } + + for _, r := range required { + add(r) + } + for _, p := range strings.Split(incoming, ",") { + add(p) + } + return strings.Join(out, ",") +} + +func mergeAnthropicBetaDropping(required []string, incoming string, drop map[string]struct{}) string { + merged := mergeAnthropicBeta(required, incoming) + if merged == "" || len(drop) == 0 { + return merged + } + out := make([]string, 0, 8) + for _, p := range strings.Split(merged, ",") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if _, ok := drop[p]; ok { + continue + } + out = append(out, p) + } + return strings.Join(out, ",") +} + +// computeFinalAnthropicBeta 计算发往上游的最终 anthropic-beta header 值。 +// +// 设计动机:将原本在 buildUpstreamRequest 内联在一起、依赖 req.Header 的 +// anthropic-beta 计算逻辑抽成纯函数。这样调用方可以在 NewRequest 之前 +// 就提前拿到最终 beta header,进而能按它对 body 做能力维度 sanitize 后再做 +// CCH 签名——一举修复了以下之前由顺序依赖导致的能力维度 sanitize +// 无法部署的问题(签名与最终 body 不一致可以被判 third-party)。 +// +// 返回 (value, shouldSet): +// - shouldSet=false 意为“不主动设置 anthropic-beta header”,与原代码“ +// API-key 账号 + 客户端未传 anthropic-beta + InjectBetaForAPIKey 未开启或 +// requestNeedsBetaFeatures=false”的行为对齐。 +// - shouldSet=true 时 value 可能为空字符串(例如客户端透传的 beta 被 dropSet +// 全部过滤掉),这与原代码中 setHeaderRaw 的结果一致。 +// +// clientHeaders 是客户端原始 HTTP header(通常为 c.Request.Header);nil 时按“客户端 +// 未传”处理。body 是已经 metadata 重写 / billing version sync 之后但未 sanitize 上游 +// 不兼容字段之前的版本。 +func (s *GatewayService) computeFinalAnthropicBeta( + tokenType string, + mimicClaudeCode bool, + modelID string, + clientHeaders http.Header, + body []byte, + effectiveDropSet map[string]struct{}, +) (string, bool) { + clientBeta := "" + if clientHeaders != nil { + clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta") + } + + if tokenType == "oauth" { + if mimicClaudeCode { + // mimic 路径:原代码跳过白名单透传,incomingBeta 总是空字符串。 + // 这里传空 string 以严格对齐原行为。 + requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking} + if !strings.Contains(strings.ToLower(modelID), "haiku") { + requiredBetas = claude.FullClaudeCodeMimicryBetas() + } + return mergeAnthropicBetaDropping(requiredBetas, "", effectiveDropSet), true + } + // 真 Claude Code 客户端透传路径 + return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true + } + + // API-key accounts + if clientBeta != "" { + return stripBetaTokensWithSet(clientBeta, effectiveDropSet), true + } + if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey { + if requestNeedsBetaFeatures(body) { + if beta := defaultAPIKeyBetaHeader(body); beta != "" { + return beta, true + } + } + } + return "", false +} + +// computeFinalCountTokensAnthropicBeta 是 count_tokens 路径上 anthropic-beta header 的 +// 计算纯函数。语义与 computeFinalAnthropicBeta 对齐,但备份了 count_tokens 独有的 +// 两条特殊规则: +// +// - OAuth mimic:requiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting +// (与 messages 不同的是:不按 haiku 排除;count_tokens 始终携带 token-counting beta) +// - OAuth 透传 + 客户端未传 anthropic-beta:补齐 CountTokensBetaHeader +// - OAuth 透传 + 客户端传了:补齐 BetaTokenCounting(如果未含) +// +// 返回语义同 computeFinalAnthropicBeta。 +func (s *GatewayService) computeFinalCountTokensAnthropicBeta( + tokenType string, + mimicClaudeCode bool, + modelID string, + clientHeaders http.Header, + body []byte, + effectiveDropSet map[string]struct{}, +) (string, bool) { + clientBeta := "" + if clientHeaders != nil { + clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta") + } + + if tokenType == "oauth" { + if mimicClaudeCode { + // 与原代码严格等价:original buildCountTokensRequest 在 count_tokens mimic + // 分支上**不**会跳过白名单透传(与 messages mimic 路径不同),所以 + // incomingBeta = req.Header[anthropic-beta] = 客户端透传过来的 client beta。 + // 重构后直接从 clientHeaders 拿同一个值,保持行为一致。 + requiredBetas := append(claude.FullClaudeCodeMimicryBetas(), claude.BetaTokenCounting) + return mergeAnthropicBetaDropping(requiredBetas, clientBeta, effectiveDropSet), true + } + if clientBeta == "" { + return claude.CountTokensBetaHeader, true + } + beta := s.getBetaHeader(modelID, clientBeta) + if !strings.Contains(beta, claude.BetaTokenCounting) { + beta = beta + "," + claude.BetaTokenCounting + } + return stripBetaTokensWithSet(beta, effectiveDropSet), true + } + + // API-key accounts + if clientBeta != "" { + return stripBetaTokensWithSet(clientBeta, effectiveDropSet), true + } + if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey { + if requestNeedsBetaFeatures(body) { + if beta := defaultAPIKeyBetaHeader(body); beta != "" { + return beta, true + } + } + } + return "", false +} + +// stripBetaTokens removes the given beta tokens from a comma-separated header value. +func stripBetaTokens(header string, tokens []string) string { + if header == "" || len(tokens) == 0 { + return header + } + return stripBetaTokensWithSet(header, buildBetaTokenSet(tokens)) +} + +func stripBetaTokensWithSet(header string, drop map[string]struct{}) string { + if header == "" || len(drop) == 0 { + return header + } + parts := strings.Split(header, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if _, ok := drop[p]; ok { + continue + } + out = append(out, p) + } + if len(out) == len(parts) { + return header // no change, avoid allocation + } + return strings.Join(out, ",") +} + +// BetaBlockedError indicates a request was blocked by a beta policy rule. +type BetaBlockedError struct { + Message string +} + +func (e *BetaBlockedError) Error() string { return e.Message } + +// betaPolicyResult holds the evaluated result of beta policy rules for a single request. +type betaPolicyResult struct { + blockErr *BetaBlockedError // non-nil if a block rule matched + filterSet map[string]struct{} // tokens to filter (may be nil) +} + +// evaluateBetaPolicy loads settings once and evaluates all rules against the given request. +func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader string, account *Account, model string) betaPolicyResult { + if s.settingService == nil { + return betaPolicyResult{} + } + settings, err := s.settingService.GetBetaPolicySettings(ctx) + if err != nil || settings == nil { + return betaPolicyResult{} + } + isOAuth := account.IsOAuth() + isBedrock := account.IsBedrock() + var result betaPolicyResult + for _, rule := range settings.Rules { + if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) { + continue + } + effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model) + switch effectiveAction { + case BetaPolicyActionBlock: + if result.blockErr == nil && betaHeader != "" && containsBetaToken(betaHeader, rule.BetaToken) { + msg := effectiveErrMsg + if msg == "" { + msg = "beta feature " + rule.BetaToken + " is not allowed" + } + result.blockErr = &BetaBlockedError{Message: msg} + } + case BetaPolicyActionFilter: + if result.filterSet == nil { + result.filterSet = make(map[string]struct{}) + } + result.filterSet[rule.BetaToken] = struct{}{} + } + } + return result +} + +// mergeDropSets merges the static defaultDroppedBetasSet with dynamic policy filter tokens. +// Returns defaultDroppedBetasSet directly when policySet is empty (zero allocation). +func mergeDropSets(policySet map[string]struct{}, extra ...string) map[string]struct{} { + if len(policySet) == 0 && len(extra) == 0 { + return defaultDroppedBetasSet + } + m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(policySet)+len(extra)) + for t := range defaultDroppedBetasSet { + m[t] = struct{}{} + } + for t := range policySet { + m[t] = struct{}{} + } + for _, t := range extra { + m[t] = struct{}{} + } + return m +} + +// betaPolicyFilterSetKey is the gin.Context key for caching the policy filter set within a request. +const betaPolicyFilterSetKey = "betaPolicyFilterSet" + +// getBetaPolicyFilterSet returns the beta policy filter set, using the gin context cache if available. +// In the /v1/messages path, Forward() evaluates the policy first and caches the result; +// buildUpstreamRequest reuses it (zero extra DB calls). In the count_tokens path, this +// evaluates on demand (one DB call). +func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Context, account *Account, model string) map[string]struct{} { + if c != nil { + if v, ok := c.Get(betaPolicyFilterSetKey); ok { + if fs, ok := v.(map[string]struct{}); ok { + return fs + } + } + } + return s.evaluateBetaPolicy(ctx, "", account, model).filterSet +} + +// betaPolicyScopeMatches checks whether a rule's scope matches the current account type. +func betaPolicyScopeMatches(scope string, isOAuth bool, isBedrock bool) bool { + switch scope { + case BetaPolicyScopeAll: + return true + case BetaPolicyScopeOAuth: + return isOAuth + case BetaPolicyScopeAPIKey: + return !isOAuth && !isBedrock + case BetaPolicyScopeBedrock: + return isBedrock + default: + return true // unknown scope → match all (fail-open) + } +} + +// matchModelWhitelist checks if a model matches any pattern in the whitelist. +// Reuses matchModelPattern from group.go which supports exact and wildcard prefix matching. +func matchModelWhitelist(model string, whitelist []string) bool { + for _, pattern := range whitelist { + if matchModelPattern(pattern, model) { + return true + } + } + return false +} + +// resolveRuleAction determines the effective action and error message for a rule given the request model. +// When ModelWhitelist is empty, the rule's primary Action/ErrorMessage applies unconditionally. +// When non-empty, Action applies to matching models; FallbackAction/FallbackErrorMessage applies to others. +func resolveRuleAction(rule BetaPolicyRule, model string) (action, errorMessage string) { + if len(rule.ModelWhitelist) == 0 { + return rule.Action, rule.ErrorMessage + } + if matchModelWhitelist(model, rule.ModelWhitelist) { + return rule.Action, rule.ErrorMessage + } + if rule.FallbackAction != "" { + return rule.FallbackAction, rule.FallbackErrorMessage + } + return BetaPolicyActionPass, "" // default fallback: pass (fail-open) +} + +// droppedBetaSet returns claude.DroppedBetas as a set, with optional extra tokens. +func droppedBetaSet(extra ...string) map[string]struct{} { + m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(extra)) + for t := range defaultDroppedBetasSet { + m[t] = struct{}{} + } + for _, t := range extra { + m[t] = struct{}{} + } + return m +} + +// containsBetaToken checks if a comma-separated header value contains the given token. +func containsBetaToken(header, token string) bool { + if header == "" || token == "" { + return false + } + for _, p := range strings.Split(header, ",") { + if strings.TrimSpace(p) == token { + return true + } + } + return false +} + +func filterBetaTokens(tokens []string, filterSet map[string]struct{}) []string { + if len(tokens) == 0 || len(filterSet) == 0 { + return tokens + } + kept := make([]string, 0, len(tokens)) + for _, token := range tokens { + if _, filtered := filterSet[token]; !filtered { + kept = append(kept, token) + } + } + return kept +} + +func (s *GatewayService) resolveBedrockBetaTokensForRequest( + ctx context.Context, + account *Account, + betaHeader string, + body []byte, + modelID string, +) ([]string, error) { + // 1. 对原始 header 中的 beta token 做 block 检查(快速失败) + policy := s.evaluateBetaPolicy(ctx, betaHeader, account, modelID) + if policy.blockErr != nil { + return nil, policy.blockErr + } + + // 2. 解析 header + body 自动注入 + Bedrock 转换/过滤 + betaTokens := ResolveBedrockBetaTokens(betaHeader, body, modelID) + + // 3. 对最终 token 列表再做 block 检查,捕获通过 body 自动注入绕过 header block 的情况。 + // 例如:管理员 block 了 interleaved-thinking,客户端不在 header 中带该 token, + // 但请求体中包含 thinking 字段 → autoInjectBedrockBetaTokens 会自动补齐 → + // 如果不做此检查,block 规则会被绕过。 + if blockErr := s.checkBetaPolicyBlockForTokens(ctx, betaTokens, account, modelID); blockErr != nil { + return nil, blockErr + } + + return filterBetaTokens(betaTokens, policy.filterSet), nil +} + +// checkBetaPolicyBlockForTokens 检查 token 列表中是否有被管理员 block 规则命中的 token。 +// 用于补充 evaluateBetaPolicy 对 header 的检查,覆盖 body 自动注入的 token。 +func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, tokens []string, account *Account, model string) *BetaBlockedError { + if s.settingService == nil || len(tokens) == 0 { + return nil + } + settings, err := s.settingService.GetBetaPolicySettings(ctx) + if err != nil || settings == nil { + return nil + } + isOAuth := account.IsOAuth() + isBedrock := account.IsBedrock() + tokenSet := buildBetaTokenSet(tokens) + for _, rule := range settings.Rules { + effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model) + if effectiveAction != BetaPolicyActionBlock { + continue + } + if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) { + continue + } + if _, present := tokenSet[rule.BetaToken]; present { + msg := effectiveErrMsg + if msg == "" { + msg = "beta feature " + rule.BetaToken + " is not allowed" + } + return &BetaBlockedError{Message: msg} + } + } + return nil +} + +func buildBetaTokenSet(tokens []string) map[string]struct{} { + m := make(map[string]struct{}, len(tokens)) + for _, t := range tokens { + if t == "" { + continue + } + m[t] = struct{}{} + } + return m +} + +var defaultDroppedBetasSet = buildBetaTokenSet(claude.DroppedBetas) + +// applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers. +// This mirrors opencode-anthropic-auth behavior: do not trust downstream +// headers when using Claude Code-scoped OAuth credentials. +func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { + if req == nil { + return + } + // Start with the standard defaults (fill missing). + applyClaudeOAuthHeaderDefaults(req) + // Then force key headers to match Claude Code fingerprint regardless of what the client sent. + // 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App") + for key, value := range claude.DefaultHeaders { + if value == "" { + continue + } + setHeaderRaw(req.Header, resolveWireCasing(key), value) + } + // Real Claude CLI uses Accept: application/json (even for streaming). + setHeaderRaw(req.Header, "Accept", "application/json") + if isStream { + setHeaderRaw(req.Header, "x-stainless-helper-method", "stream") + } + // Real Claude CLI 每个请求都会生成一个新的 UUID 放在 x-client-request-id。 + // 上游会以此作为会话/请求指纹的一部分,缺失或重复都可能触发第三方判定。 + if getHeaderRaw(req.Header, "x-client-request-id") == "" { + setHeaderRaw(req.Header, "x-client-request-id", uuid.NewString()) + } +} + +func truncateForLog(b []byte, maxBytes int) string { + if maxBytes <= 0 { + maxBytes = 2048 + } + if len(b) > maxBytes { + b = b[:maxBytes] + } + s := string(b) + // 保持一行,避免污染日志格式 + s = strings.ReplaceAll(s, "\n", "\\n") + s = strings.ReplaceAll(s, "\r", "\\r") + return s +} + +// buildCustomRelayURL 构建自定义中继转发 URL +// 在 path 后附加 beta=true 和可选的 proxy 查询参数 +func (s *GatewayService) buildCustomRelayURL(baseURL, path string, account *Account) string { + u := strings.TrimRight(baseURL, "/") + path + "?beta=true" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL := account.Proxy.URL() + if proxyURL != "" { + u += "&proxy=" + url.QueryEscape(proxyURL) + } + } + return u +} + +func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) { + if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled { + normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) + if err != nil { + return "", fmt.Errorf("invalid base_url: %w", err) + } + return normalized, nil + } + normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{ + AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts, + RequireAllowlist: true, + AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts, + }) + if err != nil { + return "", fmt.Errorf("invalid base_url: %w", err) + } + return normalized, nil +} diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go new file mode 100644 index 0000000000..7a66e12a9a --- /dev/null +++ b/backend/internal/service/gateway_upstream_response.go @@ -0,0 +1,1426 @@ +package service + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "sync/atomic" + "syscall" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + + "github.com/gin-gonic/gin" +) + +// isClaudeCodeClient 判断请求是否来自真正的 Claude Code 客户端。 +// 判定条件: +// 1. User-Agent 匹配 claude-cli/X.Y.Z(大小写不敏感) +// 2. metadata.user_id 符合 Claude Code 格式(legacy 或 JSON 格式) +// +// 只检查 metadata.user_id 非空不够严格:第三方工具(opencode 等)可能伪造 UA +// 并附带任意 metadata.user_id 字符串,从而绕过 mimicry。必须通过 ParseMetadataUserID +// 验证格式才能确认是真正的 Claude Code 客户端。 +func isClaudeCodeClient(userAgent string, metadataUserID string) bool { + if !claudeCliUserAgentRe.MatchString(userAgent) { + return false + } + return ParseMetadataUserID(metadataUserID) != nil +} + +func shouldUseClaudeCodeNoopDeltaKeepalive(userAgent string) bool { + version := ExtractCLIVersion(userAgent) + if version == "" { + return false + } + return CompareVersions(version, claudeCodeNoopDeltaKeepaliveMinVersion) >= 0 +} + +func claudeCodeKeepaliveDeltaTypeForContentBlock(blockType string) string { + switch blockType { + case "text": + return "text_delta" + case "tool_use": + return "input_json_delta" + case "thinking": + return "thinking_delta" + default: + return "" + } +} + +func claudeCodeKeepaliveFieldForDeltaType(deltaType string) string { + switch deltaType { + case "text_delta": + return "text" + case "input_json_delta": + return "partial_json" + case "thinking_delta": + return "thinking" + default: + return "" + } +} + +func buildClaudeCodeNoopDeltaKeepalive(index int, deltaType string) (string, bool) { + fieldName := claudeCodeKeepaliveFieldForDeltaType(deltaType) + if fieldName == "" { + return "", false + } + return fmt.Sprintf("event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"%s\",\"%s\":\"\"}}\n\n", index, deltaType, fieldName), true +} + +func sseEventIndex(event map[string]any) (int, bool) { + switch v := event["index"].(type) { + case float64: + return int(v), true + case int: + return v, true + case int64: + return int(v), true + case json.Number: + i, err := v.Int64() + if err != nil { + return 0, false + } + return int(i), true + default: + return 0, false + } +} + +// shouldRectifySignatureError 统一判断是否应触发签名整流(strip thinking blocks 并重试)。 +// 根据账号类型检查对应的开关和匹配模式。 +// +// mappedModel 用于按 thinking 协议族分流:passback-required (DeepSeek/Kimi/GLM 等) 上游 +// 的 400 不是签名缺失问题,retry 任何 thinking 变形都会破坏「原样回传」契约——直接透传 +// 错误给客户端。详见 thinking_protocol.go。 +func (s *GatewayService) shouldRectifySignatureError(ctx context.Context, account *Account, respBody []byte, mappedModel string) bool { + if !ShouldRectifyThinkingSignatureError(mappedModel) { + return false + } + if account.Type == AccountTypeAPIKey { + // API Key 账号:独立开关,一次读取配置 + settings, err := s.settingService.GetRectifierSettings(ctx) + if err != nil || !settings.Enabled || !settings.APIKeySignatureEnabled { + return false + } + // 先检查内置模式(同 OAuth),再检查自定义关键词 + if s.isThinkingBlockSignatureError(respBody) { + return true + } + return matchSignaturePatterns(respBody, settings.APIKeySignaturePatterns) + } + // OAuth/SetupToken/Upstream/Bedrock 等:保持原有行为(内置模式 + 原开关) + return s.isThinkingBlockSignatureError(respBody) && s.settingService.IsSignatureRectifierEnabled(ctx) +} + +// isSignatureErrorPattern 仅做模式匹配,不检查开关。 +// 用于已进入重试流程后的二阶段检测(此时开关已在首次调用时验证过)。 +func (s *GatewayService) isSignatureErrorPattern(ctx context.Context, account *Account, respBody []byte) bool { + if s.isThinkingBlockSignatureError(respBody) { + return true + } + if account.Type == AccountTypeAPIKey { + settings, err := s.settingService.GetRectifierSettings(ctx) + if err != nil { + return false + } + return matchSignaturePatterns(respBody, settings.APIKeySignaturePatterns) + } + return false +} + +// matchSignaturePatterns 检查响应体是否匹配自定义关键词列表(不区分大小写)。 +func matchSignaturePatterns(respBody []byte, patterns []string) bool { + if len(patterns) == 0 { + return false + } + bodyLower := strings.ToLower(string(respBody)) + for _, p := range patterns { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if strings.Contains(bodyLower, strings.ToLower(p)) { + return true + } + } + return false +} + +// isThinkingBlockSignatureError 检测是否是thinking block相关错误 +// 这类错误可以通过过滤thinking blocks并重试来解决 +func (s *GatewayService) isThinkingBlockSignatureError(respBody []byte) bool { + msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) + if msg == "" { + return false + } + + // 检测signature相关的错误(更宽松的匹配) + // 例如: "Invalid `signature` in `thinking` block", "***.signature" 等 + if strings.Contains(msg, "signature") { + return true + } + + // 检测 thinking block 顺序/类型错误 + // 例如: "Expected `thinking` or `redacted_thinking`, but found `text`" + if strings.Contains(msg, "expected") && (strings.Contains(msg, "thinking") || strings.Contains(msg, "redacted_thinking")) { + logger.LegacyPrintf("service.gateway", "[SignatureCheck] Detected thinking block type error") + return true + } + + // 检测 thinking block 被修改的错误 + // 例如: "thinking or redacted_thinking blocks in the latest assistant message cannot be modified" + if strings.Contains(msg, "cannot be modified") && (strings.Contains(msg, "thinking") || strings.Contains(msg, "redacted_thinking")) { + logger.LegacyPrintf("service.gateway", "[SignatureCheck] Detected thinking block modification error") + return true + } + + // 检测空消息内容错误(可能是过滤 thinking blocks 后导致的,或客户端发送了空 text block) + // 例如: "all messages must have non-empty content" + // "messages: text content blocks must be non-empty" + if strings.Contains(msg, "non-empty content") || strings.Contains(msg, "empty content") || + strings.Contains(msg, "content blocks must be non-empty") { + logger.LegacyPrintf("service.gateway", "[SignatureCheck] Detected empty content error") + return true + } + + // 检测 thinking block 缺少 thinking 字段的错误(跨模型切换时常见: + // 其他模型回过的 assistant 历史里有 type=thinking 但没有 thinking 文本, + // 喂给开启 extended thinking 的 claude 时会被拒) + // 例如: "messages.1.content.0.thinking: each thinking block must contain thinking" + if strings.Contains(msg, "thinking block must contain") { + logger.LegacyPrintf("service.gateway", "[SignatureCheck] Detected thinking block missing content error") + return true + } + + return false +} + +func (s *GatewayService) shouldFailoverOn400(respBody []byte) bool { + // 只对"可能是兼容性差异导致"的 400 允许切换,避免无意义重试。 + // 默认保守:无法识别则不切换。 + msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) + if msg == "" { + return false + } + + // 缺少/错误的 beta header:换账号/链路可能成功(尤其是混合调度时)。 + // 更精确匹配 beta 相关的兼容性问题,避免误触发切换。 + if strings.Contains(msg, "anthropic-beta") || + strings.Contains(msg, "beta feature") || + strings.Contains(msg, "requires beta") { + return true + } + + // thinking/tool streaming 等兼容性约束(常见于中间转换链路) + if strings.Contains(msg, "thinking") || strings.Contains(msg, "thought_signature") || strings.Contains(msg, "signature") { + return true + } + if strings.Contains(msg, "tool_use") || strings.Contains(msg, "tool_result") || strings.Contains(msg, "tools") { + return true + } + + return false +} + +// sanitizeStreamError 返回不含网络地址的客户端可见错误描述。 +// 默认 (*net.OpError).Error() 会拼接 Source/Addr 字段,泄露内部 IP/端口与上游 +// 服务器地址(例如 "read tcp 10.0.0.1:54321->52.1.2.3:443: read: connection +// reset by peer")。该函数只保留可识别的错误类别,原始 err 仍在调用点写入日志。 +func sanitizeStreamError(err error) string { + if err == nil { + return "" + } + switch { + case errors.Is(err, io.ErrUnexpectedEOF): + return "unexpected EOF" + case errors.Is(err, io.EOF): + return "EOF" + case errors.Is(err, context.Canceled): + return "canceled" + case errors.Is(err, context.DeadlineExceeded): + return "deadline exceeded" + case errors.Is(err, syscall.ECONNRESET): + return "connection reset by peer" + case errors.Is(err, syscall.ECONNABORTED): + return "connection aborted" + case errors.Is(err, syscall.ETIMEDOUT): + return "connection timed out" + case errors.Is(err, syscall.EPIPE): + return "broken pipe" + case errors.Is(err, syscall.ECONNREFUSED): + return "connection refused" + } + var netErr *net.OpError + if errors.As(err, &netErr) { + if netErr.Timeout() { + if netErr.Op != "" { + return netErr.Op + " timeout" + } + return "i/o timeout" + } + if netErr.Op != "" { + return netErr.Op + " network error" + } + } + return "upstream connection error" +} + +// ExtractUpstreamErrorMessage 从上游响应体中提取错误消息 +// 支持 Claude 风格的错误格式:{"type":"error","error":{"type":"...","message":"..."}} +func ExtractUpstreamErrorMessage(body []byte) string { + return extractUpstreamErrorMessage(body) +} + +func extractUpstreamErrorMessage(body []byte) string { + // Claude 风格:{"type":"error","error":{"type":"...","message":"..."}} + if m := gjson.GetBytes(body, "error.message").String(); strings.TrimSpace(m) != "" { + inner := strings.TrimSpace(m) + // 有些上游会把完整 JSON 作为字符串塞进 message + if strings.HasPrefix(inner, "{") { + if innerMsg := gjson.Get(inner, "error.message").String(); strings.TrimSpace(innerMsg) != "" { + return innerMsg + } + } + return m + } + + // ChatGPT 内部 API 风格:{"detail":"..."} + if d := gjson.GetBytes(body, "detail").String(); strings.TrimSpace(d) != "" { + return d + } + + // 兜底:尝试顶层 message + return gjson.GetBytes(body, "message").String() +} + +func extractUpstreamErrorCode(body []byte) string { + if code := strings.TrimSpace(gjson.GetBytes(body, "error.code").String()); code != "" { + return code + } + + inner := strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) + if !strings.HasPrefix(inner, "{") { + return "" + } + + if code := strings.TrimSpace(gjson.Get(inner, "error.code").String()); code != "" { + return code + } + + if lastBrace := strings.LastIndex(inner, "}"); lastBrace >= 0 { + if code := strings.TrimSpace(gjson.Get(inner[:lastBrace+1], "error.code").String()); code != "" { + return code + } + } + + return "" +} + +func isCountTokensUnsupported404(statusCode int, body []byte) bool { + if statusCode != http.StatusNotFound { + return false + } + msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(body))) + if msg == "" { + return false + } + if strings.Contains(msg, "/v1/messages/count_tokens") { + return true + } + return strings.Contains(msg, "count_tokens") && strings.Contains(msg, "not found") +} + +func (s *GatewayService) readUpstreamErrorBody(resp *http.Response) ([]byte, error) { + if resp == nil || resp.Body == nil { + return nil, nil + } + limit := gatewayUpstreamErrorBodyReadLimit + if s != nil && s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody && s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes > int(limit) { + limit = int64(s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes) + } + return io.ReadAll(io.LimitReader(resp.Body, limit)) +} + +func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, requestedModel ...string) (*ForwardResult, error) { + body, _ := s.readUpstreamErrorBody(resp) + + // 调试日志:打印上游错误响应 + logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (non-retryable): Account=%d(%s) Status=%d RequestID=%s Body=%s", + account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(body), 1000)) + + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + + // Print a compact upstream request fingerprint when we hit the Claude Code OAuth + // credential scope error. This avoids requiring env-var tweaks in a fixed deploy. + if isClaudeCodeCredentialScopeError(upstreamMsg) && c != nil { + if v, ok := c.Get(claudeMimicDebugInfoKey); ok { + if line, ok := v.(string); ok && strings.TrimSpace(line) != "" { + logger.LegacyPrintf("service.gateway", "[ClaudeMimicDebugOnError] status=%d request_id=%s %s", + resp.StatusCode, + resp.Header.Get("x-request-id"), + line, + ) + } + } + } + + // Enrich Ops error logs with upstream status + message, and optionally a truncated body snippet. + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(body), maxBytes) + } + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + + // 处理上游错误,标记账号状态 + shouldDisable := false + if s.rateLimitService != nil { + if len(requestedModel) > 0 { + shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, requestedModel[0]) + } else { + shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body) + } + } + if shouldDisable { + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: body} + } + + MarkResponseCommitted(c) + + // 记录上游错误响应体摘要便于排障(可选:由配置控制;不回显到客户端) + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + logger.LegacyPrintf("service.gateway", + "Upstream error %d (account=%d platform=%s type=%s): %s", + resp.StatusCode, + account.ID, + account.Platform, + account.Type, + truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), + ) + } + + // 非 failover 错误也支持错误透传规则匹配。 + if status, errType, errMsg, matched := applyErrorPassthroughRule( + c, + account.Platform, + resp.StatusCode, + body, + http.StatusBadGateway, + "upstream_error", + "Upstream request failed", + ); matched { + c.JSON(status, gin.H{ + "type": "error", + "error": gin.H{ + "type": errType, + "message": errMsg, + }, + }) + + summary := upstreamMsg + if summary == "" { + summary = errMsg + } + if summary == "" { + return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, summary) + } + + // 根据状态码返回适当的自定义错误响应(不透传上游详细信息) + var errType, errMsg string + var statusCode int + + switch resp.StatusCode { + case 400: + c.Data(http.StatusBadRequest, "application/json", body) + summary := upstreamMsg + if summary == "" { + summary = truncateForLog(body, 512) + } + if summary == "" { + return nil, fmt.Errorf("upstream error: %d", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, summary) + case 401: + statusCode = http.StatusBadGateway + errType = "upstream_error" + errMsg = "Upstream authentication failed, please contact administrator" + case 403: + statusCode = http.StatusBadGateway + errType = "upstream_error" + errMsg = "Upstream access forbidden, please contact administrator" + case 429: + statusCode = http.StatusTooManyRequests + errType = "rate_limit_error" + errMsg = "Upstream rate limit exceeded, please retry later" + case 529: + statusCode = http.StatusServiceUnavailable + errType = "overloaded_error" + errMsg = "Upstream service overloaded, please retry later" + case 500, 502, 503, 504: + statusCode = http.StatusBadGateway + errType = "upstream_error" + errMsg = "Upstream service temporarily unavailable" + default: + statusCode = http.StatusBadGateway + errType = "upstream_error" + errMsg = "Upstream request failed" + } + + // 返回自定义错误响应 + c.JSON(statusCode, gin.H{ + "type": "error", + "error": gin.H{ + "type": errType, + "message": errMsg, + }, + }) + + if upstreamMsg == "" { + return nil, fmt.Errorf("upstream error: %d", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg) +} + +func (s *GatewayService) handleRetryExhaustedSideEffects(ctx context.Context, resp *http.Response, account *Account) { + body, _ := s.readUpstreamErrorBody(resp) + statusCode := resp.StatusCode + + // OAuth/Setup Token 账号的 403:标记账号异常 + if account.IsOAuth() && statusCode == 403 { + s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body) + logger.LegacyPrintf("service.gateway", "Account %d: marked as error after %d retries for status %d", account.ID, maxRetryAttempts, statusCode) + } else { + // API Key 未配置错误码:不标记账号状态 + logger.LegacyPrintf("service.gateway", "Account %d: upstream error %d after %d retries (not marking account)", account.ID, statusCode, maxRetryAttempts) + } +} + +func (s *GatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, requestedModel ...string) { + body, _ := s.readUpstreamErrorBody(resp) + if len(requestedModel) > 0 { + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, requestedModel[0]) + return + } + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body) +} + +// handleRetryExhaustedError 处理重试耗尽后的错误 +// OAuth 403:标记账号异常 +// API Key 未配置错误码:仅返回错误,不标记账号 +func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) { + MarkResponseCommitted(c) + // Capture upstream error body before side-effects consume the stream. + respBody, _ := s.readUpstreamErrorBody(resp) + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + + s.handleRetryExhaustedSideEffects(ctx, resp, account) + + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + + if isClaudeCodeCredentialScopeError(upstreamMsg) && c != nil { + if v, ok := c.Get(claudeMimicDebugInfoKey); ok { + if line, ok := v.(string); ok && strings.TrimSpace(line) != "" { + logger.LegacyPrintf("service.gateway", "[ClaudeMimicDebugOnError] status=%d request_id=%s %s", + resp.StatusCode, + resp.Header.Get("x-request-id"), + line, + ) + } + } + } + + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(respBody), maxBytes) + } + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "retry_exhausted", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + logger.LegacyPrintf("service.gateway", + "Upstream error %d retries_exhausted (account=%d platform=%s type=%s): %s", + resp.StatusCode, + account.ID, + account.Platform, + account.Type, + truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), + ) + } + + if status, errType, errMsg, matched := applyErrorPassthroughRule( + c, + account.Platform, + resp.StatusCode, + respBody, + http.StatusBadGateway, + "upstream_error", + "Upstream request failed after retries", + ); matched { + c.JSON(status, gin.H{ + "type": "error", + "error": gin.H{ + "type": errType, + "message": errMsg, + }, + }) + + summary := upstreamMsg + if summary == "" { + summary = errMsg + } + if summary == "" { + return nil, fmt.Errorf("upstream error: %d (retries exhausted, passthrough rule matched)", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d (retries exhausted, passthrough rule matched) message=%s", resp.StatusCode, summary) + } + + // 返回统一的重试耗尽错误响应 + c.JSON(http.StatusBadGateway, gin.H{ + "type": "error", + "error": gin.H{ + "type": "upstream_error", + "message": "Upstream request failed after retries", + }, + }) + + if upstreamMsg == "" { + return nil, fmt.Errorf("upstream error: %d (retries exhausted)", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d (retries exhausted) message=%s", resp.StatusCode, upstreamMsg) +} + +// streamingResult 流式响应结果 +type streamingResult struct { + usage *ClaudeUsage + firstTokenMs *int + clientDisconnect bool // 客户端是否在流式传输过程中断开 +} + +func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string, mimicClaudeCode bool) (*streamingResult, error) { + // 更新5h窗口状态 + s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) + + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + } + + // 设置SSE响应头 + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + // 透传其他响应头 + if v := resp.Header.Get("x-request-id"); v != "" { + c.Header("x-request-id", v) + } + + w := c.Writer + flusher, ok := w.(http.Flusher) + if !ok { + return nil, errors.New("streaming not supported") + } + + usage := &ClaudeUsage{} + var firstTokenMs *int + scanner := bufio.NewScanner(resp.Body) + // 设置更大的buffer以处理长行 + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanBuf := getSSEScannerBuf64K() + scanner.Buffer(scanBuf[:0], maxLineSize) + + type scanEvent struct { + line string + err error + } + // 独立 goroutine 读取上游,避免读取阻塞导致超时/keepalive无法处理 + events := make(chan scanEvent, 16) + done := make(chan struct{}) + sendEvent := func(ev scanEvent) bool { + select { + case events <- ev: + return true + case <-done: + return false + } + } + var lastReadAt int64 + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + go func(scanBuf *sseScannerBuf64K) { + defer putSSEScannerBuf64K(scanBuf) + defer close(events) + for scanner.Scan() { + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + if !sendEvent(scanEvent{line: scanner.Text()}) { + return + } + } + if err := scanner.Err(); err != nil { + _ = sendEvent(scanEvent{err: err}) + } + }(scanBuf) + defer close(done) + + streamInterval := time.Duration(0) + if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 { + streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second + } + // 仅监控上游数据间隔超时,避免下游写入阻塞导致误判 + var intervalTicker *time.Ticker + if streamInterval > 0 { + intervalTicker = time.NewTicker(streamInterval) + defer intervalTicker.Stop() + } + var intervalCh <-chan time.Time + if intervalTicker != nil { + intervalCh = intervalTicker.C + } + + // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 + keepaliveInterval := time.Duration(0) + if s.cfg != nil && s.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + var keepaliveTimer *time.Timer + if keepaliveInterval > 0 { + keepaliveTimer = time.NewTimer(keepaliveInterval) + defer keepaliveTimer.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTimer != nil { + keepaliveCh = keepaliveTimer.C + } + lastDataAt := time.Now() + resetKeepaliveTimer := func() { + if keepaliveTimer == nil { + return + } + if !keepaliveTimer.Stop() { + select { + case <-keepaliveTimer.C: + default: + } + } + keepaliveTimer.Reset(keepaliveInterval) + } + + // 仅发送一次错误事件,避免多次写入导致协议混乱(写失败时尽力通知客户端)。 + // 事件格式遵循 Anthropic SSE 标准:{"type":"error","error":{"type":,"message":}} + // 这样 Anthropic SDK / Claude Code 等客户端能按标准 error 类型解析,UI 能显示具体错误文案, + // 服务端 ExtractUpstreamErrorMessage 也能从透传的 body 中提取 message。 + errorEventSent := false + sendErrorEvent := func(reason, message string) { + if errorEventSent { + return + } + errorEventSent = true + if message == "" { + message = reason + } + body, err := json.Marshal(map[string]any{ + "type": "error", + "error": map[string]string{ + "type": reason, + "message": message, + }, + }) + if err != nil { + // json.Marshal 不可能在已知 string-only 输入上失败,保守 fallback + body = []byte(fmt.Sprintf(`{"type":"error","error":{"type":%q,"message":%q}}`, reason, message)) + } + _, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", body) + flusher.Flush() + } + + needModelReplace := originalModel != mappedModel + clientDisconnected := false // 客户端断开标志,断开后继续读取上游以获取完整usage + sawTerminalEvent := false + useNoopDeltaKeepalive := c != nil && c.Request != nil && shouldUseClaudeCodeNoopDeltaKeepalive(c.GetHeader("User-Agent")) + noopDeltaKeepaliveBlockIndex := -1 + noopDeltaKeepaliveDeltaType := "" + + pendingEventLines := make([]string, 0, 4) + + processSSEEvent := func(lines []string) ([]string, string, *sseUsagePatch, error) { + if len(lines) == 0 { + return nil, "", nil, nil + } + + eventName := "" + dataLine := "" + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "event:") { + eventName = strings.TrimSpace(strings.TrimPrefix(trimmed, "event:")) + continue + } + if dataLine == "" && sseDataRe.MatchString(trimmed) { + dataLine = sseDataRe.ReplaceAllString(trimmed, "") + } + } + + if eventName == "error" { + return nil, dataLine, nil, &sseStreamErrorEventError{RawData: dataLine} + } + + if dataLine == "" { + return []string{strings.Join(lines, "\n") + "\n\n"}, "", nil, nil + } + + if dataLine == "[DONE]" { + sawTerminalEvent = true + block := "" + if eventName != "" { + block = "event: " + eventName + "\n" + } + block += "data: " + dataLine + "\n\n" + return []string{block}, dataLine, nil, nil + } + + var event map[string]any + if err := json.Unmarshal([]byte(dataLine), &event); err != nil { + // JSON 解析失败,直接透传原始数据 + block := "" + if eventName != "" { + block = "event: " + eventName + "\n" + } + block += "data: " + dataLine + "\n\n" + return []string{block}, dataLine, nil, nil + } + + eventType, _ := event["type"].(string) + if eventName == "" { + eventName = eventType + } + eventChanged := false + + if useNoopDeltaKeepalive { + switch eventType { + case "content_block_start": + if idx, ok := sseEventIndex(event); ok { + noopDeltaKeepaliveBlockIndex = -1 + noopDeltaKeepaliveDeltaType = "" + if contentBlock, ok := event["content_block"].(map[string]any); ok { + blockType, _ := contentBlock["type"].(string) + if deltaType := claudeCodeKeepaliveDeltaTypeForContentBlock(blockType); deltaType != "" { + noopDeltaKeepaliveBlockIndex = idx + noopDeltaKeepaliveDeltaType = deltaType + } + } + } + case "content_block_delta": + if idx, ok := sseEventIndex(event); ok { + if delta, ok := event["delta"].(map[string]any); ok { + deltaType, _ := delta["type"].(string) + if claudeCodeKeepaliveFieldForDeltaType(deltaType) != "" { + noopDeltaKeepaliveBlockIndex = idx + noopDeltaKeepaliveDeltaType = deltaType + } + } + } + case "content_block_stop": + if idx, ok := sseEventIndex(event); ok && idx == noopDeltaKeepaliveBlockIndex { + noopDeltaKeepaliveBlockIndex = -1 + noopDeltaKeepaliveDeltaType = "" + } + case "message_stop": + noopDeltaKeepaliveBlockIndex = -1 + noopDeltaKeepaliveDeltaType = "" + } + } + + // 兼容 Kimi cached_tokens → cache_read_input_tokens + if eventType == "message_start" { + if msg, ok := event["message"].(map[string]any); ok { + if u, ok := msg["usage"].(map[string]any); ok { + eventChanged = reconcileCachedTokens(u) || eventChanged + } + } + } + if eventType == "message_delta" { + if u, ok := event["usage"].(map[string]any); ok { + eventChanged = reconcileCachedTokens(u) || eventChanged + } + } + + // Cache TTL Override: 重写 SSE 事件中的 cache_creation 分类。 + // 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。 + if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok { + if eventType == "message_start" { + if msg, ok := event["message"].(map[string]any); ok { + if u, ok := msg["usage"].(map[string]any); ok { + eventChanged = rewriteCacheCreationJSON(u, overrideTarget) || eventChanged + } + } + } + if eventType == "message_delta" { + if u, ok := event["usage"].(map[string]any); ok { + eventChanged = rewriteCacheCreationJSON(u, overrideTarget) || eventChanged + } + } + } + + if needModelReplace { + if msg, ok := event["message"].(map[string]any); ok { + if model, ok := msg["model"].(string); ok && model == mappedModel { + msg["model"] = originalModel + eventChanged = true + } + } + } + + usagePatch := s.extractSSEUsagePatch(event) + if anthropicStreamEventIsTerminal(eventName, dataLine) { + sawTerminalEvent = true + } + if !eventChanged { + block := "" + if eventName != "" { + block = "event: " + eventName + "\n" + } + block += "data: " + dataLine + "\n\n" + return []string{block}, dataLine, usagePatch, nil + } + + newData, err := json.Marshal(event) + if err != nil { + // 序列化失败,直接透传原始数据 + block := "" + if eventName != "" { + block = "event: " + eventName + "\n" + } + block += "data: " + dataLine + "\n\n" + return []string{block}, dataLine, usagePatch, nil + } + + block := "" + if eventName != "" { + block = "event: " + eventName + "\n" + } + block += "data: " + string(newData) + "\n\n" + return []string{block}, string(newData), usagePatch, nil + } + + for { + select { + case ev, ok := <-events: + if !ok { + // 上游完成,返回结果 + if !sawTerminalEvent { + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, fmt.Errorf("stream usage incomplete: missing terminal event") + } + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, nil + } + if ev.err != nil { + if sawTerminalEvent { + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, nil + } + // 检测 context 取消(客户端断开会导致 context 取消,进而影响上游读取) + if errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded) { + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, fmt.Errorf("stream usage incomplete: %w", ev.err) + } + // 客户端已通过写入失败检测到断开,上游也出错了,返回已收集的 usage + if clientDisconnected { + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, fmt.Errorf("stream usage incomplete after disconnect: %w", ev.err) + } + // 客户端未断开,正常的错误处理 + if errors.Is(ev.err, bufio.ErrTooLong) { + logger.LegacyPrintf("service.gateway", "SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, ev.err) + sendErrorEvent("response_too_large", fmt.Sprintf("upstream SSE line exceeded %d bytes", maxLineSize)) + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, ev.err + } + // 上游中途读错误(unexpected EOF / connection reset 等,常见于 HTTP/2 GOAWAY): + // 若尚未向客户端写过任何字节,包成 UpstreamFailoverError 让 handler 层走 failover/重试。 + // 已经开始写流时 SSE 协议无 resume,只能透传错误事件给客户端。 + // 注意:面向客户端的 disconnectMsg 必须用 sanitizeStreamError 剥离地址, + // 默认 *net.OpError 的 Error() 会泄露内部 IP/端口和上游地址。完整 ev.err + // 仅在下方 LegacyPrintf 内部日志中保留供运维诊断。 + disconnectMsg := "upstream stream disconnected: " + sanitizeStreamError(ev.err) + if !c.Writer.Written() { + logger.LegacyPrintf("service.gateway", "Upstream stream read error before any client output (account=%d), failing over: %v", account.ID, ev.err) + body, _ := json.Marshal(map[string]any{ + "type": "error", + "error": map[string]string{ + "type": "upstream_disconnected", + "message": disconnectMsg, + }, + }) + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: body, + RetryableOnSameAccount: true, + } + } + sendErrorEvent("stream_read_error", disconnectMsg) + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream read error: %w", ev.err) + } + line := ev.line + trimmed := strings.TrimSpace(line) + + if trimmed == "" { + if len(pendingEventLines) == 0 { + continue + } + + outputBlocks, data, usagePatch, err := processSSEEvent(pendingEventLines) + pendingEventLines = pendingEventLines[:0] + if err != nil { + if clientDisconnected { + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil + } + return nil, err + } + + for _, block := range outputBlocks { + if !clientDisconnected { + restored := reverseToolNamesIfPresent(c, []byte(block)) + if _, werr := fmt.Fprint(w, string(restored)); werr != nil { + clientDisconnected = true + logger.LegacyPrintf("service.gateway", "Client disconnected during streaming, continuing to drain upstream for billing") + break + } + flusher.Flush() + lastDataAt = time.Now() + resetKeepaliveTimer() + } + if data != "" { + if firstTokenMs == nil && data != "[DONE]" { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + if usagePatch != nil { + mergeSSEUsagePatch(usage, usagePatch) + } + } + } + continue + } + + pendingEventLines = append(pendingEventLines, line) + + case <-intervalCh: + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) < streamInterval { + continue + } + if clientDisconnected { + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, fmt.Errorf("stream usage incomplete after timeout") + } + logger.LegacyPrintf("service.gateway", "Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval) + // 处理流超时,可能标记账户为临时不可调度或错误状态 + if s.rateLimitService != nil { + s.rateLimitService.HandleStreamTimeout(ctx, account, originalModel) + } + sendErrorEvent("stream_timeout", fmt.Sprintf("upstream stream idle for %s", streamInterval)) + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout") + + case <-keepaliveCh: + if clientDisconnected { + continue + } + if time.Since(lastDataAt) < keepaliveInterval { + resetKeepaliveTimer() + continue + } + keepaliveBlock := "event: ping\ndata: {\"type\": \"ping\"}\n\n" + if useNoopDeltaKeepalive && noopDeltaKeepaliveBlockIndex >= 0 { + if block, ok := buildClaudeCodeNoopDeltaKeepalive(noopDeltaKeepaliveBlockIndex, noopDeltaKeepaliveDeltaType); ok { + keepaliveBlock = block + } + } + if _, werr := fmt.Fprint(w, keepaliveBlock); werr != nil { + clientDisconnected = true + logger.LegacyPrintf("service.gateway", "Client disconnected during keepalive ping, continuing to drain upstream for billing") + continue + } + flusher.Flush() + lastDataAt = time.Now() + resetKeepaliveTimer() + } + } + +} + +func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) { + if usage == nil { + return + } + + var event map[string]any + if err := json.Unmarshal([]byte(data), &event); err != nil { + return + } + + if patch := s.extractSSEUsagePatch(event); patch != nil { + mergeSSEUsagePatch(usage, patch) + } +} + +type sseUsagePatch struct { + inputTokens int + hasInputTokens bool + outputTokens int + hasOutputTokens bool + cacheCreationInputTokens int + hasCacheCreationInput bool + cacheReadInputTokens int + hasCacheReadInput bool + cacheCreation5mTokens int + hasCacheCreation5m bool + cacheCreation1hTokens int + hasCacheCreation1h bool +} + +func (s *GatewayService) extractSSEUsagePatch(event map[string]any) *sseUsagePatch { + if len(event) == 0 { + return nil + } + + eventType, _ := event["type"].(string) + switch eventType { + case "message_start": + msg, _ := event["message"].(map[string]any) + usageObj, _ := msg["usage"].(map[string]any) + if len(usageObj) == 0 { + return nil + } + + patch := &sseUsagePatch{} + patch.hasInputTokens = true + if v, ok := parseSSEUsageInt(usageObj["input_tokens"]); ok { + patch.inputTokens = v + } + patch.hasCacheCreationInput = true + if v, ok := parseSSEUsageInt(usageObj["cache_creation_input_tokens"]); ok { + patch.cacheCreationInputTokens = v + } + patch.hasCacheReadInput = true + if v, ok := parseSSEUsageInt(usageObj["cache_read_input_tokens"]); ok { + patch.cacheReadInputTokens = v + } + if cc, ok := usageObj["cache_creation"].(map[string]any); ok { + if v, exists := parseSSEUsageInt(cc["ephemeral_5m_input_tokens"]); exists { + patch.cacheCreation5mTokens = v + patch.hasCacheCreation5m = true + } + if v, exists := parseSSEUsageInt(cc["ephemeral_1h_input_tokens"]); exists { + patch.cacheCreation1hTokens = v + patch.hasCacheCreation1h = true + } + } + return patch + + case "message_delta": + usageObj, _ := event["usage"].(map[string]any) + if len(usageObj) == 0 { + return nil + } + + patch := &sseUsagePatch{} + if v, ok := parseSSEUsageInt(usageObj["input_tokens"]); ok && v > 0 { + patch.inputTokens = v + patch.hasInputTokens = true + } + if v, ok := parseSSEUsageInt(usageObj["output_tokens"]); ok && v > 0 { + patch.outputTokens = v + patch.hasOutputTokens = true + } + if v, ok := parseSSEUsageInt(usageObj["cache_creation_input_tokens"]); ok && v > 0 { + patch.cacheCreationInputTokens = v + patch.hasCacheCreationInput = true + } + if v, ok := parseSSEUsageInt(usageObj["cache_read_input_tokens"]); ok && v > 0 { + patch.cacheReadInputTokens = v + patch.hasCacheReadInput = true + } + if cc, ok := usageObj["cache_creation"].(map[string]any); ok { + if v, exists := parseSSEUsageInt(cc["ephemeral_5m_input_tokens"]); exists && v > 0 { + patch.cacheCreation5mTokens = v + patch.hasCacheCreation5m = true + } + if v, exists := parseSSEUsageInt(cc["ephemeral_1h_input_tokens"]); exists && v > 0 { + patch.cacheCreation1hTokens = v + patch.hasCacheCreation1h = true + } + } + return patch + } + + return nil +} + +func mergeSSEUsagePatch(usage *ClaudeUsage, patch *sseUsagePatch) { + if usage == nil || patch == nil { + return + } + + if patch.hasInputTokens { + usage.InputTokens = patch.inputTokens + } + if patch.hasCacheCreationInput { + usage.CacheCreationInputTokens = patch.cacheCreationInputTokens + } + if patch.hasCacheReadInput { + usage.CacheReadInputTokens = patch.cacheReadInputTokens + } + if patch.hasOutputTokens { + usage.OutputTokens = patch.outputTokens + } + if patch.hasCacheCreation5m { + usage.CacheCreation5mTokens = patch.cacheCreation5mTokens + } + if patch.hasCacheCreation1h { + usage.CacheCreation1hTokens = patch.cacheCreation1hTokens + } +} + +func parseSSEUsageInt(value any) (int, bool) { + switch v := value.(type) { + case float64: + return int(v), true + case float32: + return int(v), true + case int: + return v, true + case int64: + return int(v), true + case int32: + return int(v), true + case json.Number: + if i, err := v.Int64(); err == nil { + return int(i), true + } + if f, err := v.Float64(); err == nil { + return int(f), true + } + case string: + if parsed, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + return parsed, true + } + } + return 0, false +} + +// applyCacheTTLOverride 将所有 cache creation tokens 归入指定的 TTL 类型。 +// target 为 "5m" 或 "1h"。返回 true 表示发生了变更。 +func applyCacheTTLOverride(usage *ClaudeUsage, target string) bool { + // Fallback: 如果只有聚合字段但无 5m/1h 明细,将聚合字段归入 5m 默认类别 + if usage.CacheCreation5mTokens == 0 && usage.CacheCreation1hTokens == 0 && usage.CacheCreationInputTokens > 0 { + usage.CacheCreation5mTokens = usage.CacheCreationInputTokens + } + + total := usage.CacheCreation5mTokens + usage.CacheCreation1hTokens + if total == 0 { + return false + } + switch target { + case "1h": + if usage.CacheCreation1hTokens == total { + return false // 已经全是 1h + } + usage.CacheCreation1hTokens = total + usage.CacheCreation5mTokens = 0 + default: // "5m" + if usage.CacheCreation5mTokens == total { + return false // 已经全是 5m + } + usage.CacheCreation5mTokens = total + usage.CacheCreation1hTokens = 0 + } + return true +} + +// rewriteCacheCreationJSON 在 JSON usage 对象中重写 cache_creation 嵌套对象的 TTL 分类。 +// usageObj 是 usage JSON 对象(map[string]any)。 +func rewriteCacheCreationJSON(usageObj map[string]any, target string) bool { + ccObj, ok := usageObj["cache_creation"].(map[string]any) + if !ok { + return false + } + v5m, _ := parseSSEUsageInt(ccObj["ephemeral_5m_input_tokens"]) + v1h, _ := parseSSEUsageInt(ccObj["ephemeral_1h_input_tokens"]) + total := v5m + v1h + if total == 0 { + return false + } + switch target { + case "1h": + if v1h == total { + return false + } + ccObj["ephemeral_1h_input_tokens"] = float64(total) + ccObj["ephemeral_5m_input_tokens"] = float64(0) + default: // "5m" + if v5m == total { + return false + } + ccObj["ephemeral_5m_input_tokens"] = float64(total) + ccObj["ephemeral_1h_input_tokens"] = float64(0) + } + return true +} + +func (s *GatewayService) resolveCacheTTLUsageOverrideTarget(ctx context.Context, account *Account) (string, bool) { + if account == nil { + return "", false + } + if account.IsCacheTTLOverrideEnabled() { + return account.GetCacheTTLOverrideTarget(), true + } + if account.IsAnthropicOAuthOrSetupToken() && s != nil && s.settingService != nil && s.settingService.IsAnthropicCacheTTL1hInjectionEnabled(ctx) { + return cacheTTLTarget5m, true + } + return "", false +} + +func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*ClaudeUsage, error) { + // 更新5h窗口状态 + s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) + + body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, anthropicTooLargeError) + if err != nil { + return nil, err + } + + // 解析usage + var response struct { + Usage ClaudeUsage `json:"usage"` + } + if err := json.Unmarshal(body, &response); err != nil { + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + return nil, s.invalidNonStreamingJSONFailoverError(ctx, resp, account, body, err, mappedModel) + } + return nil, fmt.Errorf("parse response: %w", err) + } + + // 解析嵌套的 cache_creation 对象中的 5m/1h 明细 + cc5m := gjson.GetBytes(body, "usage.cache_creation.ephemeral_5m_input_tokens") + cc1h := gjson.GetBytes(body, "usage.cache_creation.ephemeral_1h_input_tokens") + if cc5m.Exists() || cc1h.Exists() { + response.Usage.CacheCreation5mTokens = int(cc5m.Int()) + response.Usage.CacheCreation1hTokens = int(cc1h.Int()) + } + + // 兼容 Kimi cached_tokens → cache_read_input_tokens + if response.Usage.CacheReadInputTokens == 0 { + cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int() + if cachedTokens > 0 { + response.Usage.CacheReadInputTokens = int(cachedTokens) + if newBody, err := sjson.SetBytes(body, "usage.cache_read_input_tokens", cachedTokens); err == nil { + body = newBody + } + } + } + + // Cache TTL Override: 重写 non-streaming 响应中的 cache_creation 分类。 + // 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。 + if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok { + if applyCacheTTLOverride(&response.Usage, overrideTarget) { + // 同步更新 body JSON 中的嵌套 cache_creation 对象 + if newBody, err := sjson.SetBytes(body, "usage.cache_creation.ephemeral_5m_input_tokens", response.Usage.CacheCreation5mTokens); err == nil { + body = newBody + } + if newBody, err := sjson.SetBytes(body, "usage.cache_creation.ephemeral_1h_input_tokens", response.Usage.CacheCreation1hTokens); err == nil { + body = newBody + } + } + } + + // 如果有模型映射,替换响应中的model字段 + if originalModel != mappedModel { + body = s.replaceModelInResponseBody(body, mappedModel, originalModel) + } + + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + + contentType := "application/json" + if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled { + if upstreamType := resp.Header.Get("Content-Type"); upstreamType != "" { + contentType = upstreamType + } + } + + body = reverseToolNamesIfPresent(c, body) + + // 写入响应 + c.Data(resp.StatusCode, contentType, body) + + return &response.Usage, nil +} + +// replaceModelInResponseBody 替换响应体中的model字段 +// 使用 gjson/sjson 精确替换,避免全量 JSON 反序列化 +func (s *GatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte { + if m := gjson.GetBytes(body, "model"); m.Exists() && m.Str == fromModel { + newBody, err := sjson.SetBytes(body, "model", toModel) + if err != nil { + return body + } + return newBody + } + return body +} + +// reconcileCachedTokens 兼容 Kimi 等上游: +// 将 OpenAI 风格的 cached_tokens 映射到 Claude 标准的 cache_read_input_tokens +func reconcileCachedTokens(usage map[string]any) bool { + if usage == nil { + return false + } + cacheRead, _ := usage["cache_read_input_tokens"].(float64) + if cacheRead > 0 { + return false // 已有标准字段,无需处理 + } + cached, _ := usage["cached_tokens"].(float64) + if cached <= 0 { + return false + } + usage["cache_read_input_tokens"] = cached + return true +} diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go new file mode 100644 index 0000000000..21685caae8 --- /dev/null +++ b/backend/internal/service/gateway_usage_billing.go @@ -0,0 +1,978 @@ +package service + +import ( + "context" + "log/slog" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" +) + +func (s *GatewayService) getUserGroupRateMultiplier(ctx context.Context, userID, groupID int64, groupDefaultMultiplier float64) float64 { + if s == nil { + return groupDefaultMultiplier + } + resolver := s.userGroupRateResolver + if resolver == nil { + resolver = newUserGroupRateResolver( + s.userGroupRateRepo, + s.userGroupRateCache, + resolveUserGroupRateCacheTTL(s.cfg), + &s.userGroupRateSF, + "service.gateway", + ) + } + return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier) +} + +// RecordUsageInput 记录使用量的输入参数。 +// 异步 worker 只接收计费所需快照,不能持有 ParsedRequest/RequestBodyRef 这类大请求体引用。 +type RecordUsageInput struct { + Result *ForwardResult + APIKey *APIKey + User *User + Account *Account + Subscription *UserSubscription // 可选:订阅信息 + InboundEndpoint string // 入站端点(客户端请求路径) + UpstreamEndpoint string // 上游端点(标准化后的上游路径) + UserAgent string // 请求的 User-Agent + IPAddress string // 请求的客户端 IP 地址 + RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险 + ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换) + APIKeyService APIKeyQuotaUpdater // 可选:用于更新API Key配额 + QuotaPlatform string // user×platform 配额计量平台:handler 在请求 ctx 内经 QuotaPlatform() 算定后传入(后扣运行在 worker 池 background ctx 上,取不到 ForcePlatform) + + ChannelUsageFields // 渠道映射信息(由 handler 在 Forward 前解析) +} + +// APIKeyQuotaUpdater defines the interface for updating API Key quota and rate limit usage +type APIKeyQuotaUpdater interface { + UpdateQuotaUsed(ctx context.Context, apiKeyID int64, cost float64) error + UpdateRateLimitUsage(ctx context.Context, apiKeyID int64, cost float64) error +} + +type apiKeyAuthCacheInvalidator interface { + InvalidateAuthCacheByKey(ctx context.Context, key string) +} + +type usageLogBestEffortWriter interface { + CreateBestEffort(ctx context.Context, log *UsageLog) error +} + +// postUsageBillingParams 统一扣费所需的参数 +type postUsageBillingParams struct { + Cost *CostBreakdown + User *User + APIKey *APIKey + Account *Account + Subscription *UserSubscription + RequestPayloadHash string + IsSubscriptionBill bool + AccountRateMultiplier float64 + APIKeyService APIKeyQuotaUpdater + Platform string // 来自 APIKey 关联 Group 的平台标识 +} + +// PlatformFromAPIKey 从 APIKey 关联的 Group 推导 platform 名称。 +// apiKey 为 nil 或 Group 信息缺失时返回空串(调用方据此 short-circuit quota 累加)。 +// 导出供 handler 层调用。 +func PlatformFromAPIKey(apiKey *APIKey) string { + if apiKey == nil || apiKey.Group == nil { + return "" + } + return apiKey.Group.Platform +} + +// QuotaPlatform 返回 user×platform 配额计量使用的平台标识。 +// 强制平台路由(如 /antigravity)优先按 ctx 中的 ForcePlatform 计量,否则回退到 +// APIKey 关联 Group 的平台。 +// +// 注意:必须用带 ForcePlatform 的请求 context 调用(如 handler 的 c.Request.Context())。 +// 后扣运行在 worker 池的 background ctx 上没有 ForcePlatform,因此后扣平台由 handler +// 预先算定、经 RecordUsageInput.QuotaPlatform 传入,不要在后扣链路用 worker ctx 调用本函数。 +func QuotaPlatform(ctx context.Context, apiKey *APIKey) string { + if fp, ok := ctx.Value(ctxkey.ForcePlatform).(string); ok && fp != "" { + return fp + } + return PlatformFromAPIKey(apiKey) +} + +func (p *postUsageBillingParams) shouldDeductAPIKeyQuota() bool { + return p.Cost.ActualCost > 0 && p.APIKey.Quota > 0 && p.APIKeyService != nil +} + +func (p *postUsageBillingParams) shouldUpdateRateLimits() bool { + return p.Cost.ActualCost > 0 && p.APIKey.HasRateLimits() && p.APIKeyService != nil +} + +func (p *postUsageBillingParams) shouldUpdateAccountQuota() bool { + return p.Cost.TotalCost > 0 && p.Account.IsAPIKeyOrBedrock() && p.Account.HasAnyQuotaLimit() +} + +// postUsageBilling is the legacy fallback billing path used when the unified +// billing repo is unavailable (nil). Production uses applyUsageBilling → repo.Apply +// for atomic billing. This path only runs in tests or degraded mode. +func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *billingDeps) { + billingCtx, cancel := detachedBillingContext(ctx) + defer cancel() + + cost := p.Cost + + if p.IsSubscriptionBill { + // Subscription usage tracked by ActualCost so group rate multiplier + // consumes the quota at the expected speed. + if cost.ActualCost > 0 { + if err := deps.userSubRepo.IncrementUsage(billingCtx, p.Subscription.ID, cost.ActualCost); err != nil { + slog.Error("increment subscription usage failed", "subscription_id", p.Subscription.ID, "error", err) + } + } + } else { + if cost.ActualCost > 0 { + if err := deps.userRepo.DeductBalance(billingCtx, p.User.ID, cost.ActualCost); err != nil { + slog.Error("deduct balance failed", "user_id", p.User.ID, "error", err) + } else if deps.billingCacheService != nil { + if err := deps.billingCacheService.InvalidateUserBalance(billingCtx, p.User.ID); err != nil { + slog.Warn("invalidate balance cache after legacy deduction failed", "user_id", p.User.ID, "error", err) + } + } + } + } + + if p.shouldDeductAPIKeyQuota() { + if err := p.APIKeyService.UpdateQuotaUsed(billingCtx, p.APIKey.ID, cost.ActualCost); err != nil { + slog.Error("update api key quota failed", "api_key_id", p.APIKey.ID, "error", err) + } + } + + if p.shouldUpdateRateLimits() { + if err := p.APIKeyService.UpdateRateLimitUsage(billingCtx, p.APIKey.ID, cost.ActualCost); err != nil { + slog.Error("update api key rate limit usage failed", "api_key_id", p.APIKey.ID, "error", err) + } + } + + if p.shouldUpdateAccountQuota() { + accountCost := cost.TotalCost * p.AccountRateMultiplier + if err := deps.accountRepo.IncrementQuotaUsed(billingCtx, p.Account.ID, accountCost); err != nil { + slog.Error("increment account quota used failed", "account_id", p.Account.ID, "cost", accountCost, "error", err) + } + } + + // Platform quota 累加(legacy 兜底路径):仅对 standard(余额)模式生效;订阅模式豁免;仅对有 limit 的用户写 + // - HasUserPlatformQuotaLimit 守卫:与正常路径对齐,无 limit 公司跳过 + // - 新增 Redis 同步写:enforcement 走 Redis,legacy 路径也必须同步写,否则 preflight 看不到消费 + // - flusher_enabled=false(降级):保留原有同步直写 DB + // - flusher_enabled=true:跳过直写 DB,由 flusher 异步批量刷(markDirty 在 IncrementUserPlatformQuotaUsage 内部完成) + // - 失败仅记 ALERT log + counter,不阻断主扣费流程 + if !p.IsSubscriptionBill && p.Platform != "" && cost.ActualCost > 0 && p.User != nil && deps.userPlatformQuotaRepo != nil { + if deps.billingCacheService.HasUserPlatformQuotaLimit(billingCtx, p.User.ID, p.Platform) { + deps.billingCacheService.IncrementUserPlatformQuotaUsage(p.User.ID, p.Platform, cost.ActualCost) + if deps.cfg == nil || !deps.cfg.Database.UserPlatformQuotaFlusherEnabled { + // 降级路径:flusher 未启用时保留原有同步直写 DB + if err := deps.userPlatformQuotaRepo.IncrementUsageWithReset(billingCtx, p.User.ID, p.Platform, cost.ActualCost, time.Now().UTC()); err != nil { + userPlatformQuotaDBIncrLegacyErrorTotal.Add(1) + logger.LegacyPrintf("service.gateway", "ALERT: legacy incr user platform quota DB failed user=%d platform=%s cost=%f: %v", p.User.ID, p.Platform, cost.ActualCost, err) + } + } + // flusher_enabled=true:不直写 DB,flusher 异步批量刷 + } + } + + // NOTE: finalizePostUsageBilling is NOT called here to avoid double-queuing + // cache updates. The legacy path does DB writes directly; the finalize path + // does cache queue + notifications. Notifications are dispatched separately + // by the caller after recording the usage log. +} + +func resolveUsageBillingRequestID(ctx context.Context, upstreamRequestID string) string { + if ctx != nil { + if clientRequestID, _ := ctx.Value(ctxkey.ClientRequestID).(string); strings.TrimSpace(clientRequestID) != "" { + return "client:" + strings.TrimSpace(clientRequestID) + } + if requestID, _ := ctx.Value(ctxkey.RequestID).(string); strings.TrimSpace(requestID) != "" { + return "local:" + strings.TrimSpace(requestID) + } + } + if requestID := strings.TrimSpace(upstreamRequestID); requestID != "" { + return requestID + } + return "generated:" + generateRequestID() +} + +func resolveUsageBillingPayloadFingerprint(ctx context.Context, requestPayloadHash string) string { + if payloadHash := strings.TrimSpace(requestPayloadHash); payloadHash != "" { + return payloadHash + } + if ctx != nil { + if clientRequestID, _ := ctx.Value(ctxkey.ClientRequestID).(string); strings.TrimSpace(clientRequestID) != "" { + return "client:" + strings.TrimSpace(clientRequestID) + } + if requestID, _ := ctx.Value(ctxkey.RequestID).(string); strings.TrimSpace(requestID) != "" { + return "local:" + strings.TrimSpace(requestID) + } + } + return "" +} + +func buildUsageBillingCommand(requestID string, usageLog *UsageLog, p *postUsageBillingParams) *UsageBillingCommand { + if p == nil || p.Cost == nil || p.APIKey == nil || p.User == nil || p.Account == nil { + return nil + } + + cmd := &UsageBillingCommand{ + RequestID: requestID, + APIKeyID: p.APIKey.ID, + UserID: p.User.ID, + AccountID: p.Account.ID, + AccountType: p.Account.Type, + RequestPayloadHash: strings.TrimSpace(p.RequestPayloadHash), + } + if usageLog != nil { + cmd.Model = usageLog.Model + cmd.BillingType = usageLog.BillingType + cmd.InputTokens = usageLog.InputTokens + cmd.OutputTokens = usageLog.OutputTokens + cmd.CacheCreationTokens = usageLog.CacheCreationTokens + cmd.CacheReadTokens = usageLog.CacheReadTokens + cmd.ImageCount = usageLog.ImageCount + if usageLog.ServiceTier != nil { + cmd.ServiceTier = *usageLog.ServiceTier + } + if usageLog.ReasoningEffort != nil { + cmd.ReasoningEffort = *usageLog.ReasoningEffort + } + if usageLog.SubscriptionID != nil { + cmd.SubscriptionID = usageLog.SubscriptionID + } + } + + // Record subscription / balance cost using ActualCost so the group (and any + // user-specific) rate multiplier consumes subscription quota at the expected + // speed. TotalCost remains the raw (pre-multiplier) value; downstream guards + // on "> 0" still correctly skip free subscriptions (RateMultiplier == 0). + if p.IsSubscriptionBill && p.Subscription != nil && p.Cost.TotalCost > 0 { + cmd.SubscriptionID = &p.Subscription.ID + cmd.SubscriptionCost = p.Cost.ActualCost + } else if p.Cost.ActualCost > 0 { + cmd.BalanceCost = p.Cost.ActualCost + } + + if p.shouldDeductAPIKeyQuota() { + cmd.APIKeyQuotaCost = p.Cost.ActualCost + } + if p.shouldUpdateRateLimits() { + cmd.APIKeyRateLimitCost = p.Cost.ActualCost + } + if p.shouldUpdateAccountQuota() { + cmd.AccountQuotaCost = p.Cost.TotalCost * p.AccountRateMultiplier + } + + cmd.Normalize() + return cmd +} + +func applyUsageBilling(ctx context.Context, requestID string, usageLog *UsageLog, p *postUsageBillingParams, deps *billingDeps, repo UsageBillingRepository) (bool, error) { + if p == nil || deps == nil { + return false, nil + } + + cmd := buildUsageBillingCommand(requestID, usageLog, p) + if cmd == nil || cmd.RequestID == "" || repo == nil { + postUsageBilling(ctx, p, deps) + return true, nil + } + + billingCtx, cancel := detachedBillingContext(ctx) + defer cancel() + + result, err := repo.Apply(billingCtx, cmd) + if err != nil { + return false, err + } + + if result == nil || !result.Applied { + deps.deferredService.ScheduleLastUsedUpdate(p.Account.ID) + return false, nil + } + + if result.APIKeyQuotaExhausted { + if invalidator, ok := p.APIKeyService.(apiKeyAuthCacheInvalidator); ok && p.APIKey != nil && p.APIKey.Key != "" { + invalidator.InvalidateAuthCacheByKey(billingCtx, p.APIKey.Key) + } + } + + finalizePostUsageBilling(billingCtx, p, deps, result) + return true, nil +} + +func finalizePostUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) { + if p == nil || p.Cost == nil || deps == nil { + return + } + + if p.IsSubscriptionBill { + if p.Cost.ActualCost > 0 && p.User != nil && p.APIKey != nil && p.APIKey.GroupID != nil { + deps.billingCacheService.QueueUpdateSubscriptionUsage(p.User.ID, *p.APIKey.GroupID, p.Cost.ActualCost) + } + } else if p.Cost.ActualCost > 0 && p.User != nil { + syncBalanceCacheAfterDeduction(ctx, p, deps, result) + } + + if p.Cost.ActualCost > 0 && p.APIKey != nil && p.APIKey.HasRateLimits() { + deps.billingCacheService.QueueUpdateAPIKeyRateLimitUsage(p.APIKey.ID, p.Cost.ActualCost) + } + + deps.deferredService.ScheduleLastUsedUpdate(p.Account.ID) + + // Platform quota 累加:仅在 standard(余额)模式生效;订阅模式豁免;仅对有 limit 的用户写 + // Redis 同步写 + DB 异步持久化(flag=false 降级)或 flusher 异步刷(flag=true): + // - HasUserPlatformQuotaLimit 守卫:无 limit 的公司跳过,避免无效写入 + 浪费 Redis 容量 + // - Redis 同步:确保下次 preflight 立即看到最新 usage,把 TOCTOU 超支窗口 + // 限制在并发 in-flight 请求数量内(旧实现的异步入队会让超支无限累积直到 worker 处理) + // - DB 异步(flusher_enabled=false):在独立 goroutine 中走 detached context,失败用 ALERT log 触发 oncall 对账 + // - flusher_enabled=true:不直写 DB,由 flusher 异步批量刷(markDirty 已在 IncrementUserPlatformQuotaUsage 内部完成) + if !p.IsSubscriptionBill && p.Platform != "" && p.Cost.ActualCost > 0 && p.User != nil && deps.userPlatformQuotaRepo != nil { + if deps.billingCacheService.HasUserPlatformQuotaLimit(ctx, p.User.ID, p.Platform) { + deps.billingCacheService.IncrementUserPlatformQuotaUsage(p.User.ID, p.Platform, p.Cost.ActualCost) + if deps.cfg == nil || !deps.cfg.Database.UserPlatformQuotaFlusherEnabled { + // 降级路径:flusher 未启用时保留原有异步直写 DB + dbCtx, dbCancel := detachUpstreamContext(ctx) + userID, platform, cost := p.User.ID, p.Platform, p.Cost.ActualCost + go func() { + defer func() { + if r := recover(); r != nil { + logger.LegacyPrintf("service.gateway", "ALERT: panic in user platform quota incr goroutine user=%d platform=%s: %v", userID, platform, r) + } + }() + defer dbCancel() + if err := deps.userPlatformQuotaRepo.IncrementUsageWithReset(dbCtx, userID, platform, cost, time.Now().UTC()); err != nil { + // 失败计数器:暴露给 GatewayUserPlatformQuotaIncrStats(),由 ops 面板做斜率告警。 + userPlatformQuotaDBIncrErrorTotal.Add(1) + // ALERT 级别:DB 持久化失败意味着 Redis cache 失效后该笔 cost 永久丢失, + // 用户配额视图与实际消费会偏差,oncall 需要据此对账或人工补录。 + logger.LegacyPrintf("service.gateway", "ALERT: incr user platform quota DB failed user=%d platform=%s cost=%f: %v", userID, platform, cost, err) + } + }() + } + // flusher_enabled=true:不直写 DB,flusher 异步批量刷 + } + } + + // Notification checks run async — all parameters are already captured, + // no dependency on the request context or upstream connection. + go notifyBalanceLow(p, deps, result) + go notifyAccountQuota(p, deps, result) +} + +func syncBalanceCacheAfterDeduction(ctx context.Context, p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) { + if p == nil || p.Cost == nil || p.User == nil || deps == nil || deps.billingCacheService == nil { + return + } + if result != nil && result.NewBalance != nil && deps.billingCacheService.balanceBelowEligibilityThreshold(*result.NewBalance) { + if err := deps.billingCacheService.InvalidateUserBalance(ctx, p.User.ID); err != nil { + slog.Warn("invalidate balance cache after exhausted deduction failed", + "user_id", p.User.ID, + "new_balance", *result.NewBalance, + "balance_overdrafted", result.BalanceOverdrafted, + "error", err, + ) + } + return + } + deps.billingCacheService.QueueDeductBalance(p.User.ID, p.Cost.ActualCost) +} + +// notifyBalanceLow sends balance low notification after deduction. +// When result.NewBalance is available (from DB transaction RETURNING), it is used directly +// to reconstruct oldBalance, avoiding stale Redis reads and concurrent-deduction races. +func notifyBalanceLow(p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) { + defer func() { + if r := recover(); r != nil { + slog.Error("panic in notifyBalanceLow", "recover", r) + } + }() + if p.IsSubscriptionBill || p.Cost.ActualCost <= 0 || p.User == nil || deps.balanceNotifyService == nil { + slog.Debug("notifyBalanceLow: skipped", + "is_subscription", p.IsSubscriptionBill, + "actual_cost", p.Cost.ActualCost, + "user_nil", p.User == nil, + "service_nil", deps.balanceNotifyService == nil, + ) + return + } + + oldBalance := resolveOldBalance(p, result) + slog.Debug("notifyBalanceLow: calling CheckBalanceAfterDeduction", + "user_id", p.User.ID, + "old_balance", oldBalance, + "cost", p.Cost.ActualCost, + "notify_enabled", p.User.BalanceNotifyEnabled, + "threshold", p.User.BalanceNotifyThreshold, + "result_has_new_balance", result != nil && result.NewBalance != nil, + ) + deps.balanceNotifyService.CheckBalanceAfterDeduction(context.Background(), p.User, oldBalance, p.Cost.ActualCost) +} + +// resolveOldBalance returns the pre-deduction balance. +// Prefers the DB transaction result (newBalance + cost) over snapshot. +func resolveOldBalance(p *postUsageBillingParams, result *UsageBillingApplyResult) float64 { + if result != nil && result.NewBalance != nil { + return *result.NewBalance + p.Cost.ActualCost + } + // Legacy fallback: snapshot balance from request context + return p.User.Balance +} + +// notifyAccountQuota sends account quota threshold notification after increment. +// When result.QuotaState is available (from DB transaction RETURNING), it is passed directly +// to avoid a separate DB read that may see stale or concurrently-modified data. +func notifyAccountQuota(p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) { + defer func() { + if r := recover(); r != nil { + slog.Error("panic in notifyAccountQuota", "recover", r) + } + }() + if p.Cost.TotalCost <= 0 || p.Account == nil || !p.Account.IsAPIKeyOrBedrock() || deps.balanceNotifyService == nil { + slog.Debug("notifyAccountQuota: skipped", + "total_cost", p.Cost.TotalCost, + "account_nil", p.Account == nil, + "is_apikey_or_bedrock", p.Account != nil && p.Account.IsAPIKeyOrBedrock(), + "service_nil", deps.balanceNotifyService == nil, + ) + return + } + accountCost := p.Cost.TotalCost * p.AccountRateMultiplier + var quotaState *AccountQuotaState + if result != nil { + quotaState = result.QuotaState + } + slog.Debug("notifyAccountQuota: calling CheckAccountQuotaAfterIncrement", + "account_id", p.Account.ID, + "account_cost", accountCost, + "has_quota_state", quotaState != nil, + ) + deps.balanceNotifyService.CheckAccountQuotaAfterIncrement(context.Background(), p.Account, accountCost, quotaState) +} + +func detachedBillingContext(ctx context.Context) (context.Context, context.CancelFunc) { + base := context.Background() + if ctx != nil { + base = context.WithoutCancel(ctx) + } + return context.WithTimeout(base, postUsageBillingTimeout) +} + +func detachStreamUpstreamContext(ctx context.Context, stream bool) (context.Context, context.CancelFunc) { + if ctx == nil { + return context.Background(), func() {} + } + if !stream { + return ctx, func() {} + } + return context.WithoutCancel(ctx), func() {} +} + +func detachUpstreamContext(ctx context.Context) (context.Context, context.CancelFunc) { + if ctx == nil { + return context.Background(), func() {} + } + return context.WithoutCancel(ctx), func() {} +} + +// billingDeps 扣费逻辑依赖的服务(由各 gateway service 提供) +type billingDeps struct { + accountRepo AccountRepository + userRepo UserRepository + userSubRepo UserSubscriptionRepository + billingCacheService *BillingCacheService + deferredService *DeferredService + balanceNotifyService *BalanceNotifyService + userPlatformQuotaRepo UserPlatformQuotaRepository + cfg *config.Config +} + +func (s *GatewayService) 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, + cfg: s.cfg, + } +} + +func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usageLog *UsageLog, logKey string) { + if repo == nil || usageLog == nil { + return + } + usageCtx, cancel := detachedBillingContext(ctx) + defer cancel() + + if writer, ok := repo.(usageLogBestEffortWriter); ok { + if err := writer.CreateBestEffort(usageCtx, usageLog); err != nil { + logger.LegacyPrintf(logKey, "Create usage log failed: %v", err) + // 计费已在此前完成,日志必须落库:dropped(批处理队列超时)同样走同步兜底, + // 否则会出现“已扣费但无 usage_log”的对账缺口(issue #3656)。 + // 重复写入由 usage_logs 的 ON CONFLICT (request_id, api_key_id) DO NOTHING 防护。 + fallbackCtx := usageCtx + if usageCtx.Err() != nil { + // usageCtx 已耗尽(best-effort 入队阻塞到期限):换新的 detached 窗口,避免兜底必然失败。 + var fallbackCancel context.CancelFunc + fallbackCtx, fallbackCancel = detachedBillingContext(context.Background()) + defer fallbackCancel() + } + if _, syncErr := repo.Create(fallbackCtx, usageLog); syncErr != nil { + logger.LegacyPrintf(logKey, "Create usage log sync fallback failed: %v", syncErr) + } + } + return + } + + if _, err := repo.Create(usageCtx, usageLog); err != nil { + logger.LegacyPrintf(logKey, "Create usage log failed: %v", err) + } +} + +// recordUsageOpts 内部选项,参数化普通计费与长上下文计费的差异点。 +type recordUsageOpts struct { + // 长上下文计费(仅 Gemini 路径需要) + LongContextThreshold int + LongContextMultiplier float64 +} + +// RecordUsage 记录使用量并扣费(或更新订阅用量) +func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInput) error { + return s.recordUsageCore(ctx, &recordUsageCoreInput{ + Result: input.Result, + APIKey: input.APIKey, + User: input.User, + Account: input.Account, + Subscription: input.Subscription, + InboundEndpoint: input.InboundEndpoint, + UpstreamEndpoint: input.UpstreamEndpoint, + UserAgent: input.UserAgent, + IPAddress: input.IPAddress, + RequestPayloadHash: input.RequestPayloadHash, + ForceCacheBilling: input.ForceCacheBilling, + APIKeyService: input.APIKeyService, + QuotaPlatform: input.QuotaPlatform, + ChannelUsageFields: input.ChannelUsageFields, + }, &recordUsageOpts{}) +} + +// RecordUsageLongContextInput 记录使用量的输入参数(支持长上下文双倍计费) +type RecordUsageLongContextInput struct { + Result *ForwardResult + APIKey *APIKey + User *User + Account *Account + Subscription *UserSubscription // 可选:订阅信息 + InboundEndpoint string // 入站端点(客户端请求路径) + UpstreamEndpoint string // 上游端点(标准化后的上游路径) + UserAgent string // 请求的 User-Agent + IPAddress string // 请求的客户端 IP 地址 + RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险 + LongContextThreshold int // 长上下文阈值(如 200000) + LongContextMultiplier float64 // 超出阈值部分的倍率(如 2.0) + ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换) + APIKeyService APIKeyQuotaUpdater // API Key 配额服务(可选) + QuotaPlatform string // user×platform 配额计量平台:handler 在请求 ctx 内经 QuotaPlatform() 算定后传入(后扣运行在 worker 池 background ctx 上,取不到 ForcePlatform) + + ChannelUsageFields // 渠道映射信息(由 handler 在 Forward 前解析) +} + +// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini) +func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *RecordUsageLongContextInput) error { + return s.recordUsageCore(ctx, &recordUsageCoreInput{ + Result: input.Result, + APIKey: input.APIKey, + User: input.User, + Account: input.Account, + Subscription: input.Subscription, + InboundEndpoint: input.InboundEndpoint, + UpstreamEndpoint: input.UpstreamEndpoint, + UserAgent: input.UserAgent, + IPAddress: input.IPAddress, + RequestPayloadHash: input.RequestPayloadHash, + ForceCacheBilling: input.ForceCacheBilling, + APIKeyService: input.APIKeyService, + QuotaPlatform: input.QuotaPlatform, + ChannelUsageFields: input.ChannelUsageFields, + }, &recordUsageOpts{ + LongContextThreshold: input.LongContextThreshold, + LongContextMultiplier: input.LongContextMultiplier, + }) +} + +// recordUsageCoreInput 是 recordUsageCore 的公共输入字段,从两种输入结构体中提取。 +type recordUsageCoreInput struct { + Result *ForwardResult + APIKey *APIKey + User *User + Account *Account + Subscription *UserSubscription + InboundEndpoint string + UpstreamEndpoint string + UserAgent string + IPAddress string + RequestPayloadHash string + ForceCacheBilling bool + APIKeyService APIKeyQuotaUpdater + QuotaPlatform string + ChannelUsageFields +} + +// recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。 +// LongContextThreshold > 0 时 Token 计费回退走 CalculateCostWithLongContext。 +func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error { + result := input.Result + apiKey := input.APIKey + user := input.User + account := input.Account + subscription := input.Subscription + ApplyForwardImageBillingResolution(result) + + // 强制缓存计费:将 input_tokens 转为 cache_read_input_tokens + // 用于粘性会话切换时的特殊计费处理 + if input.ForceCacheBilling && result.Usage.InputTokens > 0 { + logger.LegacyPrintf("service.gateway", "force_cache_billing: %d input_tokens → cache_read_input_tokens (account=%d)", + result.Usage.InputTokens, account.ID) + result.Usage.CacheReadInputTokens += result.Usage.InputTokens + result.Usage.InputTokens = 0 + } + + // Cache TTL Override: 确保计费时 token 分类与账号设置一致。 + // 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。 + cacheTTLOverridden := false + if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok { + applyCacheTTLOverride(&result.Usage, overrideTarget) + cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0 + } + + // 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认) + multiplier := 1.0 + if s.cfg != nil { + multiplier = s.cfg.Default.RateMultiplier + } + if apiKey.GroupID != nil && apiKey.Group != nil { + groupDefault := apiKey.Group.RateMultiplier + multiplier = s.getUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault) + } + // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算, + // 不并入上面的 getUserGroupRateMultiplier,以免污染 user:group 倍率缓存。 + multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, timezone.Now()) + + // 确定计费模型 + billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel) + if input.BillingModelSource == BillingModelSourceChannelMapped && input.ChannelMappedModel != "" { + billingModel = input.ChannelMappedModel + } + if input.BillingModelSource == BillingModelSourceRequested && input.OriginalModel != "" { + billingModel = input.OriginalModel + } + + // 确定 RequestedModel(渠道映射前的原始模型) + requestedModel := result.Model + if input.OriginalModel != "" { + requestedModel = input.OriginalModel + } + + // 计算费用 + cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, opts) + + // 判断计费方式:订阅模式 vs 余额模式 + isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() + billingType := BillingTypeBalance + if isSubscriptionBilling { + billingType = BillingTypeSubscription + } + + // 创建使用日志 + accountRateMultiplier := account.BillingRateMultiplier() + usageLog := s.buildRecordUsageLog(ctx, input, result, apiKey, user, account, subscription, + requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost, opts) + + // 计算账号统计定价费用(使用最终上游模型匹配自定义规则) + if apiKey.GroupID != nil { + applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService, + account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model, + // Anthropic's input_tokens excludes cache_read and cache_creation (billed separately); + // OpenAI gateway uses actualInputTokens which also excludes cache_read for the same reason. + UsageTokens{ + InputTokens: result.Usage.InputTokens, + OutputTokens: result.Usage.OutputTokens, + CacheCreationTokens: result.Usage.CacheCreationInputTokens, + CacheReadTokens: result.Usage.CacheReadInputTokens, + ImageOutputTokens: result.Usage.ImageOutputTokens, + }, + cost.TotalCost, + ) + } + + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway") + logger.LegacyPrintf("service.gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens()) + s.deferredService.ScheduleLastUsedUpdate(account.ID) + return nil + } + + // 配额平台由 handler 在请求 ctx 内经 QuotaPlatform() 算定并通过 input 传入; + // 后扣运行在 worker 池的 background ctx 上,无法再从 ctx 取 ForcePlatform。 + // 缺省(未设置)时回退到分组平台,保持对其它调用方的兼容。 + quotaPlatform := input.QuotaPlatform + if quotaPlatform == "" { + quotaPlatform = PlatformFromAPIKey(apiKey) + } + requestID := usageLog.RequestID + _, billingErr := applyUsageBilling(ctx, requestID, usageLog, &postUsageBillingParams{ + Cost: cost, + User: user, + APIKey: apiKey, + Account: account, + Subscription: subscription, + RequestPayloadHash: resolveUsageBillingPayloadFingerprint(ctx, input.RequestPayloadHash), + IsSubscriptionBill: isSubscriptionBilling, + AccountRateMultiplier: accountRateMultiplier, + APIKeyService: input.APIKeyService, + Platform: quotaPlatform, + }, s.billingDeps(), s.usageBillingRepo) + + if billingErr != nil { + return billingErr + } + writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway") + + return nil +} + +// calculateRecordUsageCost 根据请求类型和选项计算费用。 +func (s *GatewayService) calculateRecordUsageCost( + ctx context.Context, + result *ForwardResult, + apiKey *APIKey, + billingModel string, + multiplier float64, + imageMultiplier float64, + opts *recordUsageOpts, +) *CostBreakdown { + // 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费 + if result.ImageCount > 0 { + if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && resolved.Mode == BillingModeToken { + return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts) + } + return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier) + } + + // Token 计费 + return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts) +} + +// resolveChannelPricing 检查指定模型是否存在渠道级别定价。 +// 返回非 nil 的 ResolvedPricing 表示有渠道定价,nil 表示走默认定价路径。 +func (s *GatewayService) resolveChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing { + if s.resolver == nil || apiKey.Group == nil { + return nil + } + gid := apiKey.Group.ID + resolved := s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid}) + if resolved.Source == PricingSourceChannel { + return resolved + } + return nil +} + +// calculateImageCost 计算图片生成费用:渠道级别定价优先,否则走按次计费。 +func (s *GatewayService) calculateImageCost( + ctx context.Context, + result *ForwardResult, + apiKey *APIKey, + billingModel string, + multiplier float64, +) *CostBreakdown { + sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize) + if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil { + tokens := UsageTokens{ + InputTokens: result.Usage.InputTokens, + OutputTokens: result.Usage.OutputTokens, + ImageOutputTokens: result.Usage.ImageOutputTokens, + } + gid := apiKey.Group.ID + cost, err := s.billingService.CalculateCostUnified(CostInput{ + Ctx: ctx, + Model: billingModel, + GroupID: &gid, + Tokens: tokens, + RequestCount: result.ImageCount, + SizeTier: sizeTier, + RateMultiplier: multiplier, + Resolver: s.resolver, + Resolved: resolved, + }) + if err != nil { + logger.LegacyPrintf("service.gateway", "Calculate image token cost failed: %v", err) + return &CostBreakdown{ActualCost: 0} + } + return cost + } + + var groupConfig *ImagePriceConfig + if apiKey.Group != nil { + groupConfig = &ImagePriceConfig{ + Price1K: apiKey.Group.ImagePrice1K, + Price2K: apiKey.Group.ImagePrice2K, + Price4K: apiKey.Group.ImagePrice4K, + } + } + return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) +} + +// calculateTokenCost 计算 Token 计费:根据 opts 决定走普通/长上下文/渠道统一计费。 +func (s *GatewayService) calculateTokenCost( + ctx context.Context, + result *ForwardResult, + apiKey *APIKey, + billingModel string, + multiplier float64, + opts *recordUsageOpts, +) *CostBreakdown { + tokens := UsageTokens{ + InputTokens: result.Usage.InputTokens, + OutputTokens: result.Usage.OutputTokens, + CacheCreationTokens: result.Usage.CacheCreationInputTokens, + CacheReadTokens: result.Usage.CacheReadInputTokens, + CacheCreation5mTokens: result.Usage.CacheCreation5mTokens, + CacheCreation1hTokens: result.Usage.CacheCreation1hTokens, + ImageOutputTokens: result.Usage.ImageOutputTokens, + } + + var cost *CostBreakdown + var err error + + // 优先尝试渠道定价 → CalculateCostUnified + if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil { + gid := apiKey.Group.ID + cost, err = s.billingService.CalculateCostUnified(CostInput{ + Ctx: ctx, + Model: billingModel, + GroupID: &gid, + Tokens: tokens, + RequestCount: 1, + RateMultiplier: multiplier, + Resolver: s.resolver, + Resolved: resolved, + }) + } else if opts.LongContextThreshold > 0 { + // 长上下文双倍计费(如 Gemini 200K 阈值) + cost, err = s.billingService.CalculateCostWithLongContext(billingModel, tokens, multiplier, opts.LongContextThreshold, opts.LongContextMultiplier) + } else { + cost, err = s.billingService.CalculateCost(billingModel, tokens, multiplier) + } + if err != nil { + logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err) + return &CostBreakdown{ActualCost: 0} + } + return cost +} + +// buildRecordUsageLog 构建使用日志并设置计费模式。 +func (s *GatewayService) buildRecordUsageLog( + ctx context.Context, + input *recordUsageCoreInput, + result *ForwardResult, + apiKey *APIKey, + user *User, + account *Account, + subscription *UserSubscription, + requestedModel string, + multiplier float64, + imageMultiplier float64, + accountRateMultiplier float64, + billingType int8, + cacheTTLOverridden bool, + cost *CostBreakdown, + opts *recordUsageOpts, +) *UsageLog { + durationMs := int(result.Duration.Milliseconds()) + requestID := resolveUsageBillingRequestID(ctx, result.RequestID) + usageLog := &UsageLog{ + UserID: user.ID, + APIKeyID: apiKey.ID, + AccountID: account.ID, + RequestID: requestID, + Model: result.Model, + RequestedModel: requestedModel, + UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model), + ReasoningEffort: result.ReasoningEffort, + InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), + UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), + InputTokens: result.Usage.InputTokens, + OutputTokens: result.Usage.OutputTokens, + CacheCreationTokens: result.Usage.CacheCreationInputTokens, + CacheReadTokens: result.Usage.CacheReadInputTokens, + CacheCreation5mTokens: result.Usage.CacheCreation5mTokens, + CacheCreation1hTokens: result.Usage.CacheCreation1hTokens, + ImageOutputTokens: result.Usage.ImageOutputTokens, + RateMultiplier: multiplier, + AccountRateMultiplier: &accountRateMultiplier, + BillingType: billingType, + BillingMode: resolveBillingMode(result, cost), + Stream: result.Stream, + DurationMs: &durationMs, + FirstTokenMs: result.FirstTokenMs, + ImageCount: result.ImageCount, + ImageSize: optionalTrimmedStringPtr(result.ImageSize), + ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize), + ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize), + ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource), + ImageSizeBreakdown: result.ImageSizeBreakdown, + CacheTTLOverridden: cacheTTLOverridden, + ChannelID: optionalInt64Ptr(input.ChannelID), + ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain), + UserAgent: optionalTrimmedStringPtr(input.UserAgent), + IPAddress: optionalTrimmedStringPtr(input.IPAddress), + GroupID: apiKey.GroupID, + SubscriptionID: optionalSubscriptionID(subscription), + CreatedAt: time.Now(), + } + if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) { + usageLog.RateMultiplier = imageMultiplier + } + if cost != nil { + usageLog.InputCost = cost.InputCost + usageLog.OutputCost = cost.OutputCost + usageLog.ImageOutputCost = cost.ImageOutputCost + usageLog.CacheCreationCost = cost.CacheCreationCost + usageLog.CacheReadCost = cost.CacheReadCost + usageLog.TotalCost = cost.TotalCost + usageLog.ActualCost = cost.ActualCost + } + + return usageLog +} + +// resolveBillingMode 根据计费结果和请求类型确定计费模式。 +func resolveBillingMode(result *ForwardResult, cost *CostBreakdown) *string { + var mode string + switch { + case cost != nil && cost.BillingMode != "": + mode = cost.BillingMode + case result.ImageCount > 0: + mode = string(BillingModeImage) + default: + mode = string(BillingModeToken) + } + return &mode +} + +func optionalSubscriptionID(subscription *UserSubscription) *int64 { + if subscription != nil { + return &subscription.ID + } + return nil +} diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go new file mode 100644 index 0000000000..6fdabeb90f --- /dev/null +++ b/backend/internal/service/openai_gateway_forward.go @@ -0,0 +1,956 @@ +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +// Forward forwards request to OpenAI API +func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { + startTime := time.Now() + + restrictionResult := s.detectCodexClientRestriction(c, account, body) + apiKeyID := getAPIKeyIDFromContext(c) + logCodexCLIOnlyDetection(ctx, c, account, apiKeyID, restrictionResult, body) + if restrictionResult.Enabled && !restrictionResult.Matched { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) + c.JSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "type": "forbidden_error", + "message": CodexClientRestrictionMessage(restrictionResult), + }, + }) + return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed") + } + + originalBody := body + requestView := newOpenAIRequestView(body) + reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey + originalModel := reqModel + + if account.Platform == PlatformGrok { + _ = promptCacheKey + return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime) + } + + if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { + return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) + } + + compatMessagesBridge := isOpenAICompatMessagesBridgeBody(body) + setOpenAICompatMessagesBridgeContext(c, compatMessagesBridge) + + isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI) + wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) + clientTransport := GetOpenAIClientTransport(c) + // 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。 + wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, clientTransport) + if c != nil { + c.Set("openai_ws_transport_decision", string(wsDecision.Transport)) + c.Set("openai_ws_transport_reason", wsDecision.Reason) + } + if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 { + logOpenAIWSModeDebug( + "selected account_id=%d account_type=%s transport=%s reason=%s model=%s stream=%v", + account.ID, + account.Type, + normalizeOpenAIWSLogValue(string(wsDecision.Transport)), + normalizeOpenAIWSLogValue(wsDecision.Reason), + reqModel, + reqStream, + ) + } + // 当前仅支持 WSv2;WSv1 命中时直接返回错误,避免出现“配置可开但行为不确定”。 + if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocket { + if c != nil { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusBadRequest, gin.H{ + "error": gin.H{ + "type": "invalid_request_error", + "message": "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.", + }, + }) + } + return nil, errors.New("openai ws v1 is temporarily unsupported; use ws v2") + } + passthroughEnabled := account.IsOpenAIPassthroughEnabled() + if passthroughEnabled { + // 透传分支只需要轻量提取字段,避免热路径全量 Unmarshal。 + reasoningEffort := extractOpenAIReasoningEffortFromBody(body, reqModel) + // 国产模型默认 effort 补充:也要用 mappedModel 判定是否是 passback-required 上游。 + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, account.GetMappedModel(reqModel)) + return s.forwardOpenAIPassthrough(ctx, c, account, originalBody, reqModel, reasoningEffort, reqStream, startTime) + } + + bodyModified := false + var reqBody map[string]any + ensureReqBody := func() (map[string]any, error) { + if requestView.HasPatches() { + patchedBody, patchErr := requestView.ApplyPatches() + if patchErr != nil { + return nil, patchErr + } + body = patchedBody + requestView = newOpenAIRequestView(body) + reqBody = nil + bodyModified = false + } + if reqBody != nil { + return reqBody, nil + } + decoded, decodeErr := requestView.Decode(c) + if decodeErr != nil { + return nil, decodeErr + } + reqBody = decoded + return reqBody, nil + } + markPatchSet := func(path string, value any) { + bodyModified = true + if requestView.patchesDisabled { + if reqBody != nil { + setOpenAIRequestMapPath(reqBody, path, value) + } + return + } + requestView.MarkPatchSet(path, value) + } + markPatchDelete := func(path string) { + bodyModified = true + if requestView.patchesDisabled { + if reqBody != nil { + deleteOpenAIRequestMapPath(reqBody, path) + } + return + } + requestView.MarkPatchDelete(path) + } + disablePatch := func() { + requestView.DisablePatches() + } + markDecodedModified := func() { + bodyModified = true + disablePatch() + } + + apiKey := getAPIKeyFromContext(c) + imageGenerationAllowed := GroupAllowsImageGeneration(nil) + if apiKey != nil { + imageGenerationAllowed = GroupAllowsImageGeneration(apiKey.Group) + } + codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow + if isCodexCLI { + codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy() + } + codexImageGenerationBridgeEnabled := isCodexCLI && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) + var imageIntent bool + if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + if stripOpenAIImageGenerationTools(decoded) { + markDecodedModified() + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy") + } + imageIntent = IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, decoded) + } else { + imageIntent = IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) + } + if imageIntent && !imageGenerationAllowed { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}}) + return nil, errors.New("image generation disabled for group") + } + + instructions := gjson.GetBytes(body, "instructions") + instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" + if instructionsEmpty && !compatMessagesBridge { + markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) + } + + billingModel := account.GetMappedModel(reqModel) + if billingModel != reqModel { + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, billingModel, account.Name, isCodexCLI) + reqModel = billingModel + markPatchSet("model", billingModel) + } + upstreamModel := billingModel + isCompactRequest := isOpenAIResponsesCompactPath(c) + compactMapped := false + if isCompactRequest { + compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel) + if compactMappedModel != "" && compactMappedModel != billingModel { + compactMapped = true + upstreamModel = compactMappedModel + reqModel = compactMappedModel + markPatchSet("model", compactMappedModel) + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", billingModel, compactMappedModel, account.Name, isCodexCLI) + } + } + if !compactMapped { + modelForNormalize := reqModel + if modelForNormalize == "" { + modelForNormalize = requestView.Model + } + upstreamModel = normalizeOpenAIModelForUpstream(account, modelForNormalize) + if upstreamModel != "" && upstreamModel != modelForNormalize { + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", modelForNormalize, upstreamModel, account.Name, account.Type, isCodexCLI) + reqModel = upstreamModel + markPatchSet("model", upstreamModel) + } + } + if strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) == "minimal" { + markPatchSet("reasoning.effort", "none") + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized reasoning.effort: minimal -> none (account: %s)", account.Name) + } + + imageIntent = imageIntent || IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, nil) || isOpenAIImageGenerationModel(upstreamModel) + if imageIntent && !imageGenerationAllowed { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}}) + return nil, errors.New("image generation disabled for group") + } + + // /responses/compact 是会话压缩请求:上游不接受 tool_choice(400 unknown_parameter), + // 注入 image_generation 工具也没有意义,整块豁免。 + if imageGenerationAllowed && !isCompactRequest && (codexImageGenerationBridgeEnabled || isOpenAIImageGenerationModel(requestView.Model) || openAIRequestBodyImageGenerationToolNeedsNormalization(body) || isOpenAIImageGenerationModel(upstreamModel)) { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationTool(decoded) { + markDecodedModified() + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Injected /responses image_generation tool for Codex client") + } + if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationToolChoiceAuto(decoded) { + markDecodedModified() + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Set /responses image_generation tool_choice=auto for Codex client") + } + if normalizeOpenAIResponsesImageGenerationTools(decoded) { + markDecodedModified() + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image_generation tool payload") + } + if normalizeOpenAIResponsesImageOnlyModel(decoded) { + markDecodedModified() + if model, ok := decoded["model"].(string); ok { + upstreamModel = strings.TrimSpace(model) + } + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image-only model request inbound_model=%s image_model=%s upstream_model=%s", requestView.Model, billingModel, upstreamModel) + } + if err := validateOpenAIResponsesImageModel(decoded, upstreamModel); err != nil { + setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "model"}}) + return nil, err + } + if hasOpenAIImageGenerationTool(decoded) { + imageIntent = true + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type) + } + if codexImageGenerationBridgeEnabled && applyCodexImageGenerationBridgeInstructions(decoded) { + markDecodedModified() + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Added Codex image_generation bridge instructions") + } + } else if imageGenerationAllowed && imageIntent && openAIRequestBodyHasImageGenerationTool(body) { + // 完整 image_generation tool 只做 raw 计费读取,校验/桥接/旧字段迁移命中时才展开大 input map。 + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type) + } + + if isCodexSparkModel(upstreamModel) && openAIRequestBodyMayContainImageInput(body) { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + if err := validateCodexSparkInput(decoded, upstreamModel); err != nil { + setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "input"}}) + return nil, err + } + } + + // gpt-5.3-codex-spark also rejects the image_generation tool (HTTP 400, + // param=tools). Strip it here so both APIKey and OAuth /responses paths are + // covered regardless of the image-generation feature gate. + if isCodexSparkModel(upstreamModel) && openAIRequestBodyHasImageGenerationTool(body) { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + if stripCodexSparkImageGenerationTools(decoded) { + markDecodedModified() + } + } + + if account.Type == AccountTypeOAuth { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + codexResult := codexTransformResult{} + if compatMessagesBridge { + codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{IsCodexCLI: isCodexCLI, IsCompact: isCompactRequest, SkipDefaultInstructions: true, PreserveToolCallIDs: true}) + ensureCodexOAuthInstructionsField(decoded) + markDecodedModified() + } else { + codexResult = applyCodexOAuthTransform(decoded, isCodexCLI, isCompactRequest) + } + if codexResult.Modified { + markDecodedModified() + } + // 带真实 device_id 时补齐 client_metadata 安装标识,与真实 Codex 对齐(compact 形态不同,跳过)。 + if !isCompactRequest && applyCodexClientMetadata(decoded, account) { + markDecodedModified() + } + if codexResult.NormalizedModel != "" { + upstreamModel = codexResult.NormalizedModel + } + if codexResult.PromptCacheKey != "" { + promptCacheKey = codexResult.PromptCacheKey + } + } + + if !SupportsVerbosity(upstreamModel) && gjson.GetBytes(body, "text.verbosity").Exists() { + markPatchDelete("text.verbosity") + } + + if !isCodexCLI { + maxOutputTokens := gjson.GetBytes(body, "max_output_tokens") + if maxOutputTokens.Exists() { + switch account.Platform { + case PlatformOpenAI: + if account.Type == AccountTypeAPIKey { + markPatchDelete("max_output_tokens") + } + case PlatformAnthropic: + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + delete(decoded, "max_output_tokens") + if _, hasMaxTokens := decoded["max_tokens"]; !hasMaxTokens { + decoded["max_tokens"] = maxOutputTokens.Value() + } + markDecodedModified() + case PlatformGemini: + markPatchDelete("max_output_tokens") + default: + markPatchDelete("max_output_tokens") + } + } + if gjson.GetBytes(body, "max_completion_tokens").Exists() && (account.Type == AccountTypeAPIKey || account.Platform != PlatformOpenAI) { + markPatchDelete("max_completion_tokens") + } + for _, unsupportedField := range []string{"prompt_cache_retention", "safety_identifier"} { + if gjson.GetBytes(body, unsupportedField).Exists() { + markPatchDelete(unsupportedField) + } + } + } + if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 && gjson.GetBytes(body, "previous_response_id").Exists() { + markPatchDelete("previous_response_id") + } + if openAIRequestBodyMayContainEmptyBase64InputImage(body) { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + if sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(decoded) { + markDecodedModified() + } + } + + if rawTier := requestView.ServiceTier; rawTier != "" { + if normTier := normalizedOpenAIServiceTierValue(rawTier); normTier != "" { + action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, upstreamModel, normTier) + switch action { + case BetaPolicyActionBlock: + msg := errMsg + if msg == "" { + msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, upstreamModel) + } + blocked := &OpenAIFastBlockedError{Message: msg} + writeOpenAIFastPolicyBlockedResponse(c, blocked) + return nil, blocked + case BetaPolicyActionFilter: + markPatchDelete("service_tier") + case OpenAIFastPolicyActionForcePriority: + if rawTier != OpenAIFastTierPriority { + markPatchSet("service_tier", OpenAIFastTierPriority) + } + default: + if normTier != rawTier { + markPatchSet("service_tier", normTier) + } + } + } + } + + if bodyModified { + if requestView.HasPatches() { + if patchedBody, patchErr := requestView.ApplyPatches(); patchErr == nil { + body = patchedBody + requestView = newOpenAIRequestView(body) + reqBody = nil + bodyModified = false + } + } + if bodyModified { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + var marshalErr error + body, marshalErr = marshalOpenAIUpstreamJSON(decoded) + if marshalErr != nil { + return nil, fmt.Errorf("serialize request body: %w", marshalErr) + } + requestView = newOpenAIRequestView(body) + } + } + imageBillingModel := "" + imageSizeTier := "" + imageInputSize := "" + if imageIntent { + var imageCfg OpenAIResponsesImageBillingConfig + var imageCfgErr error + if reqBody != nil { + imageCfg, imageCfgErr = resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, billingModel) + } else { + imageCfg, imageCfgErr = resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, billingModel) + } + if imageCfgErr != nil { + setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "") + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": imageCfgErr.Error(), "param": "size"}}) + return nil, imageCfgErr + } + imageBillingModel = imageCfg.Model + imageSizeTier = imageCfg.SizeTier + imageInputSize = imageCfg.InputSize + } + + // Get access token + token, _, err := s.GetAccessToken(ctx, account) + if err != nil { + return nil, err + } + + // 命中 WS 时仅走 WebSocket Mode;不再自动回退 HTTP。 + if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 { + // WS 分支需要结构化 payload 与重连恢复,命中后再触发 full-map decode。 + wsReqBody, err := ensureReqBody() + if err != nil { + return nil, err + } + _, hasPreviousResponseID := wsReqBody["previous_response_id"] + logOpenAIWSModeDebug( + "forward_start account_id=%d account_type=%s model=%s stream=%v has_previous_response_id=%v", + account.ID, + account.Type, + upstreamModel, + reqStream, + hasPreviousResponseID, + ) + maxAttempts := openAIWSReconnectRetryLimit + 1 + wsAttempts := 0 + var wsResult *OpenAIForwardResult + var wsErr error + wsLastFailureReason := "" + wsPrevResponseRecoveryTried := false + wsInvalidEncryptedContentRecoveryTried := false + recoverPrevResponseNotFound := func(attempt int) bool { + if wsPrevResponseRecoveryTried { + return false + } + previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id") + if previousResponseID == "" { + logOpenAIWSModeInfo( + "reconnect_prev_response_recovery_skip account_id=%d attempt=%d reason=missing_previous_response_id previous_response_id_present=false", + account.ID, + attempt, + ) + return false + } + if HasFunctionCallOutput(wsReqBody) { + logOpenAIWSModeInfo( + "reconnect_prev_response_recovery_skip account_id=%d attempt=%d reason=has_function_call_output previous_response_id_present=true", + account.ID, + attempt, + ) + return false + } + delete(wsReqBody, "previous_response_id") + wsPrevResponseRecoveryTried = true + logOpenAIWSModeInfo( + "reconnect_prev_response_recovery account_id=%d attempt=%d action=drop_previous_response_id retry=1 previous_response_id=%s previous_response_id_kind=%s", + account.ID, + attempt, + truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(ClassifyOpenAIPreviousResponseIDKind(previousResponseID)), + ) + return true + } + recoverInvalidEncryptedContent := func(attempt int) bool { + if wsInvalidEncryptedContentRecoveryTried { + return false + } + removedReasoningItems := trimOpenAIEncryptedReasoningItems(wsReqBody) + if !removedReasoningItems { + logOpenAIWSModeInfo( + "reconnect_invalid_encrypted_content_recovery_skip account_id=%d attempt=%d reason=missing_encrypted_reasoning_items", + account.ID, + attempt, + ) + return false + } + previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id") + hasFunctionCallOutput := HasFunctionCallOutput(wsReqBody) + if previousResponseID != "" && !hasFunctionCallOutput { + delete(wsReqBody, "previous_response_id") + } + wsInvalidEncryptedContentRecoveryTried = true + logOpenAIWSModeInfo( + "reconnect_invalid_encrypted_content_recovery account_id=%d attempt=%d action=drop_encrypted_reasoning_items retry=1 previous_response_id_present=%v previous_response_id=%s previous_response_id_kind=%s has_function_call_output=%v dropped_previous_response_id=%v", + account.ID, + attempt, + previousResponseID != "", + truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(ClassifyOpenAIPreviousResponseIDKind(previousResponseID)), + hasFunctionCallOutput, + previousResponseID != "" && !hasFunctionCallOutput, + ) + return true + } + retryBudget := s.openAIWSRetryTotalBudget() + retryStartedAt := time.Now() + wsRetryLoop: + for attempt := 1; attempt <= maxAttempts; attempt++ { + wsAttempts = attempt + wsResult, wsErr = s.forwardOpenAIWSV2( + ctx, + c, + account, + wsReqBody, + token, + wsDecision, + isCodexCLI, + reqStream, + originalModel, + upstreamModel, + startTime, + attempt, + wsLastFailureReason, + ) + if wsErr == nil { + break + } + if c != nil && c.Writer != nil && c.Writer.Written() { + break + } + + reason, retryable := classifyOpenAIWSReconnectReason(wsErr) + if reason != "" { + wsLastFailureReason = reason + } + // previous_response_not_found 说明续链锚点不可用: + // 对非 function_call_output 场景,允许一次“去掉 previous_response_id 后重放”。 + if reason == "previous_response_not_found" && recoverPrevResponseNotFound(attempt) { + continue + } + if reason == "invalid_encrypted_content" && recoverInvalidEncryptedContent(attempt) { + continue + } + if retryable && attempt < maxAttempts { + backoff := s.openAIWSRetryBackoff(attempt) + if retryBudget > 0 && time.Since(retryStartedAt)+backoff > retryBudget { + s.recordOpenAIWSRetryExhausted() + logOpenAIWSModeInfo( + "reconnect_budget_exhausted account_id=%d attempts=%d max_retries=%d reason=%s elapsed_ms=%d budget_ms=%d", + account.ID, + attempt, + openAIWSReconnectRetryLimit, + normalizeOpenAIWSLogValue(reason), + time.Since(retryStartedAt).Milliseconds(), + retryBudget.Milliseconds(), + ) + break + } + s.recordOpenAIWSRetryAttempt(backoff) + logOpenAIWSModeInfo( + "reconnect_retry account_id=%d retry=%d max_retries=%d reason=%s backoff_ms=%d", + account.ID, + attempt, + openAIWSReconnectRetryLimit, + normalizeOpenAIWSLogValue(reason), + backoff.Milliseconds(), + ) + if backoff > 0 { + timer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + wsErr = wrapOpenAIWSFallback("retry_backoff_canceled", ctx.Err()) + break wsRetryLoop + case <-timer.C: + } + } + continue + } + if retryable { + s.recordOpenAIWSRetryExhausted() + logOpenAIWSModeInfo( + "reconnect_exhausted account_id=%d attempts=%d max_retries=%d reason=%s", + account.ID, + attempt, + openAIWSReconnectRetryLimit, + normalizeOpenAIWSLogValue(reason), + ) + } else if reason != "" { + s.recordOpenAIWSNonRetryableFastFallback() + logOpenAIWSModeInfo( + "reconnect_stop account_id=%d attempt=%d reason=%s", + account.ID, + attempt, + normalizeOpenAIWSLogValue(reason), + ) + } + break + } + if wsErr == nil { + firstTokenMs := int64(0) + hasFirstTokenMs := wsResult != nil && wsResult.FirstTokenMs != nil + if hasFirstTokenMs { + firstTokenMs = int64(*wsResult.FirstTokenMs) + } + requestID := "" + if wsResult != nil { + requestID = strings.TrimSpace(wsResult.RequestID) + } + logOpenAIWSModeDebug( + "forward_succeeded account_id=%d request_id=%s stream=%v has_first_token_ms=%v first_token_ms=%d ws_attempts=%d", + account.ID, + requestID, + reqStream, + hasFirstTokenMs, + firstTokenMs, + wsAttempts, + ) + wsResult.UpstreamModel = upstreamModel + if wsResult.BillingModel == "" { + wsResult.BillingModel = billingModel + } + if wsResult.ImageCount > 0 { + wsResult.ImageSize = imageSizeTier + wsResult.ImageInputSize = imageInputSize + wsResult.BillingModel = imageBillingModel + } + return wsResult, nil + } + s.writeOpenAIWSFallbackErrorResponse(c, account, wsErr) + return nil, wsErr + } + + httpInvalidEncryptedContentRetryTried := false + for { + // Build upstream request + upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) + upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, reqStream, promptCacheKey, isCodexCLI) + releaseUpstreamCtx() + if err != nil { + return nil, err + } + + // Get proxy URL + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + // Send request + upstreamStart := time.Now() + resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) + if err != nil { + // Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to + // a failover so the handler switches to a healthy account, and temporarily + // unschedule the account on durable faults (e.g. rejected proxy credentials). + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) + } + + // Handle error response + if resp.StatusCode >= 400 { + respBody := s.readUpstreamErrorBody(resp) + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + upstreamCode := extractUpstreamErrorCode(respBody) + if !httpInvalidEncryptedContentRetryTried && resp.StatusCode == http.StatusBadRequest && upstreamCode == "invalid_encrypted_content" { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + if trimOpenAIEncryptedReasoningItems(decoded) { + body, err = marshalOpenAIUpstreamJSON(decoded) + if err != nil { + return nil, fmt.Errorf("serialize invalid_encrypted_content retry body: %w", err) + } + httpInvalidEncryptedContentRetryTried = true + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request once after invalid_encrypted_content (account: %s)", account.Name) + continue + } + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Skip non-WSv2 invalid_encrypted_content retry because encrypted reasoning items are missing (account: %s)", account.Name) + } + if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(respBody), maxBytes) + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "failover", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + + s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel) + return nil, &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: respBody, + RetryableOnSameAccount: account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), + } + } + return s.handleErrorResponse(ctx, resp, c, account, body, billingModel) + } + defer func() { _ = resp.Body.Close() }() + + reasoningEffort := extractOpenAIReasoningEffortFromBody(body, originalModel) + // 国产模型默认 effort 补充:此处 reqModel 已被 mapping 重写为 billingModel(见 + // line 2510-2515 的 GetMappedModel + reqModel 赋值),可直接作为 mappedModel。 + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, reqModel) + serviceTier := extractOpenAIServiceTierFromBody(body) + // 上游接受后只保留计费需要的标量,避免响应处理期间继续保活完整 input/tools map。 + reqBody = nil + + // Handle normal response + var usage *OpenAIUsage + var firstTokenMs *int + responseID := "" + imageCount := 0 + var imageOutputSizes []string + if reqStream { + streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel) + if err != nil { + return nil, err + } + usage = streamResult.usage + firstTokenMs = streamResult.firstTokenMs + responseID = strings.TrimSpace(streamResult.responseID) + imageCount = streamResult.imageCount + imageOutputSizes = streamResult.imageOutputSizes + } else { + nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel) + if err != nil { + return nil, err + } + usage = nonStreamResult.usage + responseID = strings.TrimSpace(nonStreamResult.responseID) + imageCount = nonStreamResult.imageCount + imageOutputSizes = nonStreamResult.imageOutputSizes + } + s.bindHTTPResponseAccount(ctx, c, account, responseID) + + // Extract and save Codex usage snapshot from response headers (for OAuth accounts). + // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 + if account.Type == AccountTypeOAuth && !account.IsShadow() { + if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { + s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) + } + } + + if usage == nil { + usage = &OpenAIUsage{} + } + + forwardResult := &OpenAIForwardResult{ + RequestID: resp.Header.Get("x-request-id"), + ResponseID: responseID, + Usage: *usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + ServiceTier: serviceTier, + ReasoningEffort: reasoningEffort, + Stream: reqStream, + OpenAIWSMode: false, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + } + if imageCount > 0 { + forwardResult.ImageCount = imageCount + forwardResult.ImageSize = imageSizeTier + forwardResult.ImageInputSize = imageInputSize + forwardResult.ImageOutputSizes = imageOutputSizes + forwardResult.BillingModel = imageBillingModel + } + return forwardResult, nil + } +} + +func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) { + // Determine target URL based on account type + var targetURL string + switch account.Type { + case AccountTypeOAuth: + // OAuth accounts use ChatGPT internal API + targetURL = chatgptCodexURL + case AccountTypeAPIKey: + // API Key accounts use Platform API or custom base URL + baseURL := account.GetOpenAIBaseURL() + if baseURL == "" { + targetURL = openaiPlatformAPIURL + } else { + validatedURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return nil, err + } + targetURL = buildOpenAIResponsesURL(validatedURL) + } + default: + targetURL = openaiPlatformAPIURL + } + targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) + + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI)) + + // Set authentication header + req.Header.Set("authorization", "Bearer "+token) + + // Set headers specific to OAuth accounts (ChatGPT internal API) + if account.Type == AccountTypeOAuth { + // Required: set Host for ChatGPT API (must use req.Host, not Header.Set) + req.Host = "chatgpt.com" + if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil { + return nil, fmt.Errorf("resolve chatgpt account headers: %w", err) + } + } + + // Whitelist passthrough headers + for key, values := range c.Request.Header { + lowerKey := strings.ToLower(key) + if openaiAllowedHeaders[lowerKey] { + for _, v := range values { + req.Header.Add(key, v) + } + } + } + if account.Type == AccountTypeOAuth { + compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body) + // 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。 + clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id")) + req.Header.Del("conversation_id") + req.Header.Del("session_id") + + if compatMessagesBridge { + req.Header.Del("OpenAI-Beta") + req.Header.Del("originator") + } else { + req.Header.Set("OpenAI-Beta", "responses=experimental") + req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI)) + } + apiKeyID := getAPIKeyIDFromContext(c) + if isOpenAIResponsesCompactPath(c) { + req.Header.Set("accept", "application/json") + if req.Header.Get("version") == "" { + req.Header.Set("version", codexCLIVersion) + } + compactSession := resolveOpenAICompactSessionID(c) + req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession)) + } else { + req.Header.Set("accept", "text/event-stream") + } + if promptCacheKey != "" { + isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey) + req.Header.Set("session_id", isolated) + if !compatMessagesBridge || clientConversationID != "" { + req.Header.Set("conversation_id", isolated) + } + } + } + + // Apply custom User-Agent if configured + customUA := account.GetOpenAIUserAgent() + if customUA != "" { + req.Header.Set("user-agent", customUA) + } + + // 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为 Codex CLI。 + // 用于网关未透传/改写 User-Agent 时,仍能命中 Codex 侧识别逻辑。 + if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { + req.Header.Set("user-agent", codexCLIUserAgent) + } + + // 浏览器型 UA 兜底:仅 OAuth(ChatGPT 内部接口)账号生效,若最终 user-agent 仍为浏览器 + // (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。 + s.overrideBrowserUserAgent(ctx, account, req) + + // Ensure required headers exist + if req.Header.Get("content-type") == "" { + req.Header.Set("content-type", "application/json") + } + + // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) + account.ApplyHeaderOverrides(req.Header) + + return req, nil +} + +// overrideBrowserUserAgent 检查请求的最终 user-agent,若为浏览器 UA 则替换为后台配置的 Codex UA。 +// 用于规避 Cloudflare 对浏览器型 UA 在 ChatGPT 内部接口上的访问质询。 +// 影响范围严格限定:仅 OAuth(Codex/ChatGPT 内部接口)账号生效;API Key 等其他账号原样透传。 +// 仅在识别为浏览器(Mozilla/...)时改写,其他 CLI/工具 UA 不动。 +func (s *OpenAIGatewayService) overrideBrowserUserAgent(ctx context.Context, account *Account, req *http.Request) { + if req == nil || account == nil { + return + } + if account.Type != AccountTypeOAuth { + return + } + currentUA := req.Header.Get("user-agent") + if !openai.IsBrowserUserAgent(currentUA) { + return + } + codexUA := DefaultOpenAICodexUserAgent + if s != nil && s.settingService != nil { + if v := strings.TrimSpace(s.settingService.GetOpenAICodexUserAgent(ctx)); v != "" { + codexUA = v + } + } + req.Header.Set("user-agent", codexUA) +} diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go new file mode 100644 index 0000000000..b48b7510eb --- /dev/null +++ b/backend/internal/service/openai_gateway_request_body.go @@ -0,0 +1,1164 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) { + if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled { + normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) + if err != nil { + return "", fmt.Errorf("invalid base_url: %w", err) + } + return normalized, nil + } + normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{ + AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts, + RequireAllowlist: true, + AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts, + }) + if err != nil { + return "", fmt.Errorf("invalid base_url: %w", err) + } + return normalized, nil +} + +// buildOpenAIResponsesURL 组装 OpenAI Responses 端点。 +// - base 以 /v1 结尾:追加 /responses +// - base 以其他版本段结尾(如 /v4):追加 /responses +// - base 已是 /responses:原样返回 +// - 其他情况:追加 /v1/responses +func buildOpenAIResponsesURL(base string) string { + return buildOpenAIEndpointURL(base, "/v1/responses") +} + +func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool { + if len(reqBody) == 0 { + return false + } + + inputValue, has := reqBody["input"] + if !has { + return false + } + + switch input := inputValue.(type) { + case []any: + filtered := input[:0] + changed := false + for _, item := range input { + nextItem, itemChanged, keep := sanitizeEncryptedReasoningInputItem(item) + if itemChanged { + changed = true + } + if !keep { + continue + } + filtered = append(filtered, nextItem) + } + if !changed { + return false + } + if len(filtered) == 0 { + delete(reqBody, "input") + return true + } + reqBody["input"] = filtered + return true + case []map[string]any: + filtered := input[:0] + changed := false + for _, item := range input { + nextItem, itemChanged, keep := sanitizeEncryptedReasoningInputItem(item) + if itemChanged { + changed = true + } + if !keep { + continue + } + nextMap, ok := nextItem.(map[string]any) + if !ok { + filtered = append(filtered, item) + continue + } + filtered = append(filtered, nextMap) + } + if !changed { + return false + } + if len(filtered) == 0 { + delete(reqBody, "input") + return true + } + reqBody["input"] = filtered + return true + case map[string]any: + nextItem, changed, keep := sanitizeEncryptedReasoningInputItem(input) + if !changed { + return false + } + if !keep { + delete(reqBody, "input") + return true + } + nextMap, ok := nextItem.(map[string]any) + if !ok { + return false + } + reqBody["input"] = nextMap + return true + default: + return false + } +} + +func sanitizeEncryptedReasoningInputItem(item any) (next any, changed bool, keep bool) { + inputItem, ok := item.(map[string]any) + if !ok { + return item, false, true + } + + itemType, _ := inputItem["type"].(string) + if strings.TrimSpace(itemType) != "reasoning" { + return item, false, true + } + + _, hasEncryptedContent := inputItem["encrypted_content"] + if !hasEncryptedContent { + return item, false, true + } + + delete(inputItem, "encrypted_content") + if len(inputItem) == 1 { + return nil, true, false + } + return inputItem, true, true +} + +func IsOpenAIResponsesCompactPathForTest(c *gin.Context) bool { + return isOpenAIResponsesCompactPath(c) +} + +func OpenAICompactSessionSeedKeyForTest() string { + return openAICompactSessionSeedKey +} + +func NormalizeOpenAICompactRequestBodyForTest(body []byte) ([]byte, bool, error) { + return normalizeOpenAICompactRequestBody(body) +} + +func isOpenAIResponsesCompactPath(c *gin.Context) bool { + suffix := strings.TrimSpace(openAIResponsesRequestPathSuffix(c)) + return suffix == "/compact" || strings.HasPrefix(suffix, "/compact/") +} + +func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) { + if len(body) == 0 { + return body, false, nil + } + + normalized := []byte(`{}`) + // Keep the current Codex /compact schema while still dropping request-scoped + // fields such as prompt_cache_key, store, and stream. + for _, field := range []string{ + "model", + "input", + "instructions", + "tools", + "parallel_tool_calls", + "reasoning", + "text", + "previous_response_id", + } { + value := gjson.GetBytes(body, field) + if !value.Exists() { + continue + } + next, err := sjson.SetRawBytes(normalized, field, []byte(value.Raw)) + if err != nil { + return body, false, fmt.Errorf("normalize compact body %s: %w", field, err) + } + normalized = next + } + + if bytes.Equal(bytes.TrimSpace(body), bytes.TrimSpace(normalized)) { + return body, false, nil + } + return normalized, true, nil +} + +func resolveOpenAICompactSessionID(c *gin.Context) string { + if c != nil { + if sessionID := strings.TrimSpace(c.GetHeader("session_id")); sessionID != "" { + return sessionID + } + if conversationID := strings.TrimSpace(c.GetHeader("conversation_id")); conversationID != "" { + return conversationID + } + if seed, ok := c.Get(openAICompactSessionSeedKey); ok { + if seedStr, ok := seed.(string); ok && strings.TrimSpace(seedStr) != "" { + return strings.TrimSpace(seedStr) + } + } + } + return uuid.NewString() +} + +func openAIResponsesRequestPathSuffix(c *gin.Context) string { + if c == nil || c.Request == nil || c.Request.URL == nil { + return "" + } + normalizedPath := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/") + if normalizedPath == "" { + return "" + } + idx := strings.LastIndex(normalizedPath, "/responses") + if idx < 0 { + return "" + } + suffix := normalizedPath[idx+len("/responses"):] + if suffix == "" || suffix == "/" { + return "" + } + if !strings.HasPrefix(suffix, "/") { + return "" + } + return suffix +} + +func appendOpenAIResponsesRequestPathSuffix(baseURL, suffix string) string { + trimmedBase := strings.TrimRight(strings.TrimSpace(baseURL), "/") + trimmedSuffix := strings.TrimSpace(suffix) + if trimmedBase == "" || trimmedSuffix == "" { + return trimmedBase + } + return trimmedBase + trimmedSuffix +} + +func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte { + // 使用 gjson/sjson 精确替换 model 字段,避免全量 JSON 反序列化 + if m := gjson.GetBytes(body, "model"); m.Exists() && m.Str == fromModel { + newBody, err := sjson.SetBytes(body, "model", toModel) + if err != nil { + return body + } + return newBody + } + return body +} + +func getOpenAIReasoningEffortFromReqBody(reqBody map[string]any) (value string, present bool) { + if reqBody == nil { + return "", false + } + + // Primary: reasoning.effort + if reasoning, ok := reqBody["reasoning"].(map[string]any); ok { + if effort, ok := reasoning["effort"].(string); ok { + return normalizeOpenAIReasoningEffort(effort), true + } + } + + // Fallback: some clients may use a flat field. + if effort, ok := reqBody["reasoning_effort"].(string); ok { + return normalizeOpenAIReasoningEffort(effort), true + } + + return "", false +} + +func deriveOpenAIReasoningEffortFromModel(model string) string { + if strings.TrimSpace(model) == "" { + return "" + } + + modelID := strings.TrimSpace(model) + if strings.Contains(modelID, "/") { + parts := strings.Split(modelID, "/") + modelID = parts[len(parts)-1] + } + + parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool { + switch r { + case '-', '_', ' ': + return true + default: + return false + } + }) + if len(parts) == 0 { + return "" + } + + return normalizeOpenAIReasoningEffort(parts[len(parts)-1]) +} + +type openAIRequestView struct { + body []byte + Model string + Stream bool + PromptCacheKey string + PreviousResponseID string + ServiceTier string + ReasoningEffort string + patches []openAIRequestPatch + patchesDisabled bool +} + +type openAIRequestPatch struct { + path string + delete bool + value any +} + +func newOpenAIRequestView(body []byte) openAIRequestView { + if len(body) == 0 { + return openAIRequestView{} + } + return openAIRequestView{ + body: body, + Model: strings.TrimSpace(gjson.GetBytes(body, "model").String()), + Stream: gjson.GetBytes(body, "stream").Bool(), + PromptCacheKey: strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()), + PreviousResponseID: strings.TrimSpace(gjson.GetBytes(body, "previous_response_id").String()), + ServiceTier: strings.TrimSpace(gjson.GetBytes(body, "service_tier").String()), + ReasoningEffort: strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()), + } +} + +// Decode 保留阶段一既有 full-map 行为;后续阶段会把调用点下沉到复杂分支。 +func (v openAIRequestView) Decode(c *gin.Context) (map[string]any, error) { + return getOpenAIRequestBodyMap(c, v.body) +} + +func (v *openAIRequestView) MarkPatchSet(path string, value any) { + if v == nil || v.patchesDisabled { + return + } + path = strings.TrimSpace(path) + if !isSimpleOpenAIRequestPatchPath(path) { + v.DisablePatches() + return + } + v.patches = append(v.patches, openAIRequestPatch{path: path, value: value}) +} + +func (v *openAIRequestView) MarkPatchDelete(path string) { + if v == nil || v.patchesDisabled { + return + } + path = strings.TrimSpace(path) + if !isSimpleOpenAIRequestPatchPath(path) { + v.DisablePatches() + return + } + v.patches = append(v.patches, openAIRequestPatch{path: path, delete: true}) +} + +func isSimpleOpenAIRequestPatchPath(path string) bool { + if path == "" || strings.ContainsRune(path, '\\') { + return false + } + for _, part := range strings.Split(path, ".") { + if strings.TrimSpace(part) == "" { + return false + } + } + return true +} + +func (v *openAIRequestView) DisablePatches() { + if v == nil { + return + } + v.patchesDisabled = true + v.patches = nil +} + +func (v openAIRequestView) HasPatches() bool { + return !v.patchesDisabled && len(v.patches) > 0 +} + +func (v openAIRequestView) ApplyPatches() ([]byte, error) { + if v.patchesDisabled || len(v.patches) == 0 { + return nil, errors.New("openai request patches disabled") + } + body := v.body + for _, patch := range v.patches { + var err error + if patch.delete { + body, err = sjson.DeleteBytes(body, patch.path) + } else { + body, err = sjson.SetBytes(body, patch.path, patch.value) + } + if err != nil { + return nil, err + } + } + return body, nil +} + +func setOpenAIRequestMapPath(reqBody map[string]any, path string, value any) { + path = strings.TrimSpace(path) + if reqBody == nil || path == "" { + return + } + parts := strings.Split(path, ".") + current := reqBody + for _, part := range parts[:len(parts)-1] { + part = strings.TrimSpace(part) + if part == "" { + return + } + next, _ := current[part].(map[string]any) + if next == nil { + next = map[string]any{} + current[part] = next + } + current = next + } + last := strings.TrimSpace(parts[len(parts)-1]) + if last != "" { + current[last] = value + } +} + +func deleteOpenAIRequestMapPath(reqBody map[string]any, path string) { + path = strings.TrimSpace(path) + if reqBody == nil || path == "" { + return + } + parts := strings.Split(path, ".") + current := reqBody + for _, part := range parts[:len(parts)-1] { + part = strings.TrimSpace(part) + if part == "" { + return + } + next, _ := current[part].(map[string]any) + if next == nil { + return + } + current = next + } + last := strings.TrimSpace(parts[len(parts)-1]) + if last != "" { + delete(current, last) + } +} + +func extractOpenAIRequestMetaFromBody(body []byte) (model string, stream bool, promptCacheKey string) { + view := newOpenAIRequestView(body) + return view.Model, view.Stream, view.PromptCacheKey +} + +// normalizeOpenAIPassthroughOAuthBody 将透传 OAuth 请求体收敛为旧链路关键行为: +// 1) 删除 ChatGPT internal API 不支持的顶层 Responses 参数 +// 2) store=false 3) 非 compact 保持 stream=true;compact 强制 stream=false +func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, bool, error) { + if len(body) == 0 { + return body, false, nil + } + + normalized := body + changed := false + + for _, field := range openAIChatGPTInternalUnsupportedFields { + if value := gjson.GetBytes(normalized, field); !value.Exists() { + continue + } + next, err := sjson.DeleteBytes(normalized, field) + if err != nil { + return body, false, fmt.Errorf("normalize passthrough body delete %s: %w", field, err) + } + normalized = next + changed = true + } + + if compact { + if store := gjson.GetBytes(normalized, "store"); store.Exists() { + next, err := sjson.DeleteBytes(normalized, "store") + if err != nil { + return body, false, fmt.Errorf("normalize passthrough body delete store: %w", err) + } + normalized = next + changed = true + } + if stream := gjson.GetBytes(normalized, "stream"); stream.Exists() { + next, err := sjson.DeleteBytes(normalized, "stream") + if err != nil { + return body, false, fmt.Errorf("normalize passthrough body delete stream: %w", err) + } + normalized = next + changed = true + } + } else { + if store := gjson.GetBytes(normalized, "store"); !store.Exists() || store.Type != gjson.False { + next, err := sjson.SetBytes(normalized, "store", false) + if err != nil { + return body, false, fmt.Errorf("normalize passthrough body store=false: %w", err) + } + normalized = next + changed = true + } + if stream := gjson.GetBytes(normalized, "stream"); !stream.Exists() || stream.Type != gjson.True { + next, err := sjson.SetBytes(normalized, "stream", true) + if err != nil { + return body, false, fmt.Errorf("normalize passthrough body stream=true: %w", err) + } + normalized = next + changed = true + } + } + + return normalized, changed, nil +} + +func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byte) string { + model := strings.ToLower(strings.TrimSpace(reqModel)) + if !strings.Contains(model, "codex") { + return "" + } + + instructions := gjson.GetBytes(body, "instructions") + if !instructions.Exists() { + return "instructions_missing" + } + if instructions.Type != gjson.String { + return "instructions_not_string" + } + if strings.TrimSpace(instructions.String()) == "" { + return "instructions_empty" + } + return "" +} + +func extractOpenAIReasoningEffortFromBody(body []byte, requestedModel string) *string { + reasoningEffort := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) + if reasoningEffort == "" { + reasoningEffort = strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String()) + } + if reasoningEffort != "" { + normalized := normalizeOpenAIReasoningEffort(reasoningEffort) + if normalized == "" { + return nil + } + return &normalized + } + + value := deriveOpenAIReasoningEffortFromModel(requestedModel) + if value == "" { + return nil + } + return &value +} + +func extractOpenAIServiceTier(reqBody map[string]any) *string { + if reqBody == nil { + return nil + } + raw, ok := reqBody["service_tier"].(string) + if !ok { + return nil + } + return normalizeOpenAIServiceTier(raw) +} + +func extractOpenAIServiceTierFromBody(body []byte) *string { + if len(body) == 0 { + return nil + } + return normalizeOpenAIServiceTier(gjson.GetBytes(body, "service_tier").String()) +} + +func normalizeOpenAIServiceTier(raw string) *string { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + return nil + } + if value == "fast" { + value = "priority" + } + // 放过 OpenAI 官方文档定义的所有合法 tier 值:priority/flex/auto/default/scale。 + // 对 Codex 客户端零影响(Codex 只发 priority 或 flex,见 codex-rs/core/src/client.rs), + // 但能让直连 OpenAI SDK 的用户透传 auto/default/scale 以便抓包/调试。 + // 真未知值仍返回 nil,由 normalizeResponsesBodyServiceTier 从 body 中删除。 + switch value { + case "priority", "flex", "auto", "default", "scale": + return &value + default: + return nil + } +} + +// OpenAIFastBlockedError indicates a request was rejected by the OpenAI fast +// policy (action=block). Mirrors BetaBlockedError on the Claude side. +type OpenAIFastBlockedError struct { + Message string +} + +func (e *OpenAIFastBlockedError) Error() string { return e.Message } + +// evaluateOpenAIFastPolicy returns the action and error message that should be +// applied for a request with the given account/model/service_tier. When the +// policy service is unavailable or no rule matches, it returns +// (BetaPolicyActionPass, "") so callers can short-circuit safely. +// +// Matching rules: +// - Scope filters by account type (all / oauth / apikey / bedrock) +// - ServiceTier must be empty (= any), "all", or equal the normalized tier +// - ModelWhitelist narrows the rule to specific models; FallbackAction +// handles the non-matching case (default: pass) +// +// 与 Claude BetaPolicy 的差异(保留首条匹配 short-circuit): +// - BetaPolicy 处理的是 anthropic-beta header 中的 token 集合,不同 +// 规则可能针对不同 token,filter 需要累加成 set;block 则 first-match。 +// - OpenAI fast policy 操作的是单个字段 service_tier:filter 即删字段, +// 没有可累加的对象。一次请求只携带一个 service_tier,规则的 tier +// 维度天然互斥;同一 (scope, tier) 下若多条规则的 model whitelist +// 发生重叠,admin 可通过规则顺序明确意图。因此采用 first-match 而 +// 非 BetaPolicy 那样的"block 覆盖 filter 覆盖 pass"语义。 +func (s *OpenAIGatewayService) evaluateOpenAIFastPolicy(ctx context.Context, account *Account, model, serviceTier string) (action, errMsg string) { + if s == nil || s.settingService == nil { + return BetaPolicyActionPass, "" + } + tier := strings.ToLower(strings.TrimSpace(serviceTier)) + if tier == "" { + return BetaPolicyActionPass, "" + } + settings := openAIFastPolicySettingsFromContext(ctx) + if settings == nil { + fetched, err := s.settingService.GetOpenAIFastPolicySettings(ctx) + if err != nil || fetched == nil { + return BetaPolicyActionPass, "" + } + settings = fetched + } + return evaluateOpenAIFastPolicyWithSettings(settings, account, model, tier) +} + +// evaluateOpenAIFastPolicyWithSettings is the pure-function core extracted so +// long-lived sessions (e.g. WS) can prefetch settings once and avoid hitting +// the settingService on every frame. See WSSession entry and +// openAIFastPolicySettingsFromContext for the caching glue. +func evaluateOpenAIFastPolicyWithSettings(settings *OpenAIFastPolicySettings, account *Account, model, tier string) (action, errMsg string) { + if settings == nil { + return BetaPolicyActionPass, "" + } + isOAuth := account != nil && account.IsOAuth() + isBedrock := account != nil && account.IsBedrock() + for _, rule := range settings.Rules { + if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) { + continue + } + ruleTier := strings.ToLower(strings.TrimSpace(rule.ServiceTier)) + if ruleTier != "" && ruleTier != OpenAIFastTierAny && ruleTier != tier { + continue + } + eff := BetaPolicyRule{ + Action: rule.Action, + ErrorMessage: rule.ErrorMessage, + ModelWhitelist: rule.ModelWhitelist, + FallbackAction: rule.FallbackAction, + FallbackErrorMessage: rule.FallbackErrorMessage, + } + return resolveRuleAction(eff, model) + } + return BetaPolicyActionPass, "" +} + +// openAIFastPolicyCtxKey 是 context 中预取的 OpenAIFastPolicySettings 缓存 +// 键,仅用于 WebSocket 长会话内多帧复用同一份策略快照,避免每帧 DB 命中。 +// +// Trade-off:策略变更不会影响当前 WS session(只影响新 session)。这是 +// 有意为之 —— 对长会话来说,"策略一致性"比"立刻生效"更重要,且 Claude +// BetaPolicy 的 gin.Context 缓存也是同样取舍。需要 hot-reload 时管理员 +// 可以通过踢断 session 强制刷新。 +type openAIFastPolicyCtxKeyType struct{} + +var openAIFastPolicyCtxKey = openAIFastPolicyCtxKeyType{} + +// withOpenAIFastPolicyContext 将一份 settings 快照绑定到 context,供该 ctx +// 衍生 goroutine 中的 evaluateOpenAIFastPolicy 复用。 +func withOpenAIFastPolicyContext(ctx context.Context, settings *OpenAIFastPolicySettings) context.Context { + if ctx == nil || settings == nil { + return ctx + } + return context.WithValue(ctx, openAIFastPolicyCtxKey, settings) +} + +func openAIFastPolicySettingsFromContext(ctx context.Context) *OpenAIFastPolicySettings { + if ctx == nil { + return nil + } + if v, ok := ctx.Value(openAIFastPolicyCtxKey).(*OpenAIFastPolicySettings); ok { + return v + } + return nil +} + +// applyOpenAIFastPolicyToBody applies the OpenAI fast policy to a raw request +// body. When action=filter it removes the service_tier field; when +// action=block it returns (body, *OpenAIFastBlockedError). On pass it +// normalizes the service_tier value (e.g. client alias "fast" → "priority"). +// action=force_priority rewrites any matched known tier to "priority". +// +// Rationale for normalize-on-pass: chat-completions / messages 入口在调用本 +// 函数之前已经通过 normalizeResponsesBodyServiceTier 把 service_tier 归一化 +// 到了上游可识别值;passthrough(OpenAI 自动透传) / native /responses 等 +// 入口没有这一前置步骤,pass 路径下若不在此处归一化,"fast" 就会被原样 +// 透传到 OpenAI 上游导致 400/拒绝。把归一化收敛到本函数,所有入口行为一致。 +func (s *OpenAIGatewayService) applyOpenAIFastPolicyToBody(ctx context.Context, account *Account, model string, body []byte) ([]byte, error) { + if len(body) == 0 { + return body, nil + } + rawTier := gjson.GetBytes(body, "service_tier").String() + if rawTier == "" { + return body, nil + } + normTier := normalizedOpenAIServiceTierValue(rawTier) + if normTier == "" { + return body, nil + } + action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, model, normTier) + switch action { + case BetaPolicyActionBlock: + msg := errMsg + if msg == "" { + msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, model) + } + return body, &OpenAIFastBlockedError{Message: msg} + case BetaPolicyActionFilter: + trimmed, err := sjson.DeleteBytes(body, "service_tier") + if err != nil { + return body, fmt.Errorf("strip service_tier from body: %w", err) + } + return trimmed, nil + case OpenAIFastPolicyActionForcePriority: + updated, err := sjson.SetBytes(body, "service_tier", OpenAIFastTierPriority) + if err != nil { + return body, fmt.Errorf("force service_tier priority on body: %w", err) + } + return updated, nil + default: + // pass:把别名(如 "fast")写回为规范值("priority")。 + if normTier == rawTier { + return body, nil + } + updated, err := sjson.SetBytes(body, "service_tier", normTier) + if err != nil { + return body, fmt.Errorf("normalize service_tier on pass: %w", err) + } + return updated, nil + } +} + +// writeOpenAIFastPolicyBlockedResponse writes a 403 JSON response for a +// request blocked by the OpenAI fast policy. +func writeOpenAIFastPolicyBlockedResponse(c *gin.Context, err *OpenAIFastBlockedError) { + if c == nil || err == nil { + return + } + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) + c.JSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "type": "permission_error", + "message": err.Message, + }, + }) +} + +// applyOpenAIFastPolicyToWSResponseCreate evaluates the OpenAI fast policy +// against a single client→upstream WebSocket frame whose top-level +// "type"=="response.create". It mirrors the HTTP-side +// applyOpenAIFastPolicyToBody contract but operates on a Realtime/Responses +// WS payload: +// +// - pass: keeps service_tier, normalizing aliases such as "fast" to "priority" +// - filter: returns a copy with top-level service_tier removed +// - force_priority: keeps service_tier and rewrites it to "priority" +// - block: returns (frame, *OpenAIFastBlockedError) +// +// Only frames whose "type" field strictly equals "response.create" are +// inspected/mutated. Any other frame type — including the empty string — +// passes through untouched. The OpenAI Realtime client-event spec requires +// "type" to be set, so an empty type is treated as a malformed frame we do +// not police; the upstream is the source of truth for rejecting it. +// +// service_tier lives at the top level of response.create — same as the +// Responses HTTP body shape (see openai_gateway_chat_completions.go:304 + +// extractOpenAIServiceTierFromBody at line 5593, and the test fixture at +// openai_ws_forwarder_ingress_session_test.go:402). We therefore only need +// to inspect / strip the top-level field; there is no nested form in the +// schema today. +// +// The caller is responsible for choosing the upstream model passed in — +// this helper does not re-derive it. +func (s *OpenAIGatewayService) applyOpenAIFastPolicyToWSResponseCreate( + ctx context.Context, + account *Account, + model string, + frame []byte, +) ([]byte, *OpenAIFastBlockedError, error) { + if len(frame) == 0 { + return frame, nil, nil + } + if !gjson.ValidBytes(frame) { + return frame, nil, nil + } + frameType := strings.TrimSpace(gjson.GetBytes(frame, "type").String()) + // Strict match: only response.create is policy-checked. Empty / other + // types pass through untouched so we never accidentally strip fields + // from response.cancel, conversation.item.create, or any future + // client-event the spec adds. The Realtime spec requires "type" on + // every client event, so an empty type is malformed input — let the + // upstream reject it rather than guessing at our layer. + if frameType != "response.create" { + return frame, nil, nil + } + rawTier := gjson.GetBytes(frame, "service_tier").String() + if rawTier == "" { + return frame, nil, nil + } + normTier := normalizedOpenAIServiceTierValue(rawTier) + if normTier == "" { + return frame, nil, nil + } + action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, model, normTier) + switch action { + case BetaPolicyActionBlock: + msg := errMsg + if msg == "" { + msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, model) + } + return frame, &OpenAIFastBlockedError{Message: msg}, nil + case BetaPolicyActionFilter: + trimmed, err := sjson.DeleteBytes(frame, "service_tier") + if err != nil { + return frame, nil, fmt.Errorf("strip service_tier from ws frame: %w", err) + } + return trimmed, nil, nil + case OpenAIFastPolicyActionForcePriority: + updated, err := sjson.SetBytes(frame, "service_tier", OpenAIFastTierPriority) + if err != nil { + return frame, nil, fmt.Errorf("force service_tier priority in ws frame: %w", err) + } + return updated, nil, nil + default: + if normTier == rawTier { + return frame, nil, nil + } + updated, err := sjson.SetBytes(frame, "service_tier", normTier) + if err != nil { + return frame, nil, fmt.Errorf("normalize service_tier in ws frame: %w", err) + } + return updated, nil, nil + } +} + +// newOpenAIFastPolicyWSEventID returns a Realtime-style event_id for a +// server-emitted error event. Matches the loose "evt_" convention used +// by upstream Realtime servers; the exact value is not load-bearing and is +// only required for client-side log correlation. We reuse the existing +// google/uuid dependency rather than pulling a new one. +func newOpenAIFastPolicyWSEventID() string { + id, err := uuid.NewRandom() + if err != nil { + // Extremely unlikely; fall back to a fixed prefix so the field is + // still non-empty and the schema stays self-consistent. + return "evt_openai_fast_policy" + } + // Strip dashes so it visually matches "evt_" rather than UUID v4 + // canonical form, mirroring what real Realtime traces look like. + return "evt_" + strings.ReplaceAll(id.String(), "-", "") +} + +// buildOpenAIFastPolicyBlockedWSEvent renders an OpenAI Realtime/Responses +// style "error" event payload for a request blocked by the OpenAI fast +// policy. The shape mirrors Realtime error events as observed in upstream +// traces and per the spec's server "error" event: +// +// { +// "event_id": "evt_", +// "type": "error", +// "error": { +// "type": "invalid_request_error", +// "code": "policy_violation", +// "message": "..." +// } +// } +// +// event_id lets clients correlate the rejection in their logs; "code" gives +// programmatic clients a stable identifier (HTTP-side equivalent is the +// 403 permission_error JSON body). +func buildOpenAIFastPolicyBlockedWSEvent(err *OpenAIFastBlockedError) []byte { + if err == nil { + return nil + } + eventID := newOpenAIFastPolicyWSEventID() + payload, mErr := json.Marshal(map[string]any{ + "event_id": eventID, + "type": "error", + "error": map[string]any{ + "type": "invalid_request_error", + "code": "policy_violation", + "message": err.Message, + }, + }) + if mErr != nil { + // Fallback to a minimal hand-rolled payload; Marshal of the literal + // shape above should never fail in practice. + return []byte(`{"event_id":"` + eventID + `","type":"error","error":{"type":"invalid_request_error","code":"policy_violation","message":"openai fast policy blocked this request"}}`) + } + return payload +} + +func openAIRequestBodyMayContainImageInput(body []byte) bool { + if len(body) == 0 { + return false + } + input := gjson.GetBytes(body, "input") + messages := gjson.GetBytes(body, "messages.#-1") + return openAIJSONValueMayContainImageInput(input) || openAIJSONValueMayContainImageInput(messages) +} + +func openAIJSONValueMayContainImageInput(value gjson.Result) bool { + if !value.Exists() { + return false + } + if value.IsArray() { + found := false + value.ForEach(func(_, item gjson.Result) bool { + if openAIJSONValueMayContainImageInput(item) { + found = true + return false + } + return true + }) + return found + } + if value.IsObject() { + if strings.TrimSpace(value.Get("type").String()) == "input_image" || value.Get("image_url").Exists() { + return true + } + return openAIJSONValueMayContainImageInput(value.Get("content")) + } + return false +} + +func openAIRequestBodyMayContainEmptyBase64InputImage(body []byte) bool { + if len(body) == 0 || !openAIRequestBodyMayContainInputImageToken(body) { + return false + } + input := gjson.GetBytes(body, "input") + if !input.Exists() { + return false + } + return openAIJSONValueMayContainEmptyBase64InputImage(input) +} + +func openAIRequestBodyMayContainInputImageToken(body []byte) bool { + if bytes.Contains(body, []byte("input_image")) { + return true + } + // JSON 字符串任意字符都可能被 unicode escape,遇到 \u 时交给 gjson 解码后的结构扫描兜底。 + return bytes.Contains(body, []byte("\\u")) +} + +func openAIJSONValueMayContainEmptyBase64InputImage(value gjson.Result) bool { + if !value.Exists() { + return false + } + if value.IsArray() { + found := false + value.ForEach(func(_, item gjson.Result) bool { + if openAIJSONValueMayContainEmptyBase64InputImage(item) { + found = true + return false + } + return true + }) + return found + } + if value.IsObject() { + if strings.TrimSpace(value.Get("type").String()) == "input_image" && isEmptyBase64DataURI(value.Get("image_url").String()) { + return true + } + return openAIJSONValueMayContainEmptyBase64InputImage(value.Get("content")) + } + return false +} + +func sanitizeEmptyBase64InputImagesInOpenAIBody(body []byte) ([]byte, bool, error) { + if !openAIRequestBodyMayContainEmptyBase64InputImage(body) { + return body, false, nil + } + + var reqBody map[string]any + if err := json.Unmarshal(body, &reqBody); err != nil { + return body, false, fmt.Errorf("sanitize request body: %w", err) + } + if !sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody) { + return body, false, nil + } + normalized, err := marshalOpenAIUpstreamJSON(reqBody) + if err != nil { + return body, false, fmt.Errorf("serialize sanitized request body: %w", err) + } + return normalized, true, nil +} + +func sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody map[string]any) bool { + if reqBody == nil { + return false + } + input, ok := reqBody["input"] + if !ok { + return false + } + normalizedInput, changed := sanitizeEmptyBase64InputImagesInOpenAIInput(input) + if !changed { + return false + } + reqBody["input"] = normalizedInput + return true +} + +func sanitizeEmptyBase64InputImagesInOpenAIInput(input any) (any, bool) { + items, ok := input.([]any) + if !ok { + return input, false + } + + normalizedItems := make([]any, 0, len(items)) + changed := false + for _, item := range items { + itemMap, ok := item.(map[string]any) + if !ok { + normalizedItems = append(normalizedItems, item) + continue + } + if shouldDropEmptyBase64InputImagePart(itemMap) { + changed = true + continue + } + content, ok := itemMap["content"] + if !ok { + normalizedItems = append(normalizedItems, itemMap) + continue + } + parts, ok := content.([]any) + if !ok { + normalizedItems = append(normalizedItems, itemMap) + continue + } + + normalizedParts := make([]any, 0, len(parts)) + itemChanged := false + for _, part := range parts { + if shouldDropEmptyBase64InputImagePart(part) { + changed = true + itemChanged = true + continue + } + normalizedParts = append(normalizedParts, part) + } + if itemChanged { + if len(normalizedParts) == 0 { + continue + } + itemMap["content"] = normalizedParts + } + normalizedItems = append(normalizedItems, itemMap) + } + if !changed { + return input, false + } + return normalizedItems, true +} + +func shouldDropEmptyBase64InputImagePart(part any) bool { + partMap, ok := part.(map[string]any) + if !ok { + return false + } + typeValue, _ := partMap["type"].(string) + if strings.TrimSpace(typeValue) != "input_image" { + return false + } + imageURL, _ := partMap["image_url"].(string) + return isEmptyBase64DataURI(imageURL) +} + +func isEmptyBase64DataURI(raw string) bool { + if !strings.HasPrefix(raw, "data:") { + return false + } + rest := strings.TrimPrefix(raw, "data:") + semicolonIdx := strings.Index(rest, ";") + if semicolonIdx < 0 { + return false + } + rest = rest[semicolonIdx+1:] + if !strings.HasPrefix(rest, "base64,") { + return false + } + return strings.TrimSpace(strings.TrimPrefix(rest, "base64,")) == "" +} + +func getOpenAIRequestBodyMap(_ *gin.Context, body []byte) (map[string]any, error) { + var reqBody map[string]any + if err := json.Unmarshal(body, &reqBody); err != nil { + return nil, fmt.Errorf("parse request: %w", err) + } + return reqBody, nil +} + +func extractOpenAIReasoningEffort(reqBody map[string]any, requestedModel string) *string { + if value, present := getOpenAIReasoningEffortFromReqBody(reqBody); present { + if value == "" { + return nil + } + return &value + } + + value := deriveOpenAIReasoningEffortFromModel(requestedModel) + if value == "" { + return nil + } + return &value +} + +func normalizeOpenAIReasoningEffort(raw string) string { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + return "" + } + + // Normalize separators for "x-high"/"x_high" variants. + value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) + + switch value { + case "none", "minimal": + return "" + case "low", "medium", "high": + return value + case "xhigh", "extrahigh", "max": + return "xhigh" + default: + // Only store known effort levels for now to keep UI consistent. + return "" + } +} diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go new file mode 100644 index 0000000000..d17fe410e0 --- /dev/null +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -0,0 +1,1125 @@ +package service + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// openaiStreamingResult streaming response result +type openaiStreamingResult struct { + usage *OpenAIUsage + firstTokenMs *int + responseID string + imageCount int + imageOutputSizes []string +} + +type openaiNonStreamingResult struct { + *OpenAIUsage + usage *OpenAIUsage + responseID string + imageCount int + imageOutputSizes []string +} + +func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) { + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + } + + // Set SSE response headers + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + // Pass through other headers + if v := resp.Header.Get("x-request-id"); v != "" { + c.Header("x-request-id", v) + } + + w := c.Writer + flusher, ok := w.(http.Flusher) + if !ok { + return nil, errors.New("streaming not supported") + } + bufferedWriter := bufio.NewWriterSize(w, 4*1024) + flushBuffered := func() error { + if err := bufferedWriter.Flush(); err != nil { + return err + } + flusher.Flush() + return nil + } + + usage := &OpenAIUsage{} + imageCounter := newOpenAIImageOutputCounter() + var firstTokenMs *int + responseID := "" + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanBuf := getSSEScannerBuf64K() + scanner.Buffer(scanBuf[:0], maxLineSize) + + streamInterval := time.Duration(0) + if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 { + streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second + } + // 仅监控上游数据间隔超时,不被下游写入阻塞影响 + var intervalTicker *time.Ticker + if streamInterval > 0 { + intervalTicker = time.NewTicker(streamInterval) + defer intervalTicker.Stop() + } + var intervalCh <-chan time.Time + if intervalTicker != nil { + intervalCh = intervalTicker.C + } + + keepaliveInterval := time.Duration(0) + if s.cfg != nil && s.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + // 下游 keepalive 仅用于防止代理空闲断开 + var keepaliveTicker *time.Ticker + if keepaliveInterval > 0 { + keepaliveTicker = time.NewTicker(keepaliveInterval) + defer keepaliveTicker.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTicker != nil { + keepaliveCh = keepaliveTicker.C + } + // Track downstream writes separately from upstream reads: pre-output failover + // can buffer response.created / response.in_progress, so keepalive must be + // based on downstream idle time. + lastDownstreamWriteAt := time.Now() + + // 仅发送一次错误事件,避免多次写入导致协议混乱。 + // 注意:OpenAI `/v1/responses` streaming 事件必须符合 OpenAI Responses schema; + // 否则下游 SDK(例如 OpenCode)会因为类型校验失败而报错。 + errorEventSent := false + clientDisconnected := false // 客户端断开后继续 drain 上游以收集 usage + sawTerminalEvent := false + sawFailedEvent := false + failedMessage := "" + clientOutputStarted := false + upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) + var streamFailoverErr error + sendErrorEvent := func(reason string) { + if errorEventSent || clientDisconnected { + return + } + errorEventSent = true + payload := `{"type":"error","sequence_number":0,"error":{"type":"upstream_error","message":` + strconv.Quote(reason) + `,"code":` + strconv.Quote(reason) + `}}` + if err := flushBuffered(); err != nil { + clientDisconnected = true + return + } + if _, err := bufferedWriter.WriteString("data: " + payload + "\n\n"); err != nil { + clientDisconnected = true + return + } + if err := flushBuffered(); err != nil { + clientDisconnected = true + return + } + clientOutputStarted = true + lastDownstreamWriteAt = time.Now() + } + + needModelReplace := originalModel != mappedModel + streamOutputAccumulator := apicompat.NewBufferedResponseAccumulator() + streamImageOutputs := make([]json.RawMessage, 0, 1) + streamSeenImages := make(map[string]struct{}) + resultWithUsage := func() *openaiStreamingResult { + return &openaiStreamingResult{ + usage: usage, + firstTokenMs: firstTokenMs, + responseID: responseID, + imageCount: imageCounter.Count(), + imageOutputSizes: imageCounter.Sizes(), + } + } + finalizeStream := func() (*openaiStreamingResult, error) { + if !sawTerminalEvent { + if !openAIStreamClientOutputStarted(c, clientOutputStarted) { + return resultWithUsage(), s.newOpenAIStreamFailoverError( + c, + account, + false, + upstreamRequestID, + nil, + "OpenAI stream ended before a terminal event", + ) + } + return resultWithUsage(), fmt.Errorf("stream usage incomplete: missing terminal event") + } + if sawFailedEvent { + return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage) + } + if !clientDisconnected { + hadBufferedData := bufferedWriter.Buffered() > 0 + if err := flushBuffered(); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during final flush, returning collected usage") + } else if hadBufferedData { + clientOutputStarted = true + lastDownstreamWriteAt = time.Now() + } + } + return resultWithUsage(), nil + } + handleScanErr := func(scanErr error) (*openaiStreamingResult, error, bool) { + if scanErr == nil { + return nil, nil, false + } + if sawTerminalEvent && !sawFailedEvent { + logger.LegacyPrintf("service.openai_gateway", "Upstream scan ended after terminal event: %v", scanErr) + return resultWithUsage(), nil, true + } + if sawFailedEvent { + return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage), true + } + // 客户端断开/取消请求时,上游读取往往会返回 context canceled。 + // /v1/responses 的 SSE 事件必须符合 OpenAI 协议;这里不注入自定义 error event,避免下游 SDK 解析失败。 + if errors.Is(scanErr, context.Canceled) || errors.Is(scanErr, context.DeadlineExceeded) { + return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", scanErr), true + } + if errors.Is(scanErr, bufio.ErrTooLong) { + logger.LegacyPrintf("service.openai_gateway", "SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, scanErr) + sendErrorEvent("response_too_large") + return resultWithUsage(), scanErr, true + } + if !openAIStreamClientOutputStarted(c, clientOutputStarted) { + msg := "OpenAI stream disconnected before completion" + if errText := strings.TrimSpace(scanErr.Error()); errText != "" { + msg += ": " + errText + } + return resultWithUsage(), s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, nil, msg), true + } + // 客户端已断开时,上游出错仅影响体验,不影响计费;返回已收集 usage + if clientDisconnected { + return resultWithUsage(), fmt.Errorf("stream usage incomplete after disconnect: %w", scanErr), true + } + sendErrorEvent("stream_read_error") + return resultWithUsage(), fmt.Errorf("stream read error: %w", scanErr), true + } + processSSELine := func(line string, queueDrained bool) { + if streamFailoverErr != nil { + return + } + // Extract data from SSE line (supports both "data: " and "data:" formats) + if data, ok := extractOpenAISSEDataLine(line); ok { + dataBytes := []byte(data) + if openAIStreamEventIsTerminal(data) { + sawTerminalEvent = true + } + eventType := strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) + if responseID == "" { + responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes) + } + forceFlushFailedEvent := false + if eventType == "response.failed" { + failedMessage = extractOpenAISSEErrorMessage(dataBytes) + // response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析 + // 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费 + // 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。 + s.parseSSEUsageBytes(dataBytes, usage) + if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit { + MarkOpsCyberPolicy(c, CyberPolicyMark{ + Code: code, + Message: msg, + Body: truncateString(string(dataBytes), 4096), + UpstreamStatus: http.StatusOK, + UpstreamInTok: usage.InputTokens, + UpstreamOutTok: usage.OutputTokens, + }) + } else if !openAIStreamClientOutputStarted(c, clientOutputStarted) && openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) { + sawFailedEvent = true + streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, failedMessage) + return + } + forceFlushFailedEvent = true + sawFailedEvent = true + } + imageCounter.AddSSEData(dataBytes) + + // Correct Codex tool calls if needed (apply_patch -> edit, etc.) + if correctedData, corrected := s.toolCorrector.CorrectToolCallsInSSEBytes(dataBytes); corrected { + dataBytes = correctedData + data = string(correctedData) + line = "data: " + data + eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) + } + if imageOutput, ok := extractImageGenerationOutputFromSSEData(dataBytes, streamSeenImages); ok { + streamImageOutputs = append(streamImageOutputs, imageOutput) + } + if responsesStreamEventMayContributeToOutput(eventType) { + var streamEvent apicompat.ResponsesStreamEvent + if err := json.Unmarshal(dataBytes, &streamEvent); err == nil { + streamOutputAccumulator.ProcessEvent(&streamEvent) + } + } + if normalizedData, normalized := normalizeResponsesStreamingTerminalOutput(dataBytes, streamOutputAccumulator, streamImageOutputs); normalized { + dataBytes = normalizedData + data = string(normalizedData) + line = "data: " + data + eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) + } + if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient(dataBytes, eventType); sanitized { + dataBytes = sanitizedData + data = string(sanitizedData) + line = "data: " + data + } + // Replace model in response if needed. + // Fast path: most events do not contain model field values. + if needModelReplace && mappedModel != "" && strings.Contains(line, mappedModel) { + line = s.replaceModelInSSELine(line, mappedModel, originalModel) + } + startsClientOutput := forceFlushFailedEvent || openAIStreamDataStartsClientOutput(data, eventType) + + // 写入客户端(客户端断开后继续 drain 上游) + if !clientDisconnected { + shouldFlush := queueDrained && (clientOutputStarted || startsClientOutput) + if firstTokenMs == nil && startsClientOutput { + // 保证首个 token 事件尽快出站,避免影响 TTFT。 + shouldFlush = true + } + if _, err := bufferedWriter.WriteString(line); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") + } else if _, err := bufferedWriter.WriteString("\n"); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") + } else if shouldFlush { + if err := flushBuffered(); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming flush, continuing to drain upstream for billing") + } else { + clientOutputStarted = true + lastDownstreamWriteAt = time.Now() + } + } + } + + // Record first token time + if firstTokenMs == nil && startsClientOutput { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + s.parseSSEUsageBytes(dataBytes, usage) + return + } + + // Forward non-data lines as-is + if !clientDisconnected { + if _, err := bufferedWriter.WriteString(line); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") + } else if _, err := bufferedWriter.WriteString("\n"); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") + } else if queueDrained && clientOutputStarted { + if err := flushBuffered(); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming flush, continuing to drain upstream for billing") + } else { + clientOutputStarted = true + lastDownstreamWriteAt = time.Now() + } + } + } + } + + // 无超时/无 keepalive 的常见路径走同步扫描,减少 goroutine 与 channel 开销。 + if streamInterval <= 0 && keepaliveInterval <= 0 { + defer putSSEScannerBuf64K(scanBuf) + for scanner.Scan() { + processSSELine(scanner.Text(), true) + if streamFailoverErr != nil { + return resultWithUsage(), streamFailoverErr + } + } + if result, err, done := handleScanErr(scanner.Err()); done { + return result, err + } + return finalizeStream() + } + + type scanEvent struct { + line string + err error + } + // 独立 goroutine 读取上游,避免读取阻塞影响 keepalive/超时处理 + events := make(chan scanEvent, 16) + done := make(chan struct{}) + sendEvent := func(ev scanEvent) bool { + select { + case events <- ev: + return true + case <-done: + return false + } + } + var lastReadAt int64 + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + go func(scanBuf *sseScannerBuf64K) { + defer putSSEScannerBuf64K(scanBuf) + defer close(events) + for scanner.Scan() { + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + if !sendEvent(scanEvent{line: scanner.Text()}) { + return + } + } + if err := scanner.Err(); err != nil { + _ = sendEvent(scanEvent{err: err}) + } + }(scanBuf) + defer close(done) + + for { + select { + case ev, ok := <-events: + if !ok { + return finalizeStream() + } + if result, err, done := handleScanErr(ev.err); done { + return result, err + } + processSSELine(ev.line, len(events) == 0) + if streamFailoverErr != nil { + return resultWithUsage(), streamFailoverErr + } + + case <-intervalCh: + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) < streamInterval { + continue + } + if clientDisconnected { + return resultWithUsage(), fmt.Errorf("stream usage incomplete after timeout") + } + logger.LegacyPrintf("service.openai_gateway", "Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval) + // 处理流超时,可能标记账户为临时不可调度或错误状态 + if s.rateLimitService != nil { + s.rateLimitService.HandleStreamTimeout(ctx, account, originalModel) + } + sendErrorEvent("stream_timeout") + return resultWithUsage(), fmt.Errorf("stream data interval timeout") + + case <-keepaliveCh: + if clientDisconnected { + continue + } + if time.Since(lastDownstreamWriteAt) < keepaliveInterval { + continue + } + if _, err := bufferedWriter.WriteString(":\n\n"); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") + continue + } + if err := flushBuffered(); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "Client disconnected during keepalive flush, continuing to drain upstream for billing") + } else { + lastDownstreamWriteAt = time.Now() + } + } + } + +} + +// extractOpenAISSEDataLine 低开销提取 SSE `data:` 行内容。 +// 兼容 `data: xxx` 与 `data:xxx` 两种格式。 +func extractOpenAISSEDataLine(line string) (string, bool) { + if !strings.HasPrefix(line, "data:") { + return "", false + } + start := len("data:") + for start < len(line) { + if line[start] != ' ' && line[start] != ' ' { + break + } + start++ + } + return line[start:], true +} + +func extractOpenAISSEEventLine(line string) (string, bool) { + if !strings.HasPrefix(line, "event:") { + return "", false + } + start := len("event:") + for start < len(line) { + if line[start] != ' ' && line[start] != ' ' { + break + } + start++ + } + return strings.TrimSpace(line[start:]), true +} + +type openAICompatSSEFrame struct { + EventType string + Data string +} + +type openAICompatSSEFrameParser struct { + eventType string + dataLines []string +} + +func (p *openAICompatSSEFrameParser) AddLine(line string) (openAICompatSSEFrame, bool) { + if line == "" { + return p.dispatch() + } + if strings.HasPrefix(line, ":") { + return openAICompatSSEFrame{}, false + } + if eventType, ok := extractOpenAISSEEventLine(line); ok { + p.eventType = eventType + return openAICompatSSEFrame{}, false + } + if data, ok := extractOpenAISSEDataLine(line); ok { + p.dataLines = append(p.dataLines, data) + } + return openAICompatSSEFrame{}, false +} + +func (p *openAICompatSSEFrameParser) Finish() (openAICompatSSEFrame, bool) { + return p.dispatch() +} + +func (p *openAICompatSSEFrameParser) dispatch() (openAICompatSSEFrame, bool) { + frame := openAICompatSSEFrame{ + EventType: p.eventType, + Data: strings.Join(p.dataLines, "\n"), + } + p.eventType = "" + p.dataLines = nil + return frame, frame.Data != "" +} + +func openAICompatPayloadWithEventType(payload, eventType string) string { + eventType = strings.TrimSpace(eventType) + if eventType == "" || strings.TrimSpace(payload) == "" || strings.TrimSpace(payload) == "[DONE]" { + return payload + } + if gjson.Get(payload, "type").Exists() { + return payload + } + patched, err := sjson.Set(payload, "type", eventType) + if err != nil { + return payload + } + return patched +} + +func (s *OpenAIGatewayService) replaceModelInSSELine(line, fromModel, toModel string) string { + data, ok := extractOpenAISSEDataLine(line) + if !ok { + return line + } + if data == "" || data == "[DONE]" { + return line + } + + // 使用 gjson 精确检查 model 字段,避免全量 JSON 反序列化 + if m := gjson.Get(data, "model"); m.Exists() && m.Str == fromModel { + newData, err := sjson.Set(data, "model", toModel) + if err != nil { + return line + } + return "data: " + newData + } + + // 检查嵌套的 response.model 字段 + if m := gjson.Get(data, "response.model"); m.Exists() && m.Str == fromModel { + newData, err := sjson.Set(data, "response.model", toModel) + if err != nil { + return line + } + return "data: " + newData + } + + return line +} + +// correctToolCallsInResponseBody 修正响应体中的工具调用 +func (s *OpenAIGatewayService) correctToolCallsInResponseBody(body []byte) []byte { + if len(body) == 0 { + return body + } + + updated := body + if s != nil && s.toolCorrector != nil { + if corrected, changed := s.toolCorrector.CorrectToolCallsInSSEBytes(updated); changed { + updated = corrected + } + } + if normalized, changed := normalizeOpenAIResponsesFunctionCallArguments(updated); changed { + updated = normalized + } + return updated +} + +func normalizeOpenAIResponsesFunctionCallArguments(data []byte) ([]byte, bool) { + if len(bytes.TrimSpace(data)) == 0 || !bytes.Contains(data, []byte(`"arguments"`)) { + return data, false + } + if !gjson.ValidBytes(data) { + return data, false + } + + updated := data + changed := false + setDedupedArgument := func(path string) { + arg := gjson.GetBytes(updated, path) + if !arg.Exists() || arg.Type != gjson.String { + return + } + deduped, ok := dedupeRepeatedJSONArgumentString(arg.Str) + if !ok { + return + } + next, err := sjson.SetBytes(updated, path, deduped) + if err != nil { + return + } + updated = next + changed = true + } + + eventType := strings.TrimSpace(gjson.GetBytes(updated, "type").String()) + if eventType == "response.function_call_arguments.done" { + setDedupedArgument("arguments") + } + if itemType := strings.TrimSpace(gjson.GetBytes(updated, "item.type").String()); isResponsesFunctionCallItemType(itemType) { + setDedupedArgument("item.arguments") + } + dedupeResponsesFunctionCallOutputArguments(updated, "response.output", setDedupedArgument) + dedupeResponsesFunctionCallOutputArguments(updated, "output", setDedupedArgument) + + return updated, changed +} + +func dedupeResponsesFunctionCallOutputArguments(data []byte, outputPath string, setDedupedArgument func(string)) { + output := gjson.GetBytes(data, outputPath) + if !output.Exists() || !output.IsArray() { + return + } + for i, item := range output.Array() { + if !isResponsesFunctionCallItemType(strings.TrimSpace(item.Get("type").String())) { + continue + } + setDedupedArgument(outputPath + "." + strconv.Itoa(i) + ".arguments") + } +} + +func isResponsesFunctionCallItemType(itemType string) bool { + return itemType == "function_call" || itemType == "custom_tool_call" +} + +func dedupeRepeatedJSONArgumentString(arguments string) (string, bool) { + if len(arguments) == 0 || len(arguments)%2 != 0 { + return "", false + } + halfLen := len(arguments) / 2 + first := arguments[:halfLen] + if first != arguments[halfLen:] { + return "", false + } + trimmed := strings.TrimSpace(first) + if trimmed == "" || (!strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[")) { + return "", false + } + if !json.Valid([]byte(first)) { + return "", false + } + return first, true +} + +func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) { + s.parseSSEUsageBytes([]byte(data), usage) +} + +func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsage) { + if usage == nil || len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { + return + } + // 选择性解析:仅在数据中包含终止事件标识时才进入字段提取。 + if len(data) < 72 { + return + } + eventType := gjson.GetBytes(data, "type").String() + if eventType != "response.completed" && eventType != "response.done" && eventType != "response.failed" && + eventType != "response.incomplete" && eventType != "response.cancelled" && eventType != "response.canceled" { + return + } + + if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(data); ok { + *usage = parsedUsage + } +} + +func extractOpenAIUsageFromJSONBytes(body []byte) (OpenAIUsage, bool) { + if len(body) == 0 || !gjson.ValidBytes(body) { + return OpenAIUsage{}, false + } + if usage, ok := openAIUsageFromGJSON(gjson.GetBytes(body, "usage")); ok { + return usage, true + } + return openAIUsageFromGJSON(gjson.GetBytes(body, "response.usage")) +} + +func extractOpenAIResponseIDFromJSONBytes(body []byte) string { + if len(body) == 0 || !gjson.ValidBytes(body) { + return "" + } + if id := strings.TrimSpace(gjson.GetBytes(body, "id").String()); id != "" { + return id + } + return strings.TrimSpace(gjson.GetBytes(body, "response.id").String()) +} + +func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *gin.Context, account *Account, responseID string) { + if s == nil || account == nil || account.ID <= 0 { + return + } + responseID = strings.TrimSpace(responseID) + if responseID == "" { + return + } + store := s.getOpenAIWSStateStore() + if store == nil { + return + } + groupID := getOpenAIGroupIDFromContext(c) + ttl := s.openAIWSResponseStickyTTL() + logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) +} + +func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { + if !value.Exists() || !value.IsObject() { + return OpenAIUsage{}, false + } + inputTokens := value.Get("input_tokens").Int() + if inputTokens == 0 { + inputTokens = value.Get("prompt_tokens").Int() + } + outputTokens := value.Get("output_tokens").Int() + if outputTokens == 0 { + outputTokens = value.Get("completion_tokens").Int() + } + cacheReadTokens := value.Get("input_tokens_details.cached_tokens").Int() + if cacheReadTokens == 0 { + cacheReadTokens = value.Get("prompt_tokens_details.cached_tokens").Int() + } + imageOutputTokens := value.Get("output_tokens_details.image_tokens").Int() + if imageOutputTokens == 0 { + imageOutputTokens = value.Get("completion_tokens_details.image_tokens").Int() + } + return OpenAIUsage{ + InputTokens: int(inputTokens), + OutputTokens: int(outputTokens), + CacheCreationInputTokens: int(value.Get("cache_creation_input_tokens").Int()), + CacheReadInputTokens: int(cacheReadTokens), + ImageOutputTokens: int(imageOutputTokens), + }, true +} + +func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { + body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) + if err != nil { + return nil, err + } + + // Detect SSE responses for ALL account types via Content-Type header. + // Some OpenAI-compatible upstreams (including other sub2api instances) + // may return SSE even when stream=false was requested. + if isEventStreamResponse(resp.Header) { + return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) + } + // bodyLooksLikeSSE is a line-level heuristic: real SSE framing requires + // "data:"/"event:" field names at the very start of a physical line. A + // plain bytes.Contains scan would also match ordinary JSON responses + // whose string content merely echoes the literal text "data:" or + // "event:" (e.g. compact tool output), causing those JSON bodies to be + // misrouted into handleSSEToJSON and lose their usage accounting. + bodyLooksLikeSSE := bodyHasSSEFraming(body) + + // For OAuth accounts, also fall back to a body-content heuristic because + // the upstream may omit the Content-Type header while still sending SSE. + // This heuristic is NOT applied to API-key accounts to avoid false + // positives on JSON responses that coincidentally contain "data:" or + // "event:" in their text content. + if account.Type == AccountTypeOAuth && bodyLooksLikeSSE { + return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) + } + + usageValue, usageOK := extractOpenAIUsageFromJSONBytes(body) + if !usageOK { + if bodyLooksLikeSSE { + return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) + } + return nil, fmt.Errorf("parse response: invalid json response") + } + usage := &usageValue + + // Replace model in response if needed + if originalModel != mappedModel { + body = s.replaceModelInResponseBody(body, mappedModel, originalModel) + } + + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + + contentType := "application/json" + if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled { + if upstreamType := resp.Header.Get("Content-Type"); upstreamType != "" { + contentType = upstreamType + } + } + + c.Data(resp.StatusCode, contentType, body) + + return &openaiNonStreamingResult{ + OpenAIUsage: usage, + usage: usage, + responseID: extractOpenAIResponseIDFromJSONBytes(body), + imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body), + imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body), + }, nil +} + +func isEventStreamResponse(header http.Header) bool { + contentType := strings.ToLower(header.Get("Content-Type")) + return strings.Contains(contentType, "text/event-stream") +} + +// bodyHasSSEFraming reports whether body contains genuine SSE framing by +// scanning for physical lines that begin with the "data:" or "event:" +// field names, per the SSE spec. Unlike a raw substring scan, this does not +// match when those strings only appear embedded inside JSON string values +// (e.g. "data: foo" quoted as part of an assistant text field), since such +// occurrences never start a physical line in a valid JSON encoding. +func bodyHasSSEFraming(body []byte) bool { + for _, line := range bytes.Split(body, []byte("\n")) { + line = bytes.TrimRight(line, "\r") + if bytes.HasPrefix(line, []byte("data:")) || bytes.HasPrefix(line, []byte("event:")) { + return true + } + } + return false +} + +func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { + bodyText := string(body) + finalResponse, ok := extractCodexFinalResponse(bodyText) + + usage := &OpenAIUsage{} + if ok { + if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed { + *usage = parsedUsage + } + // When the terminal event has an empty output array, reconstruct + // output from accumulated delta events so the client gets full content. + // gjson Array() returns empty slice for null, missing, or empty arrays. + if len(gjson.GetBytes(finalResponse, "output").Array()) == 0 { + if outputJSON, reconstructed := reconstructResponseOutputFromSSE(bodyText); reconstructed { + if patched, err := sjson.SetRawBytes(finalResponse, "output", outputJSON); err == nil { + finalResponse = patched + } + } + } + body = finalResponse + if originalModel != mappedModel { + body = s.replaceModelInResponseBody(body, mappedModel, originalModel) + } + // Correct tool calls in final response + body = s.correctToolCallsInResponseBody(body) + } else { + terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText) + if terminalOK && terminalType == "response.failed" { + msg := extractOpenAISSEErrorMessage(terminalPayload) + if msg == "" { + msg = "Upstream compact response failed" + } + return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg) + } + usage = s.parseSSEUsageFromBody(bodyText) + if originalModel != mappedModel { + bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel) + } + body = []byte(bodyText) + } + + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + + contentType := "application/json; charset=utf-8" + if !ok { + contentType = resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "text/event-stream" + } + } + c.Data(resp.StatusCode, contentType, body) + + return &openaiNonStreamingResult{ + OpenAIUsage: usage, + usage: usage, + responseID: extractOpenAIResponseIDFromJSONBytes(body), + imageCount: countOpenAIImageOutputsFromSSEBody(bodyText), + imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText), + }, nil +} + +func extractOpenAISSETerminalEvent(body string) (string, []byte, bool) { + var terminalType string + var terminalPayload []byte + forEachOpenAISSEDataPayload(body, func(data []byte) { + if terminalPayload != nil { + return + } + eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) + switch eventType { + case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + terminalType = eventType + terminalPayload = append([]byte(nil), data...) + } + }) + if terminalPayload != nil { + return terminalType, terminalPayload, true + } + return "", nil, false +} + +func extractOpenAISSEErrorMessage(payload []byte) string { + if len(payload) == 0 { + return "" + } + for _, path := range []string{"response.error.message", "error.message", "message"} { + if msg := strings.TrimSpace(gjson.GetBytes(payload, path).String()); msg != "" { + return sanitizeUpstreamErrorMessage(msg) + } + } + return sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(payload))) +} + +func sanitizeOpenAIResponseFailedEventForClient(payload []byte, eventType string) ([]byte, bool) { + if eventType != "response.failed" || len(payload) == 0 || !gjson.ValidBytes(payload) { + return payload, false + } + if !gjson.GetBytes(payload, "response").Exists() { + return payload, false + } + updated := payload + for _, path := range []string{ + "response.instructions", + "response.output", + "response.usage", + "response.metadata", + "response.reasoning", + "response.tools", + "response.tool_choice", + "response.parallel_tool_calls", + "response.text", + "response.truncation", + "response.max_output_tokens", + "response.incomplete_details", + } { + next, err := sjson.DeleteBytes(updated, path) + if err != nil { + return payload, false + } + updated = next + } + return updated, !bytes.Equal(updated, payload) +} + +func (s *OpenAIGatewayService) writeOpenAINonStreamingProtocolError(resp *http.Response, c *gin.Context, message string) error { + message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message)) + if message == "" { + message = "Upstream returned an invalid non-streaming response" + } + setOpsUpstreamError(c, http.StatusBadGateway, message, "") + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + c.JSON(http.StatusBadGateway, gin.H{ + "error": gin.H{ + "type": "upstream_error", + "message": message, + }, + }) + return fmt.Errorf("non-streaming openai protocol error: %s", message) +} + +func extractCodexFinalResponse(body string) ([]byte, bool) { + var finalResponse []byte + forEachOpenAISSEDataPayload(body, func(data []byte) { + if finalResponse != nil { + return + } + eventType := gjson.GetBytes(data, "type").String() + if eventType == "response.done" || eventType == "response.completed" { + if response := gjson.GetBytes(data, "response"); response.Exists() && response.Type == gjson.JSON && response.Raw != "" { + finalResponse = []byte(response.Raw) + } + } + }) + if finalResponse != nil { + return finalResponse, true + } + return nil, false +} + +func normalizeResponsesStreamingTerminalOutput(data []byte, acc *apicompat.BufferedResponseAccumulator, imageOutputs []json.RawMessage) ([]byte, bool) { + eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) + switch eventType { + case "response.completed", "response.done", "response.incomplete", "response.cancelled", "response.canceled": + default: + return data, false + } + + output := gjson.GetBytes(data, "response.output") + hasAccumulatedOutput := (acc != nil && acc.HasContent()) || len(imageOutputs) > 0 + if output.Exists() && output.IsArray() { + if len(output.Array()) > 0 || !hasAccumulatedOutput { + return data, false + } + } + + outputJSON := []byte("[]") + if reconstructed, ok := buildResponsesOutputJSON(acc, imageOutputs); ok { + outputJSON = reconstructed + } + updated, err := sjson.SetRawBytes(data, "response.output", outputJSON) + if err != nil { + return data, false + } + return updated, true +} + +func responsesStreamEventMayContributeToOutput(eventType string) bool { + switch eventType { + case "response.output_text.delta", + "response.output_item.added", + "response.function_call_arguments.delta", + "response.reasoning_summary_text.delta": + return true + default: + return false + } +} + +// reconstructResponseOutputFromSSE scans raw SSE body text for delta events and +// returns a JSON-encoded output array reconstructed from accumulated deltas. +// Returns (nil, false) if no content was found in deltas. +func reconstructResponseOutputFromSSE(bodyText string) ([]byte, bool) { + acc := apicompat.NewBufferedResponseAccumulator() + imageOutputs := make([]json.RawMessage, 0, 1) + seenImages := make(map[string]struct{}) + forEachOpenAISSEDataPayload(bodyText, func(data []byte) { + if imageOutput, ok := extractImageGenerationOutputFromSSEData(data, seenImages); ok { + imageOutputs = append(imageOutputs, imageOutput) + } + eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) + if responsesStreamEventMayContributeToOutput(eventType) { + var event apicompat.ResponsesStreamEvent + if err := json.Unmarshal(data, &event); err == nil { + acc.ProcessEvent(&event) + } + } + }) + return buildResponsesOutputJSON(acc, imageOutputs) +} + +func buildResponsesOutputJSON(acc *apicompat.BufferedResponseAccumulator, imageOutputs []json.RawMessage) ([]byte, bool) { + if (acc == nil || !acc.HasContent()) && len(imageOutputs) == 0 { + return nil, false + } + var output []json.RawMessage + if acc != nil && acc.HasContent() { + outputJSON, err := json.Marshal(acc.BuildOutput()) + if err == nil { + _ = json.Unmarshal(outputJSON, &output) + } + } + output = append(output, imageOutputs...) + if len(output) == 0 { + return nil, false + } + + outputJSON, err := json.Marshal(output) + if err != nil { + return nil, false + } + return outputJSON, true +} + +func extractImageGenerationOutputFromSSEData(data []byte, seen map[string]struct{}) (json.RawMessage, bool) { + if len(data) == 0 || !gjson.ValidBytes(data) { + return nil, false + } + if gjson.GetBytes(data, "type").String() != "response.output_item.done" { + return nil, false + } + item := gjson.GetBytes(data, "item") + if !item.Exists() || !item.IsObject() || item.Get("type").String() != "image_generation_call" { + return nil, false + } + if strings.TrimSpace(item.Get("result").String()) == "" { + return nil, false + } + key := strings.TrimSpace(item.Get("id").String()) + if key == "" { + key = strings.TrimSpace(item.Get("output_format").String()) + "|" + strings.TrimSpace(item.Get("result").String()) + } + if key != "" && seen != nil { + if _, exists := seen[key]; exists { + return nil, false + } + seen[key] = struct{}{} + } + return json.RawMessage(item.Raw), true +} + +func (s *OpenAIGatewayService) parseSSEUsageFromBody(body string) *OpenAIUsage { + usage := &OpenAIUsage{} + forEachOpenAISSEDataPayload(body, func(data []byte) { + s.parseSSEUsageBytes(data, usage) + }) + return usage +} + +func (s *OpenAIGatewayService) replaceModelInSSEBody(body, fromModel, toModel string) string { + lines := strings.Split(body, "\n") + for i, line := range lines { + if _, ok := extractOpenAISSEDataLine(line); !ok { + continue + } + lines[i] = s.replaceModelInSSELine(line, fromModel, toModel) + } + return strings.Join(lines, "\n") +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 935f4d9c45..c1d1670e07 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1,37 +1,27 @@ package service import ( - "bufio" - "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" - "io" "log/slog" "math/rand" "net/http" - "strconv" "strings" "sync" "sync/atomic" "time" "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" - "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" "github.com/cespare/xxhash/v2" "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" "go.uber.org/zap" ) @@ -1047,198 +1037,6 @@ func hashSensitiveValueForLog(raw string) string { return hex.EncodeToString(sum[:8]) } -func logOpenAIInstructionsRequiredDebug( - ctx context.Context, - c *gin.Context, - account *Account, - upstreamStatusCode int, - upstreamMsg string, - requestBody []byte, - upstreamBody []byte, -) { - msg := strings.TrimSpace(upstreamMsg) - if !isOpenAIInstructionsRequiredError(upstreamStatusCode, msg, upstreamBody) { - return - } - if ctx == nil { - ctx = context.Background() - } - - accountID := int64(0) - accountName := "" - if account != nil { - accountID = account.ID - accountName = strings.TrimSpace(account.Name) - } - - userAgent := "" - originator := "" - if c != nil { - userAgent = strings.TrimSpace(c.GetHeader("User-Agent")) - originator = strings.TrimSpace(c.GetHeader("originator")) - } - - fields := []zap.Field{ - zap.String("component", "service.openai_gateway"), - zap.Int64("account_id", accountID), - zap.String("account_name", accountName), - zap.Int("upstream_status_code", upstreamStatusCode), - zap.String("upstream_error_message", msg), - zap.String("request_user_agent", userAgent), - zap.Bool("codex_official_client_match", openai.IsCodexOfficialClientByHeaders(userAgent, originator)), - } - fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, requestBody) - - logger.FromContext(ctx).With(fields...).Warn("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查") -} - -func isOpenAIInstructionsRequiredError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool { - if upstreamStatusCode != http.StatusBadRequest { - return false - } - - hasInstructionRequired := func(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - if strings.Contains(lower, "instructions are required") { - return true - } - if strings.Contains(lower, "required parameter: 'instructions'") { - return true - } - if strings.Contains(lower, "required parameter: instructions") { - return true - } - if strings.Contains(lower, "missing required parameter") && strings.Contains(lower, "instructions") { - return true - } - return strings.Contains(lower, "instruction") && strings.Contains(lower, "required") - } - - if hasInstructionRequired(upstreamMsg) { - return true - } - if len(upstreamBody) == 0 { - return false - } - - errMsg := gjson.GetBytes(upstreamBody, "error.message").String() - errMsgLower := strings.ToLower(strings.TrimSpace(errMsg)) - errCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.code").String())) - errParam := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.param").String())) - errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.type").String())) - - if errParam == "instructions" { - return true - } - if hasInstructionRequired(errMsg) { - return true - } - if strings.Contains(errCode, "missing_required_parameter") && strings.Contains(errMsgLower, "instructions") { - return true - } - if strings.Contains(errType, "invalid_request") && strings.Contains(errMsgLower, "instructions") && strings.Contains(errMsgLower, "required") { - return true - } - - return false -} - -func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool { - if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable { - return false - } - - hasOpenAIServerOverloadedCode := func(payload []byte) bool { - code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String())) - if code == "" { - code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String())) - } - return code == "server_is_overloaded" || code == "slow_down" - } - - if len(upstreamBody) > 0 && hasOpenAIServerOverloadedCode(upstreamBody) { - return true - } - if upstreamStatusCode != http.StatusBadRequest { - return false - } - - match := func(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - if strings.Contains(lower, "an error occurred while processing your request") { - return true - } - if strings.Contains(lower, "selected model is at capacity") { - return true - } - return strings.Contains(lower, "you can retry your request") && - strings.Contains(lower, "help.openai.com") && - strings.Contains(lower, "request id") - } - - if match(upstreamMsg) { - return true - } - if len(upstreamBody) == 0 { - return false - } - if match(gjson.GetBytes(upstreamBody, "error.message").String()) { - return true - } - return match(string(upstreamBody)) -} - -func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool { - match := func(text string) bool { - lower := strings.ToLower(strings.TrimSpace(text)) - if lower == "" { - return false - } - if strings.Contains(lower, "context_too_large") || strings.Contains(lower, "context_length_exceeded") { - return true - } - if strings.Contains(lower, "maximum context length") || strings.Contains(lower, "max context length") { - return true - } - hasExceeded := strings.Contains(lower, "exceed") || strings.Contains(lower, "too large") || strings.Contains(lower, "too long") - if strings.Contains(lower, "context window") && hasExceeded { - return true - } - if strings.Contains(lower, "context length") && hasExceeded { - return true - } - return strings.Contains(lower, "token limit") && - strings.Contains(lower, "context") && - hasExceeded - } - - if match(upstreamMsg) { - return true - } - if len(upstreamBody) == 0 { - return false - } - for _, path := range []string{ - "error.message", - "response.error.message", - "message", - "error.code", - "response.error.code", - "code", - } { - if match(gjson.GetBytes(upstreamBody, path).String()) { - return true - } - } - return match(string(upstreamBody)) -} - // GetAccessToken gets the access token for an OpenAI account func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Account) (string, string, error) { if account.IsShadow() { @@ -1295,3578 +1093,3 @@ func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Acco return "", "", fmt.Errorf("unsupported account type: %s", account.Type) } } - -func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool { - switch statusCode { - case 401, 402, 403, 429, 529: - return true - default: - return statusCode >= 500 - } -} - -func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode int, upstreamMsg string, upstreamBody []byte) bool { - if isOpenAIContextWindowError(upstreamMsg, upstreamBody) { - return false - } - if s.shouldFailoverUpstreamError(statusCode) { - return true - } - return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody) -} - -func marshalOpenAIUpstreamJSON(v any) ([]byte, error) { - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) - if err := enc.Encode(v); err != nil { - return nil, err - } - out := buf.Bytes() - if len(out) > 0 && out[len(out)-1] == '\n' { - out = out[:len(out)-1] - } - return out, nil -} - -func openAIUpstreamErrorBodyReadLimitForConfig(cfg *config.Config) int64 { - limit := openAIUpstreamErrorBodyReadLimit - if cfg != nil && cfg.Gateway.LogUpstreamErrorBody && cfg.Gateway.LogUpstreamErrorBodyMaxBytes > int(limit) { - limit = int64(cfg.Gateway.LogUpstreamErrorBodyMaxBytes) - } - return limit -} - -func (s *OpenAIGatewayService) readUpstreamErrorBody(resp *http.Response) []byte { - if resp == nil || resp.Body == nil { - return nil - } - cfg := (*config.Config)(nil) - if s != nil { - cfg = s.cfg - } - body, _ := io.ReadAll(io.LimitReader(resp.Body, openAIUpstreamErrorBodyReadLimitForConfig(cfg))) - return body -} - -func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, responseBody []byte, requestedModel ...string) { - if len(requestedModel) > 0 { - s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody, requestedModel[0]) - return - } - s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody) -} - -// Forward forwards request to OpenAI API -func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { - startTime := time.Now() - - restrictionResult := s.detectCodexClientRestriction(c, account, body) - apiKeyID := getAPIKeyIDFromContext(c) - logCodexCLIOnlyDetection(ctx, c, account, apiKeyID, restrictionResult, body) - if restrictionResult.Enabled && !restrictionResult.Matched { - MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) - c.JSON(http.StatusForbidden, gin.H{ - "error": gin.H{ - "type": "forbidden_error", - "message": CodexClientRestrictionMessage(restrictionResult), - }, - }) - return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed") - } - - originalBody := body - requestView := newOpenAIRequestView(body) - reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey - originalModel := reqModel - - if account.Platform == PlatformGrok { - _ = promptCacheKey - return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime) - } - - if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { - return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) - } - - compatMessagesBridge := isOpenAICompatMessagesBridgeBody(body) - setOpenAICompatMessagesBridgeContext(c, compatMessagesBridge) - - isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI) - wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) - clientTransport := GetOpenAIClientTransport(c) - // 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。 - wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, clientTransport) - if c != nil { - c.Set("openai_ws_transport_decision", string(wsDecision.Transport)) - c.Set("openai_ws_transport_reason", wsDecision.Reason) - } - if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 { - logOpenAIWSModeDebug( - "selected account_id=%d account_type=%s transport=%s reason=%s model=%s stream=%v", - account.ID, - account.Type, - normalizeOpenAIWSLogValue(string(wsDecision.Transport)), - normalizeOpenAIWSLogValue(wsDecision.Reason), - reqModel, - reqStream, - ) - } - // 当前仅支持 WSv2;WSv1 命中时直接返回错误,避免出现“配置可开但行为不确定”。 - if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocket { - if c != nil { - MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) - c.JSON(http.StatusBadRequest, gin.H{ - "error": gin.H{ - "type": "invalid_request_error", - "message": "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.", - }, - }) - } - return nil, errors.New("openai ws v1 is temporarily unsupported; use ws v2") - } - passthroughEnabled := account.IsOpenAIPassthroughEnabled() - if passthroughEnabled { - // 透传分支只需要轻量提取字段,避免热路径全量 Unmarshal。 - reasoningEffort := extractOpenAIReasoningEffortFromBody(body, reqModel) - // 国产模型默认 effort 补充:也要用 mappedModel 判定是否是 passback-required 上游。 - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, account.GetMappedModel(reqModel)) - return s.forwardOpenAIPassthrough(ctx, c, account, originalBody, reqModel, reasoningEffort, reqStream, startTime) - } - - bodyModified := false - var reqBody map[string]any - ensureReqBody := func() (map[string]any, error) { - if requestView.HasPatches() { - patchedBody, patchErr := requestView.ApplyPatches() - if patchErr != nil { - return nil, patchErr - } - body = patchedBody - requestView = newOpenAIRequestView(body) - reqBody = nil - bodyModified = false - } - if reqBody != nil { - return reqBody, nil - } - decoded, decodeErr := requestView.Decode(c) - if decodeErr != nil { - return nil, decodeErr - } - reqBody = decoded - return reqBody, nil - } - markPatchSet := func(path string, value any) { - bodyModified = true - if requestView.patchesDisabled { - if reqBody != nil { - setOpenAIRequestMapPath(reqBody, path, value) - } - return - } - requestView.MarkPatchSet(path, value) - } - markPatchDelete := func(path string) { - bodyModified = true - if requestView.patchesDisabled { - if reqBody != nil { - deleteOpenAIRequestMapPath(reqBody, path) - } - return - } - requestView.MarkPatchDelete(path) - } - disablePatch := func() { - requestView.DisablePatches() - } - markDecodedModified := func() { - bodyModified = true - disablePatch() - } - - apiKey := getAPIKeyFromContext(c) - imageGenerationAllowed := GroupAllowsImageGeneration(nil) - if apiKey != nil { - imageGenerationAllowed = GroupAllowsImageGeneration(apiKey.Group) - } - codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow - if isCodexCLI { - codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy() - } - codexImageGenerationBridgeEnabled := isCodexCLI && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) - var imageIntent bool - if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { - decoded, decodeErr := ensureReqBody() - if decodeErr != nil { - return nil, decodeErr - } - if stripOpenAIImageGenerationTools(decoded) { - markDecodedModified() - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy") - } - imageIntent = IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, decoded) - } else { - imageIntent = IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) - } - if imageIntent && !imageGenerationAllowed { - MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) - c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}}) - return nil, errors.New("image generation disabled for group") - } - - instructions := gjson.GetBytes(body, "instructions") - instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" - if instructionsEmpty && !compatMessagesBridge { - markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) - } - - billingModel := account.GetMappedModel(reqModel) - if billingModel != reqModel { - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, billingModel, account.Name, isCodexCLI) - reqModel = billingModel - markPatchSet("model", billingModel) - } - upstreamModel := billingModel - isCompactRequest := isOpenAIResponsesCompactPath(c) - compactMapped := false - if isCompactRequest { - compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel) - if compactMappedModel != "" && compactMappedModel != billingModel { - compactMapped = true - upstreamModel = compactMappedModel - reqModel = compactMappedModel - markPatchSet("model", compactMappedModel) - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", billingModel, compactMappedModel, account.Name, isCodexCLI) - } - } - if !compactMapped { - modelForNormalize := reqModel - if modelForNormalize == "" { - modelForNormalize = requestView.Model - } - upstreamModel = normalizeOpenAIModelForUpstream(account, modelForNormalize) - if upstreamModel != "" && upstreamModel != modelForNormalize { - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", modelForNormalize, upstreamModel, account.Name, account.Type, isCodexCLI) - reqModel = upstreamModel - markPatchSet("model", upstreamModel) - } - } - if strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) == "minimal" { - markPatchSet("reasoning.effort", "none") - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized reasoning.effort: minimal -> none (account: %s)", account.Name) - } - - imageIntent = imageIntent || IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, nil) || isOpenAIImageGenerationModel(upstreamModel) - if imageIntent && !imageGenerationAllowed { - MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) - c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}}) - return nil, errors.New("image generation disabled for group") - } - - // /responses/compact 是会话压缩请求:上游不接受 tool_choice(400 unknown_parameter), - // 注入 image_generation 工具也没有意义,整块豁免。 - if imageGenerationAllowed && !isCompactRequest && (codexImageGenerationBridgeEnabled || isOpenAIImageGenerationModel(requestView.Model) || openAIRequestBodyImageGenerationToolNeedsNormalization(body) || isOpenAIImageGenerationModel(upstreamModel)) { - decoded, decodeErr := ensureReqBody() - if decodeErr != nil { - return nil, decodeErr - } - if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationTool(decoded) { - markDecodedModified() - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Injected /responses image_generation tool for Codex client") - } - if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationToolChoiceAuto(decoded) { - markDecodedModified() - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Set /responses image_generation tool_choice=auto for Codex client") - } - if normalizeOpenAIResponsesImageGenerationTools(decoded) { - markDecodedModified() - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image_generation tool payload") - } - if normalizeOpenAIResponsesImageOnlyModel(decoded) { - markDecodedModified() - if model, ok := decoded["model"].(string); ok { - upstreamModel = strings.TrimSpace(model) - } - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image-only model request inbound_model=%s image_model=%s upstream_model=%s", requestView.Model, billingModel, upstreamModel) - } - if err := validateOpenAIResponsesImageModel(decoded, upstreamModel); err != nil { - setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") - c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "model"}}) - return nil, err - } - if hasOpenAIImageGenerationTool(decoded) { - imageIntent = true - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type) - } - if codexImageGenerationBridgeEnabled && applyCodexImageGenerationBridgeInstructions(decoded) { - markDecodedModified() - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Added Codex image_generation bridge instructions") - } - } else if imageGenerationAllowed && imageIntent && openAIRequestBodyHasImageGenerationTool(body) { - // 完整 image_generation tool 只做 raw 计费读取,校验/桥接/旧字段迁移命中时才展开大 input map。 - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type) - } - - if isCodexSparkModel(upstreamModel) && openAIRequestBodyMayContainImageInput(body) { - decoded, decodeErr := ensureReqBody() - if decodeErr != nil { - return nil, decodeErr - } - if err := validateCodexSparkInput(decoded, upstreamModel); err != nil { - setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") - c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "input"}}) - return nil, err - } - } - - // gpt-5.3-codex-spark also rejects the image_generation tool (HTTP 400, - // param=tools). Strip it here so both APIKey and OAuth /responses paths are - // covered regardless of the image-generation feature gate. - if isCodexSparkModel(upstreamModel) && openAIRequestBodyHasImageGenerationTool(body) { - decoded, decodeErr := ensureReqBody() - if decodeErr != nil { - return nil, decodeErr - } - if stripCodexSparkImageGenerationTools(decoded) { - markDecodedModified() - } - } - - if account.Type == AccountTypeOAuth { - decoded, decodeErr := ensureReqBody() - if decodeErr != nil { - return nil, decodeErr - } - codexResult := codexTransformResult{} - if compatMessagesBridge { - codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{IsCodexCLI: isCodexCLI, IsCompact: isCompactRequest, SkipDefaultInstructions: true, PreserveToolCallIDs: true}) - ensureCodexOAuthInstructionsField(decoded) - markDecodedModified() - } else { - codexResult = applyCodexOAuthTransform(decoded, isCodexCLI, isCompactRequest) - } - if codexResult.Modified { - markDecodedModified() - } - // 带真实 device_id 时补齐 client_metadata 安装标识,与真实 Codex 对齐(compact 形态不同,跳过)。 - if !isCompactRequest && applyCodexClientMetadata(decoded, account) { - markDecodedModified() - } - if codexResult.NormalizedModel != "" { - upstreamModel = codexResult.NormalizedModel - } - if codexResult.PromptCacheKey != "" { - promptCacheKey = codexResult.PromptCacheKey - } - } - - if !SupportsVerbosity(upstreamModel) && gjson.GetBytes(body, "text.verbosity").Exists() { - markPatchDelete("text.verbosity") - } - - if !isCodexCLI { - maxOutputTokens := gjson.GetBytes(body, "max_output_tokens") - if maxOutputTokens.Exists() { - switch account.Platform { - case PlatformOpenAI: - if account.Type == AccountTypeAPIKey { - markPatchDelete("max_output_tokens") - } - case PlatformAnthropic: - decoded, decodeErr := ensureReqBody() - if decodeErr != nil { - return nil, decodeErr - } - delete(decoded, "max_output_tokens") - if _, hasMaxTokens := decoded["max_tokens"]; !hasMaxTokens { - decoded["max_tokens"] = maxOutputTokens.Value() - } - markDecodedModified() - case PlatformGemini: - markPatchDelete("max_output_tokens") - default: - markPatchDelete("max_output_tokens") - } - } - if gjson.GetBytes(body, "max_completion_tokens").Exists() && (account.Type == AccountTypeAPIKey || account.Platform != PlatformOpenAI) { - markPatchDelete("max_completion_tokens") - } - for _, unsupportedField := range []string{"prompt_cache_retention", "safety_identifier"} { - if gjson.GetBytes(body, unsupportedField).Exists() { - markPatchDelete(unsupportedField) - } - } - } - if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 && gjson.GetBytes(body, "previous_response_id").Exists() { - markPatchDelete("previous_response_id") - } - if openAIRequestBodyMayContainEmptyBase64InputImage(body) { - decoded, decodeErr := ensureReqBody() - if decodeErr != nil { - return nil, decodeErr - } - if sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(decoded) { - markDecodedModified() - } - } - - if rawTier := requestView.ServiceTier; rawTier != "" { - if normTier := normalizedOpenAIServiceTierValue(rawTier); normTier != "" { - action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, upstreamModel, normTier) - switch action { - case BetaPolicyActionBlock: - msg := errMsg - if msg == "" { - msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, upstreamModel) - } - blocked := &OpenAIFastBlockedError{Message: msg} - writeOpenAIFastPolicyBlockedResponse(c, blocked) - return nil, blocked - case BetaPolicyActionFilter: - markPatchDelete("service_tier") - case OpenAIFastPolicyActionForcePriority: - if rawTier != OpenAIFastTierPriority { - markPatchSet("service_tier", OpenAIFastTierPriority) - } - default: - if normTier != rawTier { - markPatchSet("service_tier", normTier) - } - } - } - } - - if bodyModified { - if requestView.HasPatches() { - if patchedBody, patchErr := requestView.ApplyPatches(); patchErr == nil { - body = patchedBody - requestView = newOpenAIRequestView(body) - reqBody = nil - bodyModified = false - } - } - if bodyModified { - decoded, decodeErr := ensureReqBody() - if decodeErr != nil { - return nil, decodeErr - } - var marshalErr error - body, marshalErr = marshalOpenAIUpstreamJSON(decoded) - if marshalErr != nil { - return nil, fmt.Errorf("serialize request body: %w", marshalErr) - } - requestView = newOpenAIRequestView(body) - } - } - imageBillingModel := "" - imageSizeTier := "" - imageInputSize := "" - if imageIntent { - var imageCfg OpenAIResponsesImageBillingConfig - var imageCfgErr error - if reqBody != nil { - imageCfg, imageCfgErr = resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, billingModel) - } else { - imageCfg, imageCfgErr = resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, billingModel) - } - if imageCfgErr != nil { - setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "") - c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": imageCfgErr.Error(), "param": "size"}}) - return nil, imageCfgErr - } - imageBillingModel = imageCfg.Model - imageSizeTier = imageCfg.SizeTier - imageInputSize = imageCfg.InputSize - } - - // Get access token - token, _, err := s.GetAccessToken(ctx, account) - if err != nil { - return nil, err - } - - // 命中 WS 时仅走 WebSocket Mode;不再自动回退 HTTP。 - if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 { - // WS 分支需要结构化 payload 与重连恢复,命中后再触发 full-map decode。 - wsReqBody, err := ensureReqBody() - if err != nil { - return nil, err - } - _, hasPreviousResponseID := wsReqBody["previous_response_id"] - logOpenAIWSModeDebug( - "forward_start account_id=%d account_type=%s model=%s stream=%v has_previous_response_id=%v", - account.ID, - account.Type, - upstreamModel, - reqStream, - hasPreviousResponseID, - ) - maxAttempts := openAIWSReconnectRetryLimit + 1 - wsAttempts := 0 - var wsResult *OpenAIForwardResult - var wsErr error - wsLastFailureReason := "" - wsPrevResponseRecoveryTried := false - wsInvalidEncryptedContentRecoveryTried := false - recoverPrevResponseNotFound := func(attempt int) bool { - if wsPrevResponseRecoveryTried { - return false - } - previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id") - if previousResponseID == "" { - logOpenAIWSModeInfo( - "reconnect_prev_response_recovery_skip account_id=%d attempt=%d reason=missing_previous_response_id previous_response_id_present=false", - account.ID, - attempt, - ) - return false - } - if HasFunctionCallOutput(wsReqBody) { - logOpenAIWSModeInfo( - "reconnect_prev_response_recovery_skip account_id=%d attempt=%d reason=has_function_call_output previous_response_id_present=true", - account.ID, - attempt, - ) - return false - } - delete(wsReqBody, "previous_response_id") - wsPrevResponseRecoveryTried = true - logOpenAIWSModeInfo( - "reconnect_prev_response_recovery account_id=%d attempt=%d action=drop_previous_response_id retry=1 previous_response_id=%s previous_response_id_kind=%s", - account.ID, - attempt, - truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(ClassifyOpenAIPreviousResponseIDKind(previousResponseID)), - ) - return true - } - recoverInvalidEncryptedContent := func(attempt int) bool { - if wsInvalidEncryptedContentRecoveryTried { - return false - } - removedReasoningItems := trimOpenAIEncryptedReasoningItems(wsReqBody) - if !removedReasoningItems { - logOpenAIWSModeInfo( - "reconnect_invalid_encrypted_content_recovery_skip account_id=%d attempt=%d reason=missing_encrypted_reasoning_items", - account.ID, - attempt, - ) - return false - } - previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id") - hasFunctionCallOutput := HasFunctionCallOutput(wsReqBody) - if previousResponseID != "" && !hasFunctionCallOutput { - delete(wsReqBody, "previous_response_id") - } - wsInvalidEncryptedContentRecoveryTried = true - logOpenAIWSModeInfo( - "reconnect_invalid_encrypted_content_recovery account_id=%d attempt=%d action=drop_encrypted_reasoning_items retry=1 previous_response_id_present=%v previous_response_id=%s previous_response_id_kind=%s has_function_call_output=%v dropped_previous_response_id=%v", - account.ID, - attempt, - previousResponseID != "", - truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(ClassifyOpenAIPreviousResponseIDKind(previousResponseID)), - hasFunctionCallOutput, - previousResponseID != "" && !hasFunctionCallOutput, - ) - return true - } - retryBudget := s.openAIWSRetryTotalBudget() - retryStartedAt := time.Now() - wsRetryLoop: - for attempt := 1; attempt <= maxAttempts; attempt++ { - wsAttempts = attempt - wsResult, wsErr = s.forwardOpenAIWSV2( - ctx, - c, - account, - wsReqBody, - token, - wsDecision, - isCodexCLI, - reqStream, - originalModel, - upstreamModel, - startTime, - attempt, - wsLastFailureReason, - ) - if wsErr == nil { - break - } - if c != nil && c.Writer != nil && c.Writer.Written() { - break - } - - reason, retryable := classifyOpenAIWSReconnectReason(wsErr) - if reason != "" { - wsLastFailureReason = reason - } - // previous_response_not_found 说明续链锚点不可用: - // 对非 function_call_output 场景,允许一次“去掉 previous_response_id 后重放”。 - if reason == "previous_response_not_found" && recoverPrevResponseNotFound(attempt) { - continue - } - if reason == "invalid_encrypted_content" && recoverInvalidEncryptedContent(attempt) { - continue - } - if retryable && attempt < maxAttempts { - backoff := s.openAIWSRetryBackoff(attempt) - if retryBudget > 0 && time.Since(retryStartedAt)+backoff > retryBudget { - s.recordOpenAIWSRetryExhausted() - logOpenAIWSModeInfo( - "reconnect_budget_exhausted account_id=%d attempts=%d max_retries=%d reason=%s elapsed_ms=%d budget_ms=%d", - account.ID, - attempt, - openAIWSReconnectRetryLimit, - normalizeOpenAIWSLogValue(reason), - time.Since(retryStartedAt).Milliseconds(), - retryBudget.Milliseconds(), - ) - break - } - s.recordOpenAIWSRetryAttempt(backoff) - logOpenAIWSModeInfo( - "reconnect_retry account_id=%d retry=%d max_retries=%d reason=%s backoff_ms=%d", - account.ID, - attempt, - openAIWSReconnectRetryLimit, - normalizeOpenAIWSLogValue(reason), - backoff.Milliseconds(), - ) - if backoff > 0 { - timer := time.NewTimer(backoff) - select { - case <-ctx.Done(): - if !timer.Stop() { - <-timer.C - } - wsErr = wrapOpenAIWSFallback("retry_backoff_canceled", ctx.Err()) - break wsRetryLoop - case <-timer.C: - } - } - continue - } - if retryable { - s.recordOpenAIWSRetryExhausted() - logOpenAIWSModeInfo( - "reconnect_exhausted account_id=%d attempts=%d max_retries=%d reason=%s", - account.ID, - attempt, - openAIWSReconnectRetryLimit, - normalizeOpenAIWSLogValue(reason), - ) - } else if reason != "" { - s.recordOpenAIWSNonRetryableFastFallback() - logOpenAIWSModeInfo( - "reconnect_stop account_id=%d attempt=%d reason=%s", - account.ID, - attempt, - normalizeOpenAIWSLogValue(reason), - ) - } - break - } - if wsErr == nil { - firstTokenMs := int64(0) - hasFirstTokenMs := wsResult != nil && wsResult.FirstTokenMs != nil - if hasFirstTokenMs { - firstTokenMs = int64(*wsResult.FirstTokenMs) - } - requestID := "" - if wsResult != nil { - requestID = strings.TrimSpace(wsResult.RequestID) - } - logOpenAIWSModeDebug( - "forward_succeeded account_id=%d request_id=%s stream=%v has_first_token_ms=%v first_token_ms=%d ws_attempts=%d", - account.ID, - requestID, - reqStream, - hasFirstTokenMs, - firstTokenMs, - wsAttempts, - ) - wsResult.UpstreamModel = upstreamModel - if wsResult.BillingModel == "" { - wsResult.BillingModel = billingModel - } - if wsResult.ImageCount > 0 { - wsResult.ImageSize = imageSizeTier - wsResult.ImageInputSize = imageInputSize - wsResult.BillingModel = imageBillingModel - } - return wsResult, nil - } - s.writeOpenAIWSFallbackErrorResponse(c, account, wsErr) - return nil, wsErr - } - - httpInvalidEncryptedContentRetryTried := false - for { - // Build upstream request - upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) - upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, reqStream, promptCacheKey, isCodexCLI) - releaseUpstreamCtx() - if err != nil { - return nil, err - } - - // Get proxy URL - proxyURL := "" - if account.ProxyID != nil && account.Proxy != nil { - proxyURL = account.Proxy.URL() - } - - // Send request - upstreamStart := time.Now() - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) - SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) - if err != nil { - // Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to - // a failover so the handler switches to a healthy account, and temporarily - // unschedule the account on durable faults (e.g. rejected proxy credentials). - return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) - } - - // Handle error response - if resp.StatusCode >= 400 { - respBody := s.readUpstreamErrorBody(resp) - _ = resp.Body.Close() - resp.Body = io.NopCloser(bytes.NewReader(respBody)) - - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - upstreamCode := extractUpstreamErrorCode(respBody) - if !httpInvalidEncryptedContentRetryTried && resp.StatusCode == http.StatusBadRequest && upstreamCode == "invalid_encrypted_content" { - decoded, decodeErr := ensureReqBody() - if decodeErr != nil { - return nil, decodeErr - } - if trimOpenAIEncryptedReasoningItems(decoded) { - body, err = marshalOpenAIUpstreamJSON(decoded) - if err != nil { - return nil, fmt.Errorf("serialize invalid_encrypted_content retry body: %w", err) - } - httpInvalidEncryptedContentRetryTried = true - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request once after invalid_encrypted_content (account: %s)", account.Name) - continue - } - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Skip non-WSv2 invalid_encrypted_content retry because encrypted reasoning items are missing (account: %s)", account.Name) - } - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(respBody), maxBytes) - } - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "failover", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - - s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel) - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - RetryableOnSameAccount: account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), - } - } - return s.handleErrorResponse(ctx, resp, c, account, body, billingModel) - } - defer func() { _ = resp.Body.Close() }() - - reasoningEffort := extractOpenAIReasoningEffortFromBody(body, originalModel) - // 国产模型默认 effort 补充:此处 reqModel 已被 mapping 重写为 billingModel(见 - // line 2510-2515 的 GetMappedModel + reqModel 赋值),可直接作为 mappedModel。 - reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, reqModel) - serviceTier := extractOpenAIServiceTierFromBody(body) - // 上游接受后只保留计费需要的标量,避免响应处理期间继续保活完整 input/tools map。 - reqBody = nil - - // Handle normal response - var usage *OpenAIUsage - var firstTokenMs *int - responseID := "" - imageCount := 0 - var imageOutputSizes []string - if reqStream { - streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel) - if err != nil { - return nil, err - } - usage = streamResult.usage - firstTokenMs = streamResult.firstTokenMs - responseID = strings.TrimSpace(streamResult.responseID) - imageCount = streamResult.imageCount - imageOutputSizes = streamResult.imageOutputSizes - } else { - nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel) - if err != nil { - return nil, err - } - usage = nonStreamResult.usage - responseID = strings.TrimSpace(nonStreamResult.responseID) - imageCount = nonStreamResult.imageCount - imageOutputSizes = nonStreamResult.imageOutputSizes - } - s.bindHTTPResponseAccount(ctx, c, account, responseID) - - // Extract and save Codex usage snapshot from response headers (for OAuth accounts). - // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 - if account.Type == AccountTypeOAuth && !account.IsShadow() { - if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { - s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) - } - } - - if usage == nil { - usage = &OpenAIUsage{} - } - - forwardResult := &OpenAIForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - ResponseID: responseID, - Usage: *usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - ServiceTier: serviceTier, - ReasoningEffort: reasoningEffort, - Stream: reqStream, - OpenAIWSMode: false, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - } - if imageCount > 0 { - forwardResult.ImageCount = imageCount - forwardResult.ImageSize = imageSizeTier - forwardResult.ImageInputSize = imageInputSize - forwardResult.ImageOutputSizes = imageOutputSizes - forwardResult.BillingModel = imageBillingModel - } - return forwardResult, nil - } -} - -func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) { - // Determine target URL based on account type - var targetURL string - switch account.Type { - case AccountTypeOAuth: - // OAuth accounts use ChatGPT internal API - targetURL = chatgptCodexURL - case AccountTypeAPIKey: - // API Key accounts use Platform API or custom base URL - baseURL := account.GetOpenAIBaseURL() - if baseURL == "" { - targetURL = openaiPlatformAPIURL - } else { - validatedURL, err := s.validateUpstreamBaseURL(baseURL) - if err != nil { - return nil, err - } - targetURL = buildOpenAIResponsesURL(validatedURL) - } - default: - targetURL = openaiPlatformAPIURL - } - targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) - - req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) - if err != nil { - return nil, err - } - req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI)) - - // Set authentication header - req.Header.Set("authorization", "Bearer "+token) - - // Set headers specific to OAuth accounts (ChatGPT internal API) - if account.Type == AccountTypeOAuth { - // Required: set Host for ChatGPT API (must use req.Host, not Header.Set) - req.Host = "chatgpt.com" - if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil { - return nil, fmt.Errorf("resolve chatgpt account headers: %w", err) - } - } - - // Whitelist passthrough headers - for key, values := range c.Request.Header { - lowerKey := strings.ToLower(key) - if openaiAllowedHeaders[lowerKey] { - for _, v := range values { - req.Header.Add(key, v) - } - } - } - if account.Type == AccountTypeOAuth { - compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body) - // 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。 - clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id")) - req.Header.Del("conversation_id") - req.Header.Del("session_id") - - if compatMessagesBridge { - req.Header.Del("OpenAI-Beta") - req.Header.Del("originator") - } else { - req.Header.Set("OpenAI-Beta", "responses=experimental") - req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI)) - } - apiKeyID := getAPIKeyIDFromContext(c) - if isOpenAIResponsesCompactPath(c) { - req.Header.Set("accept", "application/json") - if req.Header.Get("version") == "" { - req.Header.Set("version", codexCLIVersion) - } - compactSession := resolveOpenAICompactSessionID(c) - req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession)) - } else { - req.Header.Set("accept", "text/event-stream") - } - if promptCacheKey != "" { - isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey) - req.Header.Set("session_id", isolated) - if !compatMessagesBridge || clientConversationID != "" { - req.Header.Set("conversation_id", isolated) - } - } - } - - // Apply custom User-Agent if configured - customUA := account.GetOpenAIUserAgent() - if customUA != "" { - req.Header.Set("user-agent", customUA) - } - - // 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为 Codex CLI。 - // 用于网关未透传/改写 User-Agent 时,仍能命中 Codex 侧识别逻辑。 - if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("user-agent", codexCLIUserAgent) - } - - // 浏览器型 UA 兜底:仅 OAuth(ChatGPT 内部接口)账号生效,若最终 user-agent 仍为浏览器 - // (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。 - s.overrideBrowserUserAgent(ctx, account, req) - - // Ensure required headers exist - if req.Header.Get("content-type") == "" { - req.Header.Set("content-type", "application/json") - } - - // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) - account.ApplyHeaderOverrides(req.Header) - - return req, nil -} - -// overrideBrowserUserAgent 检查请求的最终 user-agent,若为浏览器 UA 则替换为后台配置的 Codex UA。 -// 用于规避 Cloudflare 对浏览器型 UA 在 ChatGPT 内部接口上的访问质询。 -// 影响范围严格限定:仅 OAuth(Codex/ChatGPT 内部接口)账号生效;API Key 等其他账号原样透传。 -// 仅在识别为浏览器(Mozilla/...)时改写,其他 CLI/工具 UA 不动。 -func (s *OpenAIGatewayService) overrideBrowserUserAgent(ctx context.Context, account *Account, req *http.Request) { - if req == nil || account == nil { - return - } - if account.Type != AccountTypeOAuth { - return - } - currentUA := req.Header.Get("user-agent") - if !openai.IsBrowserUserAgent(currentUA) { - return - } - codexUA := DefaultOpenAICodexUserAgent - if s != nil && s.settingService != nil { - if v := strings.TrimSpace(s.settingService.GetOpenAICodexUserAgent(ctx)); v != "" { - codexUA = v - } - } - req.Header.Set("user-agent", codexUA) -} - -func (s *OpenAIGatewayService) handleErrorResponse( - ctx context.Context, - resp *http.Response, - c *gin.Context, - account *Account, - requestBody []byte, - requestedModel ...string, -) (*OpenAIForwardResult, error) { - body := s.readUpstreamErrorBody(resp) - - // cyber_policy 硬阻断:透传上游原始错误体给客户端(不重包成通用 502),不冷却账号。 - // 当前请求恒透传(需求1);标记供 handler 事后写风控/邮件。400 cyber 不可 failover - // (shouldFailoverUpstreamError(400)=false),故走到此处即可安全早返回。 - if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit { - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: cyberMsg, - Body: truncateString(string(body), 4096), - UpstreamStatus: resp.StatusCode, - }) - setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048)) - writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/json" - } - c.Data(resp.StatusCode, contentType, body) - if cyberMsg == "" { - return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode) - } - return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg) - } - - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(body), maxBytes) - } - setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) - logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body) - - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - logger.LegacyPrintf("service.openai_gateway", - "OpenAI upstream error %d (account=%d platform=%s type=%s): %s", - resp.StatusCode, - account.ID, - account.Platform, - account.Type, - truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), - ) - } - - if status, errType, errMsg, matched := applyErrorPassthroughRule( - c, - PlatformOpenAI, - resp.StatusCode, - body, - http.StatusBadGateway, - "upstream_error", - "Upstream request failed", - ); matched { - MarkResponseCommitted(c) - c.JSON(status, gin.H{ - "error": gin.H{ - "type": errType, - "message": errMsg, - }, - }) - if upstreamMsg == "" { - upstreamMsg = errMsg - } - if upstreamMsg == "" { - return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg) - } - - // Check custom error codes - if !account.ShouldHandleErrorCode(resp.StatusCode) { - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "http_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - MarkResponseCommitted(c) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": gin.H{ - "type": "upstream_error", - "message": "Upstream gateway error", - }, - }) - if upstreamMsg == "" { - return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg) - } - - // Handle upstream error (mark account status) - var reqModel string - if len(requestedModel) > 0 { - reqModel = strings.TrimSpace(requestedModel[0]) - } - if reqModel == "" { - reqModel, _, _ = extractOpenAIRequestMetaFromBody(requestBody) - } - shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel) - kind := "http_error" - if shouldDisable { - kind = "failover" - } - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: kind, - Message: upstreamMsg, - Detail: upstreamDetail, - }) - if shouldDisable { - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: body, - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), - } - } - - MarkResponseCommitted(c) - - // Return appropriate error response - var errType, errMsg string - var statusCode int - - switch resp.StatusCode { - case 401: - statusCode = http.StatusBadGateway - errType = "upstream_error" - errMsg = "Upstream authentication failed, please contact administrator" - case 402: - statusCode = http.StatusBadGateway - errType = "upstream_error" - errMsg = "Upstream payment required: insufficient balance or billing issue" - case 403: - statusCode = http.StatusBadGateway - errType = "upstream_error" - errMsg = "Upstream access forbidden, please contact administrator" - case 429: - statusCode = http.StatusTooManyRequests - errType = "rate_limit_error" - errMsg = "Upstream rate limit exceeded, please retry later" - default: - statusCode = http.StatusBadGateway - errType = "upstream_error" - errMsg = "Upstream request failed" - } - if isOpenAIContextWindowError(upstreamMsg, body) && upstreamMsg != "" { - errMsg = upstreamMsg - } - - c.JSON(statusCode, gin.H{ - "error": gin.H{ - "type": errType, - "message": errMsg, - }, - }) - - if upstreamMsg == "" { - return nil, fmt.Errorf("upstream error: %d", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg) -} - -// compatErrorWriter is the signature for format-specific error writers used by -// the compat paths (Chat Completions and Anthropic Messages). -type compatErrorWriter func(c *gin.Context, statusCode int, errType, message string) - -// handleCompatErrorResponse is the shared non-failover error handler for the -// Chat Completions and Anthropic Messages compat paths. It mirrors the logic of -// handleErrorResponse (passthrough rules, ShouldHandleErrorCode, rate-limit -// tracking, secondary failover) but delegates the final error write to the -// format-specific writer function. -func (s *OpenAIGatewayService) handleCompatErrorResponse( - resp *http.Response, - c *gin.Context, - account *Account, - writeError compatErrorWriter, - requestedModel ...string, -) (*OpenAIForwardResult, error) { - body := s.readUpstreamErrorBody(resp) - - // cyber_policy:兼容路径(Chat Completions / Anthropic)以各自格式回写错误, - // 不原样透传 responses 格式的 cyber body(否则对下游格式不合法)。cyber 是上游网络 - // 安全策略拦截,不冷却账号,故标记后直接以兼容格式回写错误并返回,跳过下方 - // handleOpenAIAccountUpstreamError(避免自定义 temp-unschedulable 规则误冷却)。 - if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit { - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: cyberMsg, - Body: truncateString(string(body), 4096), - UpstreamStatus: resp.StatusCode, - }) - setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048)) - clientMsg := cyberMsg - if clientMsg == "" { - clientMsg = "Request blocked by upstream cyber-security policy" - } - writeError(c, resp.StatusCode, "invalid_request_error", clientMsg) - if cyberMsg == "" { - return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode) - } - return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg) - } - - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) - if upstreamMsg == "" { - upstreamMsg = fmt.Sprintf("Upstream error: %d", resp.StatusCode) - } - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(body), maxBytes) - } - setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) - - // Apply error passthrough rules - if status, errType, errMsg, matched := applyErrorPassthroughRule( - c, account.Platform, resp.StatusCode, body, - http.StatusBadGateway, "api_error", "Upstream request failed", - ); matched { - MarkResponseCommitted(c) - writeError(c, status, errType, errMsg) - if upstreamMsg == "" { - upstreamMsg = errMsg - } - if upstreamMsg == "" { - return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg) - } - - // Check custom error codes — if the account does not handle this status, - // return a generic error without exposing upstream details. - if !account.ShouldHandleErrorCode(resp.StatusCode) { - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: "http_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - MarkResponseCommitted(c) - writeError(c, http.StatusInternalServerError, "api_error", "Upstream gateway error") - if upstreamMsg == "" { - return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode) - } - return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg) - } - - // Track rate limits and decide whether to trigger secondary failover. - var modelForCooldown string - if len(requestedModel) > 0 { - modelForCooldown = requestedModel[0] - } - shouldDisable := s.handleOpenAIAccountUpstreamError( - c.Request.Context(), account, resp.StatusCode, resp.Header, body, modelForCooldown, - ) - kind := "http_error" - if shouldDisable { - kind = "failover" - } - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: resp.Header.Get("x-request-id"), - Kind: kind, - Message: upstreamMsg, - Detail: upstreamDetail, - }) - if shouldDisable { - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: body, - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), - } - } - - MarkResponseCommitted(c) - - // Map status code to error type and write response - errType := "api_error" - switch { - case resp.StatusCode == 400: - errType = "invalid_request_error" - case resp.StatusCode == 404: - errType = "not_found_error" - case resp.StatusCode == 429: - errType = "rate_limit_error" - case resp.StatusCode >= 500: - errType = "api_error" - } - - writeError(c, resp.StatusCode, errType, upstreamMsg) - return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg) -} - -// openaiStreamingResult streaming response result -type openaiStreamingResult struct { - usage *OpenAIUsage - firstTokenMs *int - responseID string - imageCount int - imageOutputSizes []string -} - -type openaiNonStreamingResult struct { - *OpenAIUsage - usage *OpenAIUsage - responseID string - imageCount int - imageOutputSizes []string -} - -func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) { - if s.responseHeaderFilter != nil { - responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - } - - // Set SSE response headers - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") - - // Pass through other headers - if v := resp.Header.Get("x-request-id"); v != "" { - c.Header("x-request-id", v) - } - - w := c.Writer - flusher, ok := w.(http.Flusher) - if !ok { - return nil, errors.New("streaming not supported") - } - bufferedWriter := bufio.NewWriterSize(w, 4*1024) - flushBuffered := func() error { - if err := bufferedWriter.Flush(); err != nil { - return err - } - flusher.Flush() - return nil - } - - usage := &OpenAIUsage{} - imageCounter := newOpenAIImageOutputCounter() - var firstTokenMs *int - responseID := "" - scanner := bufio.NewScanner(resp.Body) - maxLineSize := defaultMaxLineSize - if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { - maxLineSize = s.cfg.Gateway.MaxLineSize - } - scanBuf := getSSEScannerBuf64K() - scanner.Buffer(scanBuf[:0], maxLineSize) - - streamInterval := time.Duration(0) - if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 { - streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second - } - // 仅监控上游数据间隔超时,不被下游写入阻塞影响 - var intervalTicker *time.Ticker - if streamInterval > 0 { - intervalTicker = time.NewTicker(streamInterval) - defer intervalTicker.Stop() - } - var intervalCh <-chan time.Time - if intervalTicker != nil { - intervalCh = intervalTicker.C - } - - keepaliveInterval := time.Duration(0) - if s.cfg != nil && s.cfg.Gateway.StreamKeepaliveInterval > 0 { - keepaliveInterval = time.Duration(s.cfg.Gateway.StreamKeepaliveInterval) * time.Second - } - // 下游 keepalive 仅用于防止代理空闲断开 - var keepaliveTicker *time.Ticker - if keepaliveInterval > 0 { - keepaliveTicker = time.NewTicker(keepaliveInterval) - defer keepaliveTicker.Stop() - } - var keepaliveCh <-chan time.Time - if keepaliveTicker != nil { - keepaliveCh = keepaliveTicker.C - } - // Track downstream writes separately from upstream reads: pre-output failover - // can buffer response.created / response.in_progress, so keepalive must be - // based on downstream idle time. - lastDownstreamWriteAt := time.Now() - - // 仅发送一次错误事件,避免多次写入导致协议混乱。 - // 注意:OpenAI `/v1/responses` streaming 事件必须符合 OpenAI Responses schema; - // 否则下游 SDK(例如 OpenCode)会因为类型校验失败而报错。 - errorEventSent := false - clientDisconnected := false // 客户端断开后继续 drain 上游以收集 usage - sawTerminalEvent := false - sawFailedEvent := false - failedMessage := "" - clientOutputStarted := false - upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) - var streamFailoverErr error - sendErrorEvent := func(reason string) { - if errorEventSent || clientDisconnected { - return - } - errorEventSent = true - payload := `{"type":"error","sequence_number":0,"error":{"type":"upstream_error","message":` + strconv.Quote(reason) + `,"code":` + strconv.Quote(reason) + `}}` - if err := flushBuffered(); err != nil { - clientDisconnected = true - return - } - if _, err := bufferedWriter.WriteString("data: " + payload + "\n\n"); err != nil { - clientDisconnected = true - return - } - if err := flushBuffered(); err != nil { - clientDisconnected = true - return - } - clientOutputStarted = true - lastDownstreamWriteAt = time.Now() - } - - needModelReplace := originalModel != mappedModel - streamOutputAccumulator := apicompat.NewBufferedResponseAccumulator() - streamImageOutputs := make([]json.RawMessage, 0, 1) - streamSeenImages := make(map[string]struct{}) - resultWithUsage := func() *openaiStreamingResult { - return &openaiStreamingResult{ - usage: usage, - firstTokenMs: firstTokenMs, - responseID: responseID, - imageCount: imageCounter.Count(), - imageOutputSizes: imageCounter.Sizes(), - } - } - finalizeStream := func() (*openaiStreamingResult, error) { - if !sawTerminalEvent { - if !openAIStreamClientOutputStarted(c, clientOutputStarted) { - return resultWithUsage(), s.newOpenAIStreamFailoverError( - c, - account, - false, - upstreamRequestID, - nil, - "OpenAI stream ended before a terminal event", - ) - } - return resultWithUsage(), fmt.Errorf("stream usage incomplete: missing terminal event") - } - if sawFailedEvent { - return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage) - } - if !clientDisconnected { - hadBufferedData := bufferedWriter.Buffered() > 0 - if err := flushBuffered(); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during final flush, returning collected usage") - } else if hadBufferedData { - clientOutputStarted = true - lastDownstreamWriteAt = time.Now() - } - } - return resultWithUsage(), nil - } - handleScanErr := func(scanErr error) (*openaiStreamingResult, error, bool) { - if scanErr == nil { - return nil, nil, false - } - if sawTerminalEvent && !sawFailedEvent { - logger.LegacyPrintf("service.openai_gateway", "Upstream scan ended after terminal event: %v", scanErr) - return resultWithUsage(), nil, true - } - if sawFailedEvent { - return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage), true - } - // 客户端断开/取消请求时,上游读取往往会返回 context canceled。 - // /v1/responses 的 SSE 事件必须符合 OpenAI 协议;这里不注入自定义 error event,避免下游 SDK 解析失败。 - if errors.Is(scanErr, context.Canceled) || errors.Is(scanErr, context.DeadlineExceeded) { - return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", scanErr), true - } - if errors.Is(scanErr, bufio.ErrTooLong) { - logger.LegacyPrintf("service.openai_gateway", "SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, scanErr) - sendErrorEvent("response_too_large") - return resultWithUsage(), scanErr, true - } - if !openAIStreamClientOutputStarted(c, clientOutputStarted) { - msg := "OpenAI stream disconnected before completion" - if errText := strings.TrimSpace(scanErr.Error()); errText != "" { - msg += ": " + errText - } - return resultWithUsage(), s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, nil, msg), true - } - // 客户端已断开时,上游出错仅影响体验,不影响计费;返回已收集 usage - if clientDisconnected { - return resultWithUsage(), fmt.Errorf("stream usage incomplete after disconnect: %w", scanErr), true - } - sendErrorEvent("stream_read_error") - return resultWithUsage(), fmt.Errorf("stream read error: %w", scanErr), true - } - processSSELine := func(line string, queueDrained bool) { - if streamFailoverErr != nil { - return - } - // Extract data from SSE line (supports both "data: " and "data:" formats) - if data, ok := extractOpenAISSEDataLine(line); ok { - dataBytes := []byte(data) - if openAIStreamEventIsTerminal(data) { - sawTerminalEvent = true - } - eventType := strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) - if responseID == "" { - responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes) - } - forceFlushFailedEvent := false - if eventType == "response.failed" { - failedMessage = extractOpenAISSEErrorMessage(dataBytes) - // response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析 - // 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费 - // 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。 - s.parseSSEUsageBytes(dataBytes, usage) - if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit { - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: msg, - Body: truncateString(string(dataBytes), 4096), - UpstreamStatus: http.StatusOK, - UpstreamInTok: usage.InputTokens, - UpstreamOutTok: usage.OutputTokens, - }) - } else if !openAIStreamClientOutputStarted(c, clientOutputStarted) && openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) { - sawFailedEvent = true - streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, failedMessage) - return - } - forceFlushFailedEvent = true - sawFailedEvent = true - } - imageCounter.AddSSEData(dataBytes) - - // Correct Codex tool calls if needed (apply_patch -> edit, etc.) - if correctedData, corrected := s.toolCorrector.CorrectToolCallsInSSEBytes(dataBytes); corrected { - dataBytes = correctedData - data = string(correctedData) - line = "data: " + data - eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) - } - if imageOutput, ok := extractImageGenerationOutputFromSSEData(dataBytes, streamSeenImages); ok { - streamImageOutputs = append(streamImageOutputs, imageOutput) - } - if responsesStreamEventMayContributeToOutput(eventType) { - var streamEvent apicompat.ResponsesStreamEvent - if err := json.Unmarshal(dataBytes, &streamEvent); err == nil { - streamOutputAccumulator.ProcessEvent(&streamEvent) - } - } - if normalizedData, normalized := normalizeResponsesStreamingTerminalOutput(dataBytes, streamOutputAccumulator, streamImageOutputs); normalized { - dataBytes = normalizedData - data = string(normalizedData) - line = "data: " + data - eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) - } - if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient(dataBytes, eventType); sanitized { - dataBytes = sanitizedData - data = string(sanitizedData) - line = "data: " + data - } - // Replace model in response if needed. - // Fast path: most events do not contain model field values. - if needModelReplace && mappedModel != "" && strings.Contains(line, mappedModel) { - line = s.replaceModelInSSELine(line, mappedModel, originalModel) - } - startsClientOutput := forceFlushFailedEvent || openAIStreamDataStartsClientOutput(data, eventType) - - // 写入客户端(客户端断开后继续 drain 上游) - if !clientDisconnected { - shouldFlush := queueDrained && (clientOutputStarted || startsClientOutput) - if firstTokenMs == nil && startsClientOutput { - // 保证首个 token 事件尽快出站,避免影响 TTFT。 - shouldFlush = true - } - if _, err := bufferedWriter.WriteString(line); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") - } else if _, err := bufferedWriter.WriteString("\n"); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") - } else if shouldFlush { - if err := flushBuffered(); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming flush, continuing to drain upstream for billing") - } else { - clientOutputStarted = true - lastDownstreamWriteAt = time.Now() - } - } - } - - // Record first token time - if firstTokenMs == nil && startsClientOutput { - ms := int(time.Since(startTime).Milliseconds()) - firstTokenMs = &ms - } - s.parseSSEUsageBytes(dataBytes, usage) - return - } - - // Forward non-data lines as-is - if !clientDisconnected { - if _, err := bufferedWriter.WriteString(line); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") - } else if _, err := bufferedWriter.WriteString("\n"); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") - } else if queueDrained && clientOutputStarted { - if err := flushBuffered(); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming flush, continuing to drain upstream for billing") - } else { - clientOutputStarted = true - lastDownstreamWriteAt = time.Now() - } - } - } - } - - // 无超时/无 keepalive 的常见路径走同步扫描,减少 goroutine 与 channel 开销。 - if streamInterval <= 0 && keepaliveInterval <= 0 { - defer putSSEScannerBuf64K(scanBuf) - for scanner.Scan() { - processSSELine(scanner.Text(), true) - if streamFailoverErr != nil { - return resultWithUsage(), streamFailoverErr - } - } - if result, err, done := handleScanErr(scanner.Err()); done { - return result, err - } - return finalizeStream() - } - - type scanEvent struct { - line string - err error - } - // 独立 goroutine 读取上游,避免读取阻塞影响 keepalive/超时处理 - events := make(chan scanEvent, 16) - done := make(chan struct{}) - sendEvent := func(ev scanEvent) bool { - select { - case events <- ev: - return true - case <-done: - return false - } - } - var lastReadAt int64 - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - go func(scanBuf *sseScannerBuf64K) { - defer putSSEScannerBuf64K(scanBuf) - defer close(events) - for scanner.Scan() { - atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) - if !sendEvent(scanEvent{line: scanner.Text()}) { - return - } - } - if err := scanner.Err(); err != nil { - _ = sendEvent(scanEvent{err: err}) - } - }(scanBuf) - defer close(done) - - for { - select { - case ev, ok := <-events: - if !ok { - return finalizeStream() - } - if result, err, done := handleScanErr(ev.err); done { - return result, err - } - processSSELine(ev.line, len(events) == 0) - if streamFailoverErr != nil { - return resultWithUsage(), streamFailoverErr - } - - case <-intervalCh: - lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) - if time.Since(lastRead) < streamInterval { - continue - } - if clientDisconnected { - return resultWithUsage(), fmt.Errorf("stream usage incomplete after timeout") - } - logger.LegacyPrintf("service.openai_gateway", "Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval) - // 处理流超时,可能标记账户为临时不可调度或错误状态 - if s.rateLimitService != nil { - s.rateLimitService.HandleStreamTimeout(ctx, account, originalModel) - } - sendErrorEvent("stream_timeout") - return resultWithUsage(), fmt.Errorf("stream data interval timeout") - - case <-keepaliveCh: - if clientDisconnected { - continue - } - if time.Since(lastDownstreamWriteAt) < keepaliveInterval { - continue - } - if _, err := bufferedWriter.WriteString(":\n\n"); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") - continue - } - if err := flushBuffered(); err != nil { - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "Client disconnected during keepalive flush, continuing to drain upstream for billing") - } else { - lastDownstreamWriteAt = time.Now() - } - } - } - -} - -// extractOpenAISSEDataLine 低开销提取 SSE `data:` 行内容。 -// 兼容 `data: xxx` 与 `data:xxx` 两种格式。 -func extractOpenAISSEDataLine(line string) (string, bool) { - if !strings.HasPrefix(line, "data:") { - return "", false - } - start := len("data:") - for start < len(line) { - if line[start] != ' ' && line[start] != ' ' { - break - } - start++ - } - return line[start:], true -} - -func extractOpenAISSEEventLine(line string) (string, bool) { - if !strings.HasPrefix(line, "event:") { - return "", false - } - start := len("event:") - for start < len(line) { - if line[start] != ' ' && line[start] != ' ' { - break - } - start++ - } - return strings.TrimSpace(line[start:]), true -} - -type openAICompatSSEFrame struct { - EventType string - Data string -} - -type openAICompatSSEFrameParser struct { - eventType string - dataLines []string -} - -func (p *openAICompatSSEFrameParser) AddLine(line string) (openAICompatSSEFrame, bool) { - if line == "" { - return p.dispatch() - } - if strings.HasPrefix(line, ":") { - return openAICompatSSEFrame{}, false - } - if eventType, ok := extractOpenAISSEEventLine(line); ok { - p.eventType = eventType - return openAICompatSSEFrame{}, false - } - if data, ok := extractOpenAISSEDataLine(line); ok { - p.dataLines = append(p.dataLines, data) - } - return openAICompatSSEFrame{}, false -} - -func (p *openAICompatSSEFrameParser) Finish() (openAICompatSSEFrame, bool) { - return p.dispatch() -} - -func (p *openAICompatSSEFrameParser) dispatch() (openAICompatSSEFrame, bool) { - frame := openAICompatSSEFrame{ - EventType: p.eventType, - Data: strings.Join(p.dataLines, "\n"), - } - p.eventType = "" - p.dataLines = nil - return frame, frame.Data != "" -} - -func openAICompatPayloadWithEventType(payload, eventType string) string { - eventType = strings.TrimSpace(eventType) - if eventType == "" || strings.TrimSpace(payload) == "" || strings.TrimSpace(payload) == "[DONE]" { - return payload - } - if gjson.Get(payload, "type").Exists() { - return payload - } - patched, err := sjson.Set(payload, "type", eventType) - if err != nil { - return payload - } - return patched -} - -func (s *OpenAIGatewayService) replaceModelInSSELine(line, fromModel, toModel string) string { - data, ok := extractOpenAISSEDataLine(line) - if !ok { - return line - } - if data == "" || data == "[DONE]" { - return line - } - - // 使用 gjson 精确检查 model 字段,避免全量 JSON 反序列化 - if m := gjson.Get(data, "model"); m.Exists() && m.Str == fromModel { - newData, err := sjson.Set(data, "model", toModel) - if err != nil { - return line - } - return "data: " + newData - } - - // 检查嵌套的 response.model 字段 - if m := gjson.Get(data, "response.model"); m.Exists() && m.Str == fromModel { - newData, err := sjson.Set(data, "response.model", toModel) - if err != nil { - return line - } - return "data: " + newData - } - - return line -} - -// correctToolCallsInResponseBody 修正响应体中的工具调用 -func (s *OpenAIGatewayService) correctToolCallsInResponseBody(body []byte) []byte { - if len(body) == 0 { - return body - } - - updated := body - if s != nil && s.toolCorrector != nil { - if corrected, changed := s.toolCorrector.CorrectToolCallsInSSEBytes(updated); changed { - updated = corrected - } - } - if normalized, changed := normalizeOpenAIResponsesFunctionCallArguments(updated); changed { - updated = normalized - } - return updated -} - -func normalizeOpenAIResponsesFunctionCallArguments(data []byte) ([]byte, bool) { - if len(bytes.TrimSpace(data)) == 0 || !bytes.Contains(data, []byte(`"arguments"`)) { - return data, false - } - if !gjson.ValidBytes(data) { - return data, false - } - - updated := data - changed := false - setDedupedArgument := func(path string) { - arg := gjson.GetBytes(updated, path) - if !arg.Exists() || arg.Type != gjson.String { - return - } - deduped, ok := dedupeRepeatedJSONArgumentString(arg.Str) - if !ok { - return - } - next, err := sjson.SetBytes(updated, path, deduped) - if err != nil { - return - } - updated = next - changed = true - } - - eventType := strings.TrimSpace(gjson.GetBytes(updated, "type").String()) - if eventType == "response.function_call_arguments.done" { - setDedupedArgument("arguments") - } - if itemType := strings.TrimSpace(gjson.GetBytes(updated, "item.type").String()); isResponsesFunctionCallItemType(itemType) { - setDedupedArgument("item.arguments") - } - dedupeResponsesFunctionCallOutputArguments(updated, "response.output", setDedupedArgument) - dedupeResponsesFunctionCallOutputArguments(updated, "output", setDedupedArgument) - - return updated, changed -} - -func dedupeResponsesFunctionCallOutputArguments(data []byte, outputPath string, setDedupedArgument func(string)) { - output := gjson.GetBytes(data, outputPath) - if !output.Exists() || !output.IsArray() { - return - } - for i, item := range output.Array() { - if !isResponsesFunctionCallItemType(strings.TrimSpace(item.Get("type").String())) { - continue - } - setDedupedArgument(outputPath + "." + strconv.Itoa(i) + ".arguments") - } -} - -func isResponsesFunctionCallItemType(itemType string) bool { - return itemType == "function_call" || itemType == "custom_tool_call" -} - -func dedupeRepeatedJSONArgumentString(arguments string) (string, bool) { - if len(arguments) == 0 || len(arguments)%2 != 0 { - return "", false - } - halfLen := len(arguments) / 2 - first := arguments[:halfLen] - if first != arguments[halfLen:] { - return "", false - } - trimmed := strings.TrimSpace(first) - if trimmed == "" || (!strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[")) { - return "", false - } - if !json.Valid([]byte(first)) { - return "", false - } - return first, true -} - -func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) { - s.parseSSEUsageBytes([]byte(data), usage) -} - -func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsage) { - if usage == nil || len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { - return - } - // 选择性解析:仅在数据中包含终止事件标识时才进入字段提取。 - if len(data) < 72 { - return - } - eventType := gjson.GetBytes(data, "type").String() - if eventType != "response.completed" && eventType != "response.done" && eventType != "response.failed" && - eventType != "response.incomplete" && eventType != "response.cancelled" && eventType != "response.canceled" { - return - } - - if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(data); ok { - *usage = parsedUsage - } -} - -func extractOpenAIUsageFromJSONBytes(body []byte) (OpenAIUsage, bool) { - if len(body) == 0 || !gjson.ValidBytes(body) { - return OpenAIUsage{}, false - } - if usage, ok := openAIUsageFromGJSON(gjson.GetBytes(body, "usage")); ok { - return usage, true - } - return openAIUsageFromGJSON(gjson.GetBytes(body, "response.usage")) -} - -func extractOpenAIResponseIDFromJSONBytes(body []byte) string { - if len(body) == 0 || !gjson.ValidBytes(body) { - return "" - } - if id := strings.TrimSpace(gjson.GetBytes(body, "id").String()); id != "" { - return id - } - return strings.TrimSpace(gjson.GetBytes(body, "response.id").String()) -} - -func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *gin.Context, account *Account, responseID string) { - if s == nil || account == nil || account.ID <= 0 { - return - } - responseID = strings.TrimSpace(responseID) - if responseID == "" { - return - } - store := s.getOpenAIWSStateStore() - if store == nil { - return - } - groupID := getOpenAIGroupIDFromContext(c) - ttl := s.openAIWSResponseStickyTTL() - logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) -} - -func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { - if !value.Exists() || !value.IsObject() { - return OpenAIUsage{}, false - } - inputTokens := value.Get("input_tokens").Int() - if inputTokens == 0 { - inputTokens = value.Get("prompt_tokens").Int() - } - outputTokens := value.Get("output_tokens").Int() - if outputTokens == 0 { - outputTokens = value.Get("completion_tokens").Int() - } - cacheReadTokens := value.Get("input_tokens_details.cached_tokens").Int() - if cacheReadTokens == 0 { - cacheReadTokens = value.Get("prompt_tokens_details.cached_tokens").Int() - } - imageOutputTokens := value.Get("output_tokens_details.image_tokens").Int() - if imageOutputTokens == 0 { - imageOutputTokens = value.Get("completion_tokens_details.image_tokens").Int() - } - return OpenAIUsage{ - InputTokens: int(inputTokens), - OutputTokens: int(outputTokens), - CacheCreationInputTokens: int(value.Get("cache_creation_input_tokens").Int()), - CacheReadInputTokens: int(cacheReadTokens), - ImageOutputTokens: int(imageOutputTokens), - }, true -} - -func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { - body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) - if err != nil { - return nil, err - } - - // Detect SSE responses for ALL account types via Content-Type header. - // Some OpenAI-compatible upstreams (including other sub2api instances) - // may return SSE even when stream=false was requested. - if isEventStreamResponse(resp.Header) { - return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) - } - // bodyLooksLikeSSE is a line-level heuristic: real SSE framing requires - // "data:"/"event:" field names at the very start of a physical line. A - // plain bytes.Contains scan would also match ordinary JSON responses - // whose string content merely echoes the literal text "data:" or - // "event:" (e.g. compact tool output), causing those JSON bodies to be - // misrouted into handleSSEToJSON and lose their usage accounting. - bodyLooksLikeSSE := bodyHasSSEFraming(body) - - // For OAuth accounts, also fall back to a body-content heuristic because - // the upstream may omit the Content-Type header while still sending SSE. - // This heuristic is NOT applied to API-key accounts to avoid false - // positives on JSON responses that coincidentally contain "data:" or - // "event:" in their text content. - if account.Type == AccountTypeOAuth && bodyLooksLikeSSE { - return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) - } - - usageValue, usageOK := extractOpenAIUsageFromJSONBytes(body) - if !usageOK { - if bodyLooksLikeSSE { - return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) - } - return nil, fmt.Errorf("parse response: invalid json response") - } - usage := &usageValue - - // Replace model in response if needed - if originalModel != mappedModel { - body = s.replaceModelInResponseBody(body, mappedModel, originalModel) - } - - responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - - contentType := "application/json" - if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled { - if upstreamType := resp.Header.Get("Content-Type"); upstreamType != "" { - contentType = upstreamType - } - } - - c.Data(resp.StatusCode, contentType, body) - - return &openaiNonStreamingResult{ - OpenAIUsage: usage, - usage: usage, - responseID: extractOpenAIResponseIDFromJSONBytes(body), - imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body), - imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body), - }, nil -} - -func isEventStreamResponse(header http.Header) bool { - contentType := strings.ToLower(header.Get("Content-Type")) - return strings.Contains(contentType, "text/event-stream") -} - -// bodyHasSSEFraming reports whether body contains genuine SSE framing by -// scanning for physical lines that begin with the "data:" or "event:" -// field names, per the SSE spec. Unlike a raw substring scan, this does not -// match when those strings only appear embedded inside JSON string values -// (e.g. "data: foo" quoted as part of an assistant text field), since such -// occurrences never start a physical line in a valid JSON encoding. -func bodyHasSSEFraming(body []byte) bool { - for _, line := range bytes.Split(body, []byte("\n")) { - line = bytes.TrimRight(line, "\r") - if bytes.HasPrefix(line, []byte("data:")) || bytes.HasPrefix(line, []byte("event:")) { - return true - } - } - return false -} - -func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { - bodyText := string(body) - finalResponse, ok := extractCodexFinalResponse(bodyText) - - usage := &OpenAIUsage{} - if ok { - if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed { - *usage = parsedUsage - } - // When the terminal event has an empty output array, reconstruct - // output from accumulated delta events so the client gets full content. - // gjson Array() returns empty slice for null, missing, or empty arrays. - if len(gjson.GetBytes(finalResponse, "output").Array()) == 0 { - if outputJSON, reconstructed := reconstructResponseOutputFromSSE(bodyText); reconstructed { - if patched, err := sjson.SetRawBytes(finalResponse, "output", outputJSON); err == nil { - finalResponse = patched - } - } - } - body = finalResponse - if originalModel != mappedModel { - body = s.replaceModelInResponseBody(body, mappedModel, originalModel) - } - // Correct tool calls in final response - body = s.correctToolCallsInResponseBody(body) - } else { - terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText) - if terminalOK && terminalType == "response.failed" { - msg := extractOpenAISSEErrorMessage(terminalPayload) - if msg == "" { - msg = "Upstream compact response failed" - } - return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg) - } - usage = s.parseSSEUsageFromBody(bodyText) - if originalModel != mappedModel { - bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel) - } - body = []byte(bodyText) - } - - responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - - contentType := "application/json; charset=utf-8" - if !ok { - contentType = resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "text/event-stream" - } - } - c.Data(resp.StatusCode, contentType, body) - - return &openaiNonStreamingResult{ - OpenAIUsage: usage, - usage: usage, - responseID: extractOpenAIResponseIDFromJSONBytes(body), - imageCount: countOpenAIImageOutputsFromSSEBody(bodyText), - imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText), - }, nil -} - -func extractOpenAISSETerminalEvent(body string) (string, []byte, bool) { - var terminalType string - var terminalPayload []byte - forEachOpenAISSEDataPayload(body, func(data []byte) { - if terminalPayload != nil { - return - } - eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) - switch eventType { - case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": - terminalType = eventType - terminalPayload = append([]byte(nil), data...) - } - }) - if terminalPayload != nil { - return terminalType, terminalPayload, true - } - return "", nil, false -} - -func extractOpenAISSEErrorMessage(payload []byte) string { - if len(payload) == 0 { - return "" - } - for _, path := range []string{"response.error.message", "error.message", "message"} { - if msg := strings.TrimSpace(gjson.GetBytes(payload, path).String()); msg != "" { - return sanitizeUpstreamErrorMessage(msg) - } - } - return sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(payload))) -} - -func sanitizeOpenAIResponseFailedEventForClient(payload []byte, eventType string) ([]byte, bool) { - if eventType != "response.failed" || len(payload) == 0 || !gjson.ValidBytes(payload) { - return payload, false - } - if !gjson.GetBytes(payload, "response").Exists() { - return payload, false - } - updated := payload - for _, path := range []string{ - "response.instructions", - "response.output", - "response.usage", - "response.metadata", - "response.reasoning", - "response.tools", - "response.tool_choice", - "response.parallel_tool_calls", - "response.text", - "response.truncation", - "response.max_output_tokens", - "response.incomplete_details", - } { - next, err := sjson.DeleteBytes(updated, path) - if err != nil { - return payload, false - } - updated = next - } - return updated, !bytes.Equal(updated, payload) -} - -func (s *OpenAIGatewayService) writeOpenAINonStreamingProtocolError(resp *http.Response, c *gin.Context, message string) error { - message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message)) - if message == "" { - message = "Upstream returned an invalid non-streaming response" - } - setOpsUpstreamError(c, http.StatusBadGateway, message, "") - responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) - c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") - c.JSON(http.StatusBadGateway, gin.H{ - "error": gin.H{ - "type": "upstream_error", - "message": message, - }, - }) - return fmt.Errorf("non-streaming openai protocol error: %s", message) -} - -func extractCodexFinalResponse(body string) ([]byte, bool) { - var finalResponse []byte - forEachOpenAISSEDataPayload(body, func(data []byte) { - if finalResponse != nil { - return - } - eventType := gjson.GetBytes(data, "type").String() - if eventType == "response.done" || eventType == "response.completed" { - if response := gjson.GetBytes(data, "response"); response.Exists() && response.Type == gjson.JSON && response.Raw != "" { - finalResponse = []byte(response.Raw) - } - } - }) - if finalResponse != nil { - return finalResponse, true - } - return nil, false -} - -func normalizeResponsesStreamingTerminalOutput(data []byte, acc *apicompat.BufferedResponseAccumulator, imageOutputs []json.RawMessage) ([]byte, bool) { - eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) - switch eventType { - case "response.completed", "response.done", "response.incomplete", "response.cancelled", "response.canceled": - default: - return data, false - } - - output := gjson.GetBytes(data, "response.output") - hasAccumulatedOutput := (acc != nil && acc.HasContent()) || len(imageOutputs) > 0 - if output.Exists() && output.IsArray() { - if len(output.Array()) > 0 || !hasAccumulatedOutput { - return data, false - } - } - - outputJSON := []byte("[]") - if reconstructed, ok := buildResponsesOutputJSON(acc, imageOutputs); ok { - outputJSON = reconstructed - } - updated, err := sjson.SetRawBytes(data, "response.output", outputJSON) - if err != nil { - return data, false - } - return updated, true -} - -func responsesStreamEventMayContributeToOutput(eventType string) bool { - switch eventType { - case "response.output_text.delta", - "response.output_item.added", - "response.function_call_arguments.delta", - "response.reasoning_summary_text.delta": - return true - default: - return false - } -} - -// reconstructResponseOutputFromSSE scans raw SSE body text for delta events and -// returns a JSON-encoded output array reconstructed from accumulated deltas. -// Returns (nil, false) if no content was found in deltas. -func reconstructResponseOutputFromSSE(bodyText string) ([]byte, bool) { - acc := apicompat.NewBufferedResponseAccumulator() - imageOutputs := make([]json.RawMessage, 0, 1) - seenImages := make(map[string]struct{}) - forEachOpenAISSEDataPayload(bodyText, func(data []byte) { - if imageOutput, ok := extractImageGenerationOutputFromSSEData(data, seenImages); ok { - imageOutputs = append(imageOutputs, imageOutput) - } - eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) - if responsesStreamEventMayContributeToOutput(eventType) { - var event apicompat.ResponsesStreamEvent - if err := json.Unmarshal(data, &event); err == nil { - acc.ProcessEvent(&event) - } - } - }) - return buildResponsesOutputJSON(acc, imageOutputs) -} - -func buildResponsesOutputJSON(acc *apicompat.BufferedResponseAccumulator, imageOutputs []json.RawMessage) ([]byte, bool) { - if (acc == nil || !acc.HasContent()) && len(imageOutputs) == 0 { - return nil, false - } - var output []json.RawMessage - if acc != nil && acc.HasContent() { - outputJSON, err := json.Marshal(acc.BuildOutput()) - if err == nil { - _ = json.Unmarshal(outputJSON, &output) - } - } - output = append(output, imageOutputs...) - if len(output) == 0 { - return nil, false - } - - outputJSON, err := json.Marshal(output) - if err != nil { - return nil, false - } - return outputJSON, true -} - -func extractImageGenerationOutputFromSSEData(data []byte, seen map[string]struct{}) (json.RawMessage, bool) { - if len(data) == 0 || !gjson.ValidBytes(data) { - return nil, false - } - if gjson.GetBytes(data, "type").String() != "response.output_item.done" { - return nil, false - } - item := gjson.GetBytes(data, "item") - if !item.Exists() || !item.IsObject() || item.Get("type").String() != "image_generation_call" { - return nil, false - } - if strings.TrimSpace(item.Get("result").String()) == "" { - return nil, false - } - key := strings.TrimSpace(item.Get("id").String()) - if key == "" { - key = strings.TrimSpace(item.Get("output_format").String()) + "|" + strings.TrimSpace(item.Get("result").String()) - } - if key != "" && seen != nil { - if _, exists := seen[key]; exists { - return nil, false - } - seen[key] = struct{}{} - } - return json.RawMessage(item.Raw), true -} - -func (s *OpenAIGatewayService) parseSSEUsageFromBody(body string) *OpenAIUsage { - usage := &OpenAIUsage{} - forEachOpenAISSEDataPayload(body, func(data []byte) { - s.parseSSEUsageBytes(data, usage) - }) - return usage -} - -func (s *OpenAIGatewayService) replaceModelInSSEBody(body, fromModel, toModel string) string { - lines := strings.Split(body, "\n") - for i, line := range lines { - if _, ok := extractOpenAISSEDataLine(line); !ok { - continue - } - lines[i] = s.replaceModelInSSELine(line, fromModel, toModel) - } - return strings.Join(lines, "\n") -} - -func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) { - if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled { - normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) - if err != nil { - return "", fmt.Errorf("invalid base_url: %w", err) - } - return normalized, nil - } - normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{ - AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts, - RequireAllowlist: true, - AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts, - }) - if err != nil { - return "", fmt.Errorf("invalid base_url: %w", err) - } - return normalized, nil -} - -// buildOpenAIResponsesURL 组装 OpenAI Responses 端点。 -// - base 以 /v1 结尾:追加 /responses -// - base 以其他版本段结尾(如 /v4):追加 /responses -// - base 已是 /responses:原样返回 -// - 其他情况:追加 /v1/responses -func buildOpenAIResponsesURL(base string) string { - return buildOpenAIEndpointURL(base, "/v1/responses") -} - -func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool { - if len(reqBody) == 0 { - return false - } - - inputValue, has := reqBody["input"] - if !has { - return false - } - - switch input := inputValue.(type) { - case []any: - filtered := input[:0] - changed := false - for _, item := range input { - nextItem, itemChanged, keep := sanitizeEncryptedReasoningInputItem(item) - if itemChanged { - changed = true - } - if !keep { - continue - } - filtered = append(filtered, nextItem) - } - if !changed { - return false - } - if len(filtered) == 0 { - delete(reqBody, "input") - return true - } - reqBody["input"] = filtered - return true - case []map[string]any: - filtered := input[:0] - changed := false - for _, item := range input { - nextItem, itemChanged, keep := sanitizeEncryptedReasoningInputItem(item) - if itemChanged { - changed = true - } - if !keep { - continue - } - nextMap, ok := nextItem.(map[string]any) - if !ok { - filtered = append(filtered, item) - continue - } - filtered = append(filtered, nextMap) - } - if !changed { - return false - } - if len(filtered) == 0 { - delete(reqBody, "input") - return true - } - reqBody["input"] = filtered - return true - case map[string]any: - nextItem, changed, keep := sanitizeEncryptedReasoningInputItem(input) - if !changed { - return false - } - if !keep { - delete(reqBody, "input") - return true - } - nextMap, ok := nextItem.(map[string]any) - if !ok { - return false - } - reqBody["input"] = nextMap - return true - default: - return false - } -} - -func sanitizeEncryptedReasoningInputItem(item any) (next any, changed bool, keep bool) { - inputItem, ok := item.(map[string]any) - if !ok { - return item, false, true - } - - itemType, _ := inputItem["type"].(string) - if strings.TrimSpace(itemType) != "reasoning" { - return item, false, true - } - - _, hasEncryptedContent := inputItem["encrypted_content"] - if !hasEncryptedContent { - return item, false, true - } - - delete(inputItem, "encrypted_content") - if len(inputItem) == 1 { - return nil, true, false - } - return inputItem, true, true -} - -func IsOpenAIResponsesCompactPathForTest(c *gin.Context) bool { - return isOpenAIResponsesCompactPath(c) -} - -func OpenAICompactSessionSeedKeyForTest() string { - return openAICompactSessionSeedKey -} - -func NormalizeOpenAICompactRequestBodyForTest(body []byte) ([]byte, bool, error) { - return normalizeOpenAICompactRequestBody(body) -} - -func isOpenAIResponsesCompactPath(c *gin.Context) bool { - suffix := strings.TrimSpace(openAIResponsesRequestPathSuffix(c)) - return suffix == "/compact" || strings.HasPrefix(suffix, "/compact/") -} - -func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) { - if len(body) == 0 { - return body, false, nil - } - - normalized := []byte(`{}`) - // Keep the current Codex /compact schema while still dropping request-scoped - // fields such as prompt_cache_key, store, and stream. - for _, field := range []string{ - "model", - "input", - "instructions", - "tools", - "parallel_tool_calls", - "reasoning", - "text", - "previous_response_id", - } { - value := gjson.GetBytes(body, field) - if !value.Exists() { - continue - } - next, err := sjson.SetRawBytes(normalized, field, []byte(value.Raw)) - if err != nil { - return body, false, fmt.Errorf("normalize compact body %s: %w", field, err) - } - normalized = next - } - - if bytes.Equal(bytes.TrimSpace(body), bytes.TrimSpace(normalized)) { - return body, false, nil - } - return normalized, true, nil -} - -func resolveOpenAICompactSessionID(c *gin.Context) string { - if c != nil { - if sessionID := strings.TrimSpace(c.GetHeader("session_id")); sessionID != "" { - return sessionID - } - if conversationID := strings.TrimSpace(c.GetHeader("conversation_id")); conversationID != "" { - return conversationID - } - if seed, ok := c.Get(openAICompactSessionSeedKey); ok { - if seedStr, ok := seed.(string); ok && strings.TrimSpace(seedStr) != "" { - return strings.TrimSpace(seedStr) - } - } - } - return uuid.NewString() -} - -func openAIResponsesRequestPathSuffix(c *gin.Context) string { - if c == nil || c.Request == nil || c.Request.URL == nil { - return "" - } - normalizedPath := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/") - if normalizedPath == "" { - return "" - } - idx := strings.LastIndex(normalizedPath, "/responses") - if idx < 0 { - return "" - } - suffix := normalizedPath[idx+len("/responses"):] - if suffix == "" || suffix == "/" { - return "" - } - if !strings.HasPrefix(suffix, "/") { - return "" - } - return suffix -} - -func appendOpenAIResponsesRequestPathSuffix(baseURL, suffix string) string { - trimmedBase := strings.TrimRight(strings.TrimSpace(baseURL), "/") - trimmedSuffix := strings.TrimSpace(suffix) - if trimmedBase == "" || trimmedSuffix == "" { - return trimmedBase - } - return trimmedBase + trimmedSuffix -} - -func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte { - // 使用 gjson/sjson 精确替换 model 字段,避免全量 JSON 反序列化 - if m := gjson.GetBytes(body, "model"); m.Exists() && m.Str == fromModel { - newBody, err := sjson.SetBytes(body, "model", toModel) - if err != nil { - return body - } - return newBody - } - return body -} - -func getOpenAIReasoningEffortFromReqBody(reqBody map[string]any) (value string, present bool) { - if reqBody == nil { - return "", false - } - - // Primary: reasoning.effort - if reasoning, ok := reqBody["reasoning"].(map[string]any); ok { - if effort, ok := reasoning["effort"].(string); ok { - return normalizeOpenAIReasoningEffort(effort), true - } - } - - // Fallback: some clients may use a flat field. - if effort, ok := reqBody["reasoning_effort"].(string); ok { - return normalizeOpenAIReasoningEffort(effort), true - } - - return "", false -} - -func deriveOpenAIReasoningEffortFromModel(model string) string { - if strings.TrimSpace(model) == "" { - return "" - } - - modelID := strings.TrimSpace(model) - if strings.Contains(modelID, "/") { - parts := strings.Split(modelID, "/") - modelID = parts[len(parts)-1] - } - - parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool { - switch r { - case '-', '_', ' ': - return true - default: - return false - } - }) - if len(parts) == 0 { - return "" - } - - return normalizeOpenAIReasoningEffort(parts[len(parts)-1]) -} - -type openAIRequestView struct { - body []byte - Model string - Stream bool - PromptCacheKey string - PreviousResponseID string - ServiceTier string - ReasoningEffort string - patches []openAIRequestPatch - patchesDisabled bool -} - -type openAIRequestPatch struct { - path string - delete bool - value any -} - -func newOpenAIRequestView(body []byte) openAIRequestView { - if len(body) == 0 { - return openAIRequestView{} - } - return openAIRequestView{ - body: body, - Model: strings.TrimSpace(gjson.GetBytes(body, "model").String()), - Stream: gjson.GetBytes(body, "stream").Bool(), - PromptCacheKey: strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()), - PreviousResponseID: strings.TrimSpace(gjson.GetBytes(body, "previous_response_id").String()), - ServiceTier: strings.TrimSpace(gjson.GetBytes(body, "service_tier").String()), - ReasoningEffort: strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()), - } -} - -// Decode 保留阶段一既有 full-map 行为;后续阶段会把调用点下沉到复杂分支。 -func (v openAIRequestView) Decode(c *gin.Context) (map[string]any, error) { - return getOpenAIRequestBodyMap(c, v.body) -} - -func (v *openAIRequestView) MarkPatchSet(path string, value any) { - if v == nil || v.patchesDisabled { - return - } - path = strings.TrimSpace(path) - if !isSimpleOpenAIRequestPatchPath(path) { - v.DisablePatches() - return - } - v.patches = append(v.patches, openAIRequestPatch{path: path, value: value}) -} - -func (v *openAIRequestView) MarkPatchDelete(path string) { - if v == nil || v.patchesDisabled { - return - } - path = strings.TrimSpace(path) - if !isSimpleOpenAIRequestPatchPath(path) { - v.DisablePatches() - return - } - v.patches = append(v.patches, openAIRequestPatch{path: path, delete: true}) -} - -func isSimpleOpenAIRequestPatchPath(path string) bool { - if path == "" || strings.ContainsRune(path, '\\') { - return false - } - for _, part := range strings.Split(path, ".") { - if strings.TrimSpace(part) == "" { - return false - } - } - return true -} - -func (v *openAIRequestView) DisablePatches() { - if v == nil { - return - } - v.patchesDisabled = true - v.patches = nil -} - -func (v openAIRequestView) HasPatches() bool { - return !v.patchesDisabled && len(v.patches) > 0 -} - -func (v openAIRequestView) ApplyPatches() ([]byte, error) { - if v.patchesDisabled || len(v.patches) == 0 { - return nil, errors.New("openai request patches disabled") - } - body := v.body - for _, patch := range v.patches { - var err error - if patch.delete { - body, err = sjson.DeleteBytes(body, patch.path) - } else { - body, err = sjson.SetBytes(body, patch.path, patch.value) - } - if err != nil { - return nil, err - } - } - return body, nil -} - -func setOpenAIRequestMapPath(reqBody map[string]any, path string, value any) { - path = strings.TrimSpace(path) - if reqBody == nil || path == "" { - return - } - parts := strings.Split(path, ".") - current := reqBody - for _, part := range parts[:len(parts)-1] { - part = strings.TrimSpace(part) - if part == "" { - return - } - next, _ := current[part].(map[string]any) - if next == nil { - next = map[string]any{} - current[part] = next - } - current = next - } - last := strings.TrimSpace(parts[len(parts)-1]) - if last != "" { - current[last] = value - } -} - -func deleteOpenAIRequestMapPath(reqBody map[string]any, path string) { - path = strings.TrimSpace(path) - if reqBody == nil || path == "" { - return - } - parts := strings.Split(path, ".") - current := reqBody - for _, part := range parts[:len(parts)-1] { - part = strings.TrimSpace(part) - if part == "" { - return - } - next, _ := current[part].(map[string]any) - if next == nil { - return - } - current = next - } - last := strings.TrimSpace(parts[len(parts)-1]) - if last != "" { - delete(current, last) - } -} - -func extractOpenAIRequestMetaFromBody(body []byte) (model string, stream bool, promptCacheKey string) { - view := newOpenAIRequestView(body) - return view.Model, view.Stream, view.PromptCacheKey -} - -// normalizeOpenAIPassthroughOAuthBody 将透传 OAuth 请求体收敛为旧链路关键行为: -// 1) 删除 ChatGPT internal API 不支持的顶层 Responses 参数 -// 2) store=false 3) 非 compact 保持 stream=true;compact 强制 stream=false -func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, bool, error) { - if len(body) == 0 { - return body, false, nil - } - - normalized := body - changed := false - - for _, field := range openAIChatGPTInternalUnsupportedFields { - if value := gjson.GetBytes(normalized, field); !value.Exists() { - continue - } - next, err := sjson.DeleteBytes(normalized, field) - if err != nil { - return body, false, fmt.Errorf("normalize passthrough body delete %s: %w", field, err) - } - normalized = next - changed = true - } - - if compact { - if store := gjson.GetBytes(normalized, "store"); store.Exists() { - next, err := sjson.DeleteBytes(normalized, "store") - if err != nil { - return body, false, fmt.Errorf("normalize passthrough body delete store: %w", err) - } - normalized = next - changed = true - } - if stream := gjson.GetBytes(normalized, "stream"); stream.Exists() { - next, err := sjson.DeleteBytes(normalized, "stream") - if err != nil { - return body, false, fmt.Errorf("normalize passthrough body delete stream: %w", err) - } - normalized = next - changed = true - } - } else { - if store := gjson.GetBytes(normalized, "store"); !store.Exists() || store.Type != gjson.False { - next, err := sjson.SetBytes(normalized, "store", false) - if err != nil { - return body, false, fmt.Errorf("normalize passthrough body store=false: %w", err) - } - normalized = next - changed = true - } - if stream := gjson.GetBytes(normalized, "stream"); !stream.Exists() || stream.Type != gjson.True { - next, err := sjson.SetBytes(normalized, "stream", true) - if err != nil { - return body, false, fmt.Errorf("normalize passthrough body stream=true: %w", err) - } - normalized = next - changed = true - } - } - - return normalized, changed, nil -} - -func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byte) string { - model := strings.ToLower(strings.TrimSpace(reqModel)) - if !strings.Contains(model, "codex") { - return "" - } - - instructions := gjson.GetBytes(body, "instructions") - if !instructions.Exists() { - return "instructions_missing" - } - if instructions.Type != gjson.String { - return "instructions_not_string" - } - if strings.TrimSpace(instructions.String()) == "" { - return "instructions_empty" - } - return "" -} - -func extractOpenAIReasoningEffortFromBody(body []byte, requestedModel string) *string { - reasoningEffort := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) - if reasoningEffort == "" { - reasoningEffort = strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String()) - } - if reasoningEffort != "" { - normalized := normalizeOpenAIReasoningEffort(reasoningEffort) - if normalized == "" { - return nil - } - return &normalized - } - - value := deriveOpenAIReasoningEffortFromModel(requestedModel) - if value == "" { - return nil - } - return &value -} - -func extractOpenAIServiceTier(reqBody map[string]any) *string { - if reqBody == nil { - return nil - } - raw, ok := reqBody["service_tier"].(string) - if !ok { - return nil - } - return normalizeOpenAIServiceTier(raw) -} - -func extractOpenAIServiceTierFromBody(body []byte) *string { - if len(body) == 0 { - return nil - } - return normalizeOpenAIServiceTier(gjson.GetBytes(body, "service_tier").String()) -} - -func normalizeOpenAIServiceTier(raw string) *string { - value := strings.ToLower(strings.TrimSpace(raw)) - if value == "" { - return nil - } - if value == "fast" { - value = "priority" - } - // 放过 OpenAI 官方文档定义的所有合法 tier 值:priority/flex/auto/default/scale。 - // 对 Codex 客户端零影响(Codex 只发 priority 或 flex,见 codex-rs/core/src/client.rs), - // 但能让直连 OpenAI SDK 的用户透传 auto/default/scale 以便抓包/调试。 - // 真未知值仍返回 nil,由 normalizeResponsesBodyServiceTier 从 body 中删除。 - switch value { - case "priority", "flex", "auto", "default", "scale": - return &value - default: - return nil - } -} - -// OpenAIFastBlockedError indicates a request was rejected by the OpenAI fast -// policy (action=block). Mirrors BetaBlockedError on the Claude side. -type OpenAIFastBlockedError struct { - Message string -} - -func (e *OpenAIFastBlockedError) Error() string { return e.Message } - -// evaluateOpenAIFastPolicy returns the action and error message that should be -// applied for a request with the given account/model/service_tier. When the -// policy service is unavailable or no rule matches, it returns -// (BetaPolicyActionPass, "") so callers can short-circuit safely. -// -// Matching rules: -// - Scope filters by account type (all / oauth / apikey / bedrock) -// - ServiceTier must be empty (= any), "all", or equal the normalized tier -// - ModelWhitelist narrows the rule to specific models; FallbackAction -// handles the non-matching case (default: pass) -// -// 与 Claude BetaPolicy 的差异(保留首条匹配 short-circuit): -// - BetaPolicy 处理的是 anthropic-beta header 中的 token 集合,不同 -// 规则可能针对不同 token,filter 需要累加成 set;block 则 first-match。 -// - OpenAI fast policy 操作的是单个字段 service_tier:filter 即删字段, -// 没有可累加的对象。一次请求只携带一个 service_tier,规则的 tier -// 维度天然互斥;同一 (scope, tier) 下若多条规则的 model whitelist -// 发生重叠,admin 可通过规则顺序明确意图。因此采用 first-match 而 -// 非 BetaPolicy 那样的"block 覆盖 filter 覆盖 pass"语义。 -func (s *OpenAIGatewayService) evaluateOpenAIFastPolicy(ctx context.Context, account *Account, model, serviceTier string) (action, errMsg string) { - if s == nil || s.settingService == nil { - return BetaPolicyActionPass, "" - } - tier := strings.ToLower(strings.TrimSpace(serviceTier)) - if tier == "" { - return BetaPolicyActionPass, "" - } - settings := openAIFastPolicySettingsFromContext(ctx) - if settings == nil { - fetched, err := s.settingService.GetOpenAIFastPolicySettings(ctx) - if err != nil || fetched == nil { - return BetaPolicyActionPass, "" - } - settings = fetched - } - return evaluateOpenAIFastPolicyWithSettings(settings, account, model, tier) -} - -// evaluateOpenAIFastPolicyWithSettings is the pure-function core extracted so -// long-lived sessions (e.g. WS) can prefetch settings once and avoid hitting -// the settingService on every frame. See WSSession entry and -// openAIFastPolicySettingsFromContext for the caching glue. -func evaluateOpenAIFastPolicyWithSettings(settings *OpenAIFastPolicySettings, account *Account, model, tier string) (action, errMsg string) { - if settings == nil { - return BetaPolicyActionPass, "" - } - isOAuth := account != nil && account.IsOAuth() - isBedrock := account != nil && account.IsBedrock() - for _, rule := range settings.Rules { - if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) { - continue - } - ruleTier := strings.ToLower(strings.TrimSpace(rule.ServiceTier)) - if ruleTier != "" && ruleTier != OpenAIFastTierAny && ruleTier != tier { - continue - } - eff := BetaPolicyRule{ - Action: rule.Action, - ErrorMessage: rule.ErrorMessage, - ModelWhitelist: rule.ModelWhitelist, - FallbackAction: rule.FallbackAction, - FallbackErrorMessage: rule.FallbackErrorMessage, - } - return resolveRuleAction(eff, model) - } - return BetaPolicyActionPass, "" -} - -// openAIFastPolicyCtxKey 是 context 中预取的 OpenAIFastPolicySettings 缓存 -// 键,仅用于 WebSocket 长会话内多帧复用同一份策略快照,避免每帧 DB 命中。 -// -// Trade-off:策略变更不会影响当前 WS session(只影响新 session)。这是 -// 有意为之 —— 对长会话来说,"策略一致性"比"立刻生效"更重要,且 Claude -// BetaPolicy 的 gin.Context 缓存也是同样取舍。需要 hot-reload 时管理员 -// 可以通过踢断 session 强制刷新。 -type openAIFastPolicyCtxKeyType struct{} - -var openAIFastPolicyCtxKey = openAIFastPolicyCtxKeyType{} - -// withOpenAIFastPolicyContext 将一份 settings 快照绑定到 context,供该 ctx -// 衍生 goroutine 中的 evaluateOpenAIFastPolicy 复用。 -func withOpenAIFastPolicyContext(ctx context.Context, settings *OpenAIFastPolicySettings) context.Context { - if ctx == nil || settings == nil { - return ctx - } - return context.WithValue(ctx, openAIFastPolicyCtxKey, settings) -} - -func openAIFastPolicySettingsFromContext(ctx context.Context) *OpenAIFastPolicySettings { - if ctx == nil { - return nil - } - if v, ok := ctx.Value(openAIFastPolicyCtxKey).(*OpenAIFastPolicySettings); ok { - return v - } - return nil -} - -// applyOpenAIFastPolicyToBody applies the OpenAI fast policy to a raw request -// body. When action=filter it removes the service_tier field; when -// action=block it returns (body, *OpenAIFastBlockedError). On pass it -// normalizes the service_tier value (e.g. client alias "fast" → "priority"). -// action=force_priority rewrites any matched known tier to "priority". -// -// Rationale for normalize-on-pass: chat-completions / messages 入口在调用本 -// 函数之前已经通过 normalizeResponsesBodyServiceTier 把 service_tier 归一化 -// 到了上游可识别值;passthrough(OpenAI 自动透传) / native /responses 等 -// 入口没有这一前置步骤,pass 路径下若不在此处归一化,"fast" 就会被原样 -// 透传到 OpenAI 上游导致 400/拒绝。把归一化收敛到本函数,所有入口行为一致。 -func (s *OpenAIGatewayService) applyOpenAIFastPolicyToBody(ctx context.Context, account *Account, model string, body []byte) ([]byte, error) { - if len(body) == 0 { - return body, nil - } - rawTier := gjson.GetBytes(body, "service_tier").String() - if rawTier == "" { - return body, nil - } - normTier := normalizedOpenAIServiceTierValue(rawTier) - if normTier == "" { - return body, nil - } - action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, model, normTier) - switch action { - case BetaPolicyActionBlock: - msg := errMsg - if msg == "" { - msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, model) - } - return body, &OpenAIFastBlockedError{Message: msg} - case BetaPolicyActionFilter: - trimmed, err := sjson.DeleteBytes(body, "service_tier") - if err != nil { - return body, fmt.Errorf("strip service_tier from body: %w", err) - } - return trimmed, nil - case OpenAIFastPolicyActionForcePriority: - updated, err := sjson.SetBytes(body, "service_tier", OpenAIFastTierPriority) - if err != nil { - return body, fmt.Errorf("force service_tier priority on body: %w", err) - } - return updated, nil - default: - // pass:把别名(如 "fast")写回为规范值("priority")。 - if normTier == rawTier { - return body, nil - } - updated, err := sjson.SetBytes(body, "service_tier", normTier) - if err != nil { - return body, fmt.Errorf("normalize service_tier on pass: %w", err) - } - return updated, nil - } -} - -// writeOpenAIFastPolicyBlockedResponse writes a 403 JSON response for a -// request blocked by the OpenAI fast policy. -func writeOpenAIFastPolicyBlockedResponse(c *gin.Context, err *OpenAIFastBlockedError) { - if c == nil || err == nil { - return - } - MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) - c.JSON(http.StatusForbidden, gin.H{ - "error": gin.H{ - "type": "permission_error", - "message": err.Message, - }, - }) -} - -// applyOpenAIFastPolicyToWSResponseCreate evaluates the OpenAI fast policy -// against a single client→upstream WebSocket frame whose top-level -// "type"=="response.create". It mirrors the HTTP-side -// applyOpenAIFastPolicyToBody contract but operates on a Realtime/Responses -// WS payload: -// -// - pass: keeps service_tier, normalizing aliases such as "fast" to "priority" -// - filter: returns a copy with top-level service_tier removed -// - force_priority: keeps service_tier and rewrites it to "priority" -// - block: returns (frame, *OpenAIFastBlockedError) -// -// Only frames whose "type" field strictly equals "response.create" are -// inspected/mutated. Any other frame type — including the empty string — -// passes through untouched. The OpenAI Realtime client-event spec requires -// "type" to be set, so an empty type is treated as a malformed frame we do -// not police; the upstream is the source of truth for rejecting it. -// -// service_tier lives at the top level of response.create — same as the -// Responses HTTP body shape (see openai_gateway_chat_completions.go:304 + -// extractOpenAIServiceTierFromBody at line 5593, and the test fixture at -// openai_ws_forwarder_ingress_session_test.go:402). We therefore only need -// to inspect / strip the top-level field; there is no nested form in the -// schema today. -// -// The caller is responsible for choosing the upstream model passed in — -// this helper does not re-derive it. -func (s *OpenAIGatewayService) applyOpenAIFastPolicyToWSResponseCreate( - ctx context.Context, - account *Account, - model string, - frame []byte, -) ([]byte, *OpenAIFastBlockedError, error) { - if len(frame) == 0 { - return frame, nil, nil - } - if !gjson.ValidBytes(frame) { - return frame, nil, nil - } - frameType := strings.TrimSpace(gjson.GetBytes(frame, "type").String()) - // Strict match: only response.create is policy-checked. Empty / other - // types pass through untouched so we never accidentally strip fields - // from response.cancel, conversation.item.create, or any future - // client-event the spec adds. The Realtime spec requires "type" on - // every client event, so an empty type is malformed input — let the - // upstream reject it rather than guessing at our layer. - if frameType != "response.create" { - return frame, nil, nil - } - rawTier := gjson.GetBytes(frame, "service_tier").String() - if rawTier == "" { - return frame, nil, nil - } - normTier := normalizedOpenAIServiceTierValue(rawTier) - if normTier == "" { - return frame, nil, nil - } - action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, model, normTier) - switch action { - case BetaPolicyActionBlock: - msg := errMsg - if msg == "" { - msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, model) - } - return frame, &OpenAIFastBlockedError{Message: msg}, nil - case BetaPolicyActionFilter: - trimmed, err := sjson.DeleteBytes(frame, "service_tier") - if err != nil { - return frame, nil, fmt.Errorf("strip service_tier from ws frame: %w", err) - } - return trimmed, nil, nil - case OpenAIFastPolicyActionForcePriority: - updated, err := sjson.SetBytes(frame, "service_tier", OpenAIFastTierPriority) - if err != nil { - return frame, nil, fmt.Errorf("force service_tier priority in ws frame: %w", err) - } - return updated, nil, nil - default: - if normTier == rawTier { - return frame, nil, nil - } - updated, err := sjson.SetBytes(frame, "service_tier", normTier) - if err != nil { - return frame, nil, fmt.Errorf("normalize service_tier in ws frame: %w", err) - } - return updated, nil, nil - } -} - -// newOpenAIFastPolicyWSEventID returns a Realtime-style event_id for a -// server-emitted error event. Matches the loose "evt_" convention used -// by upstream Realtime servers; the exact value is not load-bearing and is -// only required for client-side log correlation. We reuse the existing -// google/uuid dependency rather than pulling a new one. -func newOpenAIFastPolicyWSEventID() string { - id, err := uuid.NewRandom() - if err != nil { - // Extremely unlikely; fall back to a fixed prefix so the field is - // still non-empty and the schema stays self-consistent. - return "evt_openai_fast_policy" - } - // Strip dashes so it visually matches "evt_" rather than UUID v4 - // canonical form, mirroring what real Realtime traces look like. - return "evt_" + strings.ReplaceAll(id.String(), "-", "") -} - -// buildOpenAIFastPolicyBlockedWSEvent renders an OpenAI Realtime/Responses -// style "error" event payload for a request blocked by the OpenAI fast -// policy. The shape mirrors Realtime error events as observed in upstream -// traces and per the spec's server "error" event: -// -// { -// "event_id": "evt_", -// "type": "error", -// "error": { -// "type": "invalid_request_error", -// "code": "policy_violation", -// "message": "..." -// } -// } -// -// event_id lets clients correlate the rejection in their logs; "code" gives -// programmatic clients a stable identifier (HTTP-side equivalent is the -// 403 permission_error JSON body). -func buildOpenAIFastPolicyBlockedWSEvent(err *OpenAIFastBlockedError) []byte { - if err == nil { - return nil - } - eventID := newOpenAIFastPolicyWSEventID() - payload, mErr := json.Marshal(map[string]any{ - "event_id": eventID, - "type": "error", - "error": map[string]any{ - "type": "invalid_request_error", - "code": "policy_violation", - "message": err.Message, - }, - }) - if mErr != nil { - // Fallback to a minimal hand-rolled payload; Marshal of the literal - // shape above should never fail in practice. - return []byte(`{"event_id":"` + eventID + `","type":"error","error":{"type":"invalid_request_error","code":"policy_violation","message":"openai fast policy blocked this request"}}`) - } - return payload -} - -func openAIRequestBodyMayContainImageInput(body []byte) bool { - if len(body) == 0 { - return false - } - input := gjson.GetBytes(body, "input") - messages := gjson.GetBytes(body, "messages.#-1") - return openAIJSONValueMayContainImageInput(input) || openAIJSONValueMayContainImageInput(messages) -} - -func openAIJSONValueMayContainImageInput(value gjson.Result) bool { - if !value.Exists() { - return false - } - if value.IsArray() { - found := false - value.ForEach(func(_, item gjson.Result) bool { - if openAIJSONValueMayContainImageInput(item) { - found = true - return false - } - return true - }) - return found - } - if value.IsObject() { - if strings.TrimSpace(value.Get("type").String()) == "input_image" || value.Get("image_url").Exists() { - return true - } - return openAIJSONValueMayContainImageInput(value.Get("content")) - } - return false -} - -func openAIRequestBodyMayContainEmptyBase64InputImage(body []byte) bool { - if len(body) == 0 || !openAIRequestBodyMayContainInputImageToken(body) { - return false - } - input := gjson.GetBytes(body, "input") - if !input.Exists() { - return false - } - return openAIJSONValueMayContainEmptyBase64InputImage(input) -} - -func openAIRequestBodyMayContainInputImageToken(body []byte) bool { - if bytes.Contains(body, []byte("input_image")) { - return true - } - // JSON 字符串任意字符都可能被 unicode escape,遇到 \u 时交给 gjson 解码后的结构扫描兜底。 - return bytes.Contains(body, []byte("\\u")) -} - -func openAIJSONValueMayContainEmptyBase64InputImage(value gjson.Result) bool { - if !value.Exists() { - return false - } - if value.IsArray() { - found := false - value.ForEach(func(_, item gjson.Result) bool { - if openAIJSONValueMayContainEmptyBase64InputImage(item) { - found = true - return false - } - return true - }) - return found - } - if value.IsObject() { - if strings.TrimSpace(value.Get("type").String()) == "input_image" && isEmptyBase64DataURI(value.Get("image_url").String()) { - return true - } - return openAIJSONValueMayContainEmptyBase64InputImage(value.Get("content")) - } - return false -} - -func sanitizeEmptyBase64InputImagesInOpenAIBody(body []byte) ([]byte, bool, error) { - if !openAIRequestBodyMayContainEmptyBase64InputImage(body) { - return body, false, nil - } - - var reqBody map[string]any - if err := json.Unmarshal(body, &reqBody); err != nil { - return body, false, fmt.Errorf("sanitize request body: %w", err) - } - if !sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody) { - return body, false, nil - } - normalized, err := marshalOpenAIUpstreamJSON(reqBody) - if err != nil { - return body, false, fmt.Errorf("serialize sanitized request body: %w", err) - } - return normalized, true, nil -} - -func sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody map[string]any) bool { - if reqBody == nil { - return false - } - input, ok := reqBody["input"] - if !ok { - return false - } - normalizedInput, changed := sanitizeEmptyBase64InputImagesInOpenAIInput(input) - if !changed { - return false - } - reqBody["input"] = normalizedInput - return true -} - -func sanitizeEmptyBase64InputImagesInOpenAIInput(input any) (any, bool) { - items, ok := input.([]any) - if !ok { - return input, false - } - - normalizedItems := make([]any, 0, len(items)) - changed := false - for _, item := range items { - itemMap, ok := item.(map[string]any) - if !ok { - normalizedItems = append(normalizedItems, item) - continue - } - if shouldDropEmptyBase64InputImagePart(itemMap) { - changed = true - continue - } - content, ok := itemMap["content"] - if !ok { - normalizedItems = append(normalizedItems, itemMap) - continue - } - parts, ok := content.([]any) - if !ok { - normalizedItems = append(normalizedItems, itemMap) - continue - } - - normalizedParts := make([]any, 0, len(parts)) - itemChanged := false - for _, part := range parts { - if shouldDropEmptyBase64InputImagePart(part) { - changed = true - itemChanged = true - continue - } - normalizedParts = append(normalizedParts, part) - } - if itemChanged { - if len(normalizedParts) == 0 { - continue - } - itemMap["content"] = normalizedParts - } - normalizedItems = append(normalizedItems, itemMap) - } - if !changed { - return input, false - } - return normalizedItems, true -} - -func shouldDropEmptyBase64InputImagePart(part any) bool { - partMap, ok := part.(map[string]any) - if !ok { - return false - } - typeValue, _ := partMap["type"].(string) - if strings.TrimSpace(typeValue) != "input_image" { - return false - } - imageURL, _ := partMap["image_url"].(string) - return isEmptyBase64DataURI(imageURL) -} - -func isEmptyBase64DataURI(raw string) bool { - if !strings.HasPrefix(raw, "data:") { - return false - } - rest := strings.TrimPrefix(raw, "data:") - semicolonIdx := strings.Index(rest, ";") - if semicolonIdx < 0 { - return false - } - rest = rest[semicolonIdx+1:] - if !strings.HasPrefix(rest, "base64,") { - return false - } - return strings.TrimSpace(strings.TrimPrefix(rest, "base64,")) == "" -} - -func getOpenAIRequestBodyMap(_ *gin.Context, body []byte) (map[string]any, error) { - var reqBody map[string]any - if err := json.Unmarshal(body, &reqBody); err != nil { - return nil, fmt.Errorf("parse request: %w", err) - } - return reqBody, nil -} - -func extractOpenAIReasoningEffort(reqBody map[string]any, requestedModel string) *string { - if value, present := getOpenAIReasoningEffortFromReqBody(reqBody); present { - if value == "" { - return nil - } - return &value - } - - value := deriveOpenAIReasoningEffortFromModel(requestedModel) - if value == "" { - return nil - } - return &value -} - -func normalizeOpenAIReasoningEffort(raw string) string { - value := strings.ToLower(strings.TrimSpace(raw)) - if value == "" { - return "" - } - - // Normalize separators for "x-high"/"x_high" variants. - value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) - - switch value { - case "none", "minimal": - return "" - case "low", "medium", "high": - return value - case "xhigh", "extrahigh", "max": - return "xhigh" - default: - // Only store known effort levels for now to keep UI consistent. - return "" - } -} diff --git a/backend/internal/service/openai_gateway_upstream_errors.go b/backend/internal/service/openai_gateway_upstream_errors.go new file mode 100644 index 0000000000..2e661e7417 --- /dev/null +++ b/backend/internal/service/openai_gateway_upstream_errors.go @@ -0,0 +1,597 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "go.uber.org/zap" +) + +func logOpenAIInstructionsRequiredDebug( + ctx context.Context, + c *gin.Context, + account *Account, + upstreamStatusCode int, + upstreamMsg string, + requestBody []byte, + upstreamBody []byte, +) { + msg := strings.TrimSpace(upstreamMsg) + if !isOpenAIInstructionsRequiredError(upstreamStatusCode, msg, upstreamBody) { + return + } + if ctx == nil { + ctx = context.Background() + } + + accountID := int64(0) + accountName := "" + if account != nil { + accountID = account.ID + accountName = strings.TrimSpace(account.Name) + } + + userAgent := "" + originator := "" + if c != nil { + userAgent = strings.TrimSpace(c.GetHeader("User-Agent")) + originator = strings.TrimSpace(c.GetHeader("originator")) + } + + fields := []zap.Field{ + zap.String("component", "service.openai_gateway"), + zap.Int64("account_id", accountID), + zap.String("account_name", accountName), + zap.Int("upstream_status_code", upstreamStatusCode), + zap.String("upstream_error_message", msg), + zap.String("request_user_agent", userAgent), + zap.Bool("codex_official_client_match", openai.IsCodexOfficialClientByHeaders(userAgent, originator)), + } + fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, requestBody) + + logger.FromContext(ctx).With(fields...).Warn("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查") +} + +func isOpenAIInstructionsRequiredError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool { + if upstreamStatusCode != http.StatusBadRequest { + return false + } + + hasInstructionRequired := func(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false + } + if strings.Contains(lower, "instructions are required") { + return true + } + if strings.Contains(lower, "required parameter: 'instructions'") { + return true + } + if strings.Contains(lower, "required parameter: instructions") { + return true + } + if strings.Contains(lower, "missing required parameter") && strings.Contains(lower, "instructions") { + return true + } + return strings.Contains(lower, "instruction") && strings.Contains(lower, "required") + } + + if hasInstructionRequired(upstreamMsg) { + return true + } + if len(upstreamBody) == 0 { + return false + } + + errMsg := gjson.GetBytes(upstreamBody, "error.message").String() + errMsgLower := strings.ToLower(strings.TrimSpace(errMsg)) + errCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.code").String())) + errParam := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.param").String())) + errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.type").String())) + + if errParam == "instructions" { + return true + } + if hasInstructionRequired(errMsg) { + return true + } + if strings.Contains(errCode, "missing_required_parameter") && strings.Contains(errMsgLower, "instructions") { + return true + } + if strings.Contains(errType, "invalid_request") && strings.Contains(errMsgLower, "instructions") && strings.Contains(errMsgLower, "required") { + return true + } + + return false +} + +func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool { + if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable { + return false + } + + hasOpenAIServerOverloadedCode := func(payload []byte) bool { + code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String())) + if code == "" { + code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String())) + } + return code == "server_is_overloaded" || code == "slow_down" + } + + if len(upstreamBody) > 0 && hasOpenAIServerOverloadedCode(upstreamBody) { + return true + } + if upstreamStatusCode != http.StatusBadRequest { + return false + } + + match := func(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false + } + if strings.Contains(lower, "an error occurred while processing your request") { + return true + } + if strings.Contains(lower, "selected model is at capacity") { + return true + } + return strings.Contains(lower, "you can retry your request") && + strings.Contains(lower, "help.openai.com") && + strings.Contains(lower, "request id") + } + + if match(upstreamMsg) { + return true + } + if len(upstreamBody) == 0 { + return false + } + if match(gjson.GetBytes(upstreamBody, "error.message").String()) { + return true + } + return match(string(upstreamBody)) +} + +func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool { + match := func(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return false + } + if strings.Contains(lower, "context_too_large") || strings.Contains(lower, "context_length_exceeded") { + return true + } + if strings.Contains(lower, "maximum context length") || strings.Contains(lower, "max context length") { + return true + } + hasExceeded := strings.Contains(lower, "exceed") || strings.Contains(lower, "too large") || strings.Contains(lower, "too long") + if strings.Contains(lower, "context window") && hasExceeded { + return true + } + if strings.Contains(lower, "context length") && hasExceeded { + return true + } + return strings.Contains(lower, "token limit") && + strings.Contains(lower, "context") && + hasExceeded + } + + if match(upstreamMsg) { + return true + } + if len(upstreamBody) == 0 { + return false + } + for _, path := range []string{ + "error.message", + "response.error.message", + "message", + "error.code", + "response.error.code", + "code", + } { + if match(gjson.GetBytes(upstreamBody, path).String()) { + return true + } + } + return match(string(upstreamBody)) +} + +func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool { + switch statusCode { + case 401, 402, 403, 429, 529: + return true + default: + return statusCode >= 500 + } +} + +func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode int, upstreamMsg string, upstreamBody []byte) bool { + if isOpenAIContextWindowError(upstreamMsg, upstreamBody) { + return false + } + if s.shouldFailoverUpstreamError(statusCode) { + return true + } + return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody) +} + +func marshalOpenAIUpstreamJSON(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + out := buf.Bytes() + if len(out) > 0 && out[len(out)-1] == '\n' { + out = out[:len(out)-1] + } + return out, nil +} + +func openAIUpstreamErrorBodyReadLimitForConfig(cfg *config.Config) int64 { + limit := openAIUpstreamErrorBodyReadLimit + if cfg != nil && cfg.Gateway.LogUpstreamErrorBody && cfg.Gateway.LogUpstreamErrorBodyMaxBytes > int(limit) { + limit = int64(cfg.Gateway.LogUpstreamErrorBodyMaxBytes) + } + return limit +} + +func (s *OpenAIGatewayService) readUpstreamErrorBody(resp *http.Response) []byte { + if resp == nil || resp.Body == nil { + return nil + } + cfg := (*config.Config)(nil) + if s != nil { + cfg = s.cfg + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, openAIUpstreamErrorBodyReadLimitForConfig(cfg))) + return body +} + +func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, responseBody []byte, requestedModel ...string) { + if len(requestedModel) > 0 { + s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody, requestedModel[0]) + return + } + s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody) +} + +func (s *OpenAIGatewayService) handleErrorResponse( + ctx context.Context, + resp *http.Response, + c *gin.Context, + account *Account, + requestBody []byte, + requestedModel ...string, +) (*OpenAIForwardResult, error) { + body := s.readUpstreamErrorBody(resp) + + // cyber_policy 硬阻断:透传上游原始错误体给客户端(不重包成通用 502),不冷却账号。 + // 当前请求恒透传(需求1);标记供 handler 事后写风控/邮件。400 cyber 不可 failover + // (shouldFailoverUpstreamError(400)=false),故走到此处即可安全早返回。 + if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit { + MarkOpsCyberPolicy(c, CyberPolicyMark{ + Code: code, + Message: cyberMsg, + Body: truncateString(string(body), 4096), + UpstreamStatus: resp.StatusCode, + }) + setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048)) + writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + c.Data(resp.StatusCode, contentType, body) + if cyberMsg == "" { + return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode) + } + return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg) + } + + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(body), maxBytes) + } + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body) + + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + logger.LegacyPrintf("service.openai_gateway", + "OpenAI upstream error %d (account=%d platform=%s type=%s): %s", + resp.StatusCode, + account.ID, + account.Platform, + account.Type, + truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes), + ) + } + + if status, errType, errMsg, matched := applyErrorPassthroughRule( + c, + PlatformOpenAI, + resp.StatusCode, + body, + http.StatusBadGateway, + "upstream_error", + "Upstream request failed", + ); matched { + MarkResponseCommitted(c) + c.JSON(status, gin.H{ + "error": gin.H{ + "type": errType, + "message": errMsg, + }, + }) + if upstreamMsg == "" { + upstreamMsg = errMsg + } + if upstreamMsg == "" { + return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg) + } + + // Check custom error codes + if !account.ShouldHandleErrorCode(resp.StatusCode) { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + MarkResponseCommitted(c) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "type": "upstream_error", + "message": "Upstream gateway error", + }, + }) + if upstreamMsg == "" { + return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg) + } + + // Handle upstream error (mark account status) + var reqModel string + if len(requestedModel) > 0 { + reqModel = strings.TrimSpace(requestedModel[0]) + } + if reqModel == "" { + reqModel, _, _ = extractOpenAIRequestMetaFromBody(requestBody) + } + shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel) + kind := "http_error" + if shouldDisable { + kind = "failover" + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: kind, + Message: upstreamMsg, + Detail: upstreamDetail, + }) + if shouldDisable { + return nil, &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: body, + RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + } + } + + MarkResponseCommitted(c) + + // Return appropriate error response + var errType, errMsg string + var statusCode int + + switch resp.StatusCode { + case 401: + statusCode = http.StatusBadGateway + errType = "upstream_error" + errMsg = "Upstream authentication failed, please contact administrator" + case 402: + statusCode = http.StatusBadGateway + errType = "upstream_error" + errMsg = "Upstream payment required: insufficient balance or billing issue" + case 403: + statusCode = http.StatusBadGateway + errType = "upstream_error" + errMsg = "Upstream access forbidden, please contact administrator" + case 429: + statusCode = http.StatusTooManyRequests + errType = "rate_limit_error" + errMsg = "Upstream rate limit exceeded, please retry later" + default: + statusCode = http.StatusBadGateway + errType = "upstream_error" + errMsg = "Upstream request failed" + } + if isOpenAIContextWindowError(upstreamMsg, body) && upstreamMsg != "" { + errMsg = upstreamMsg + } + + c.JSON(statusCode, gin.H{ + "error": gin.H{ + "type": errType, + "message": errMsg, + }, + }) + + if upstreamMsg == "" { + return nil, fmt.Errorf("upstream error: %d", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg) +} + +// compatErrorWriter is the signature for format-specific error writers used by +// the compat paths (Chat Completions and Anthropic Messages). +type compatErrorWriter func(c *gin.Context, statusCode int, errType, message string) + +// handleCompatErrorResponse is the shared non-failover error handler for the +// Chat Completions and Anthropic Messages compat paths. It mirrors the logic of +// handleErrorResponse (passthrough rules, ShouldHandleErrorCode, rate-limit +// tracking, secondary failover) but delegates the final error write to the +// format-specific writer function. +func (s *OpenAIGatewayService) handleCompatErrorResponse( + resp *http.Response, + c *gin.Context, + account *Account, + writeError compatErrorWriter, + requestedModel ...string, +) (*OpenAIForwardResult, error) { + body := s.readUpstreamErrorBody(resp) + + // cyber_policy:兼容路径(Chat Completions / Anthropic)以各自格式回写错误, + // 不原样透传 responses 格式的 cyber body(否则对下游格式不合法)。cyber 是上游网络 + // 安全策略拦截,不冷却账号,故标记后直接以兼容格式回写错误并返回,跳过下方 + // handleOpenAIAccountUpstreamError(避免自定义 temp-unschedulable 规则误冷却)。 + if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit { + MarkOpsCyberPolicy(c, CyberPolicyMark{ + Code: code, + Message: cyberMsg, + Body: truncateString(string(body), 4096), + UpstreamStatus: resp.StatusCode, + }) + setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048)) + clientMsg := cyberMsg + if clientMsg == "" { + clientMsg = "Request blocked by upstream cyber-security policy" + } + writeError(c, resp.StatusCode, "invalid_request_error", clientMsg) + if cyberMsg == "" { + return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode) + } + return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg) + } + + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) + if upstreamMsg == "" { + upstreamMsg = fmt.Sprintf("Upstream error: %d", resp.StatusCode) + } + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(body), maxBytes) + } + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + + // Apply error passthrough rules + if status, errType, errMsg, matched := applyErrorPassthroughRule( + c, account.Platform, resp.StatusCode, body, + http.StatusBadGateway, "api_error", "Upstream request failed", + ); matched { + MarkResponseCommitted(c) + writeError(c, status, errType, errMsg) + if upstreamMsg == "" { + upstreamMsg = errMsg + } + if upstreamMsg == "" { + return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg) + } + + // Check custom error codes — if the account does not handle this status, + // return a generic error without exposing upstream details. + if !account.ShouldHandleErrorCode(resp.StatusCode) { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + MarkResponseCommitted(c) + writeError(c, http.StatusInternalServerError, "api_error", "Upstream gateway error") + if upstreamMsg == "" { + return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg) + } + + // Track rate limits and decide whether to trigger secondary failover. + var modelForCooldown string + if len(requestedModel) > 0 { + modelForCooldown = requestedModel[0] + } + shouldDisable := s.handleOpenAIAccountUpstreamError( + c.Request.Context(), account, resp.StatusCode, resp.Header, body, modelForCooldown, + ) + kind := "http_error" + if shouldDisable { + kind = "failover" + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: kind, + Message: upstreamMsg, + Detail: upstreamDetail, + }) + if shouldDisable { + return nil, &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: body, + RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + } + } + + MarkResponseCommitted(c) + + // Map status code to error type and write response + errType := "api_error" + switch { + case resp.StatusCode == 400: + errType = "invalid_request_error" + case resp.StatusCode == 404: + errType = "not_found_error" + case resp.StatusCode == 429: + errType = "rate_limit_error" + case resp.StatusCode >= 500: + errType = "api_error" + } + + writeError(c, resp.StatusCode, errType, upstreamMsg) + return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg) +} diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index bbca9776ab..e0bd769c3c 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -1,27 +1,15 @@ package service import ( - "bytes" "context" - "encoding/json" "errors" "fmt" - "io" "math/rand" - "net" - "net/http" - "net/url" - "sort" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" - "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" coderws "github.com/coder/websocket" - "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" "go.uber.org/zap" ) @@ -227,676 +215,6 @@ type OpenAIWSIngressHooks struct { AfterTurn func(turn int, result *OpenAIForwardResult, turnErr error) } -func normalizeOpenAIWSLogValue(value string) string { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return "-" - } - return openAIWSLogValueReplacer.Replace(trimmed) -} - -func truncateOpenAIWSLogValue(value string, maxLen int) string { - normalized := normalizeOpenAIWSLogValue(value) - if normalized == "-" || maxLen <= 0 { - return normalized - } - if len(normalized) <= maxLen { - return normalized - } - return normalized[:maxLen] + "..." -} - -func openAIWSHeaderValueForLog(headers http.Header, key string) string { - if headers == nil { - return "-" - } - return truncateOpenAIWSLogValue(headers.Get(key), openAIWSHeaderValueMaxLen) -} - -func hasOpenAIWSHeader(headers http.Header, key string) bool { - if headers == nil { - return false - } - return strings.TrimSpace(headers.Get(key)) != "" -} - -type openAIWSSessionHeaderResolution struct { - SessionID string - ConversationID string - SessionSource string - ConversationSource string -} - -func resolveOpenAIWSSessionHeaders(c *gin.Context, promptCacheKey string) openAIWSSessionHeaderResolution { - resolution := openAIWSSessionHeaderResolution{ - SessionSource: "none", - ConversationSource: "none", - } - if c != nil && c.Request != nil { - if sessionID := strings.TrimSpace(c.Request.Header.Get("session_id")); sessionID != "" { - resolution.SessionID = sessionID - resolution.SessionSource = "header_session_id" - } - if conversationID := strings.TrimSpace(c.Request.Header.Get("conversation_id")); conversationID != "" { - resolution.ConversationID = conversationID - resolution.ConversationSource = "header_conversation_id" - if resolution.SessionID == "" { - resolution.SessionID = conversationID - resolution.SessionSource = "header_conversation_id" - } - } - } - - cacheKey := strings.TrimSpace(promptCacheKey) - if cacheKey != "" { - if resolution.SessionID == "" { - resolution.SessionID = cacheKey - resolution.SessionSource = "prompt_cache_key" - } - } - return resolution -} - -func shouldLogOpenAIWSEvent(idx int, eventType string) bool { - if idx <= openAIWSEventLogHeadLimit { - return true - } - if openAIWSEventLogEveryN > 0 && idx%openAIWSEventLogEveryN == 0 { - return true - } - if eventType == "error" || isOpenAIWSTerminalEvent(eventType) { - return true - } - return false -} - -func shouldLogOpenAIWSBufferedEvent(idx int) bool { - if idx <= openAIWSBufferLogHeadLimit { - return true - } - if openAIWSBufferLogEveryN > 0 && idx%openAIWSBufferLogEveryN == 0 { - return true - } - return false -} - -func openAIWSEventMayContainModel(eventType string) bool { - switch eventType { - case "response.created", - "response.in_progress", - "response.completed", - "response.done", - "response.failed", - "response.incomplete", - "response.cancelled", - "response.canceled": - return true - default: - trimmed := strings.TrimSpace(eventType) - if trimmed == eventType { - return false - } - switch trimmed { - case "response.created", - "response.in_progress", - "response.completed", - "response.done", - "response.failed", - "response.incomplete", - "response.cancelled", - "response.canceled": - return true - default: - return false - } - } -} - -func openAIWSEventMayContainToolCalls(eventType string) bool { - eventType = strings.TrimSpace(eventType) - if eventType == "" { - return false - } - if strings.Contains(eventType, "function_call") || strings.Contains(eventType, "tool_call") { - return true - } - switch eventType { - case "response.output_item.added", "response.output_item.done", "response.completed", "response.done": - return true - default: - return false - } -} - -func openAIWSEventShouldParseUsage(eventType string) bool { - switch strings.TrimSpace(eventType) { - case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": - return true - default: - return false - } -} - -func parseOpenAIWSEventEnvelope(message []byte) (eventType string, responseID string, response gjson.Result) { - if len(message) == 0 { - return "", "", gjson.Result{} - } - values := gjson.GetManyBytes(message, "type", "response.id", "id", "response") - eventType = strings.TrimSpace(values[0].String()) - if id := strings.TrimSpace(values[1].String()); id != "" { - responseID = id - } else { - responseID = strings.TrimSpace(values[2].String()) - } - return eventType, responseID, values[3] -} - -func openAIWSMessageLikelyContainsToolCalls(message []byte) bool { - if len(message) == 0 { - return false - } - return bytes.Contains(message, []byte(`"tool_calls"`)) || - bytes.Contains(message, []byte(`"tool_call"`)) || - bytes.Contains(message, []byte(`"function_call"`)) -} - -func parseOpenAIWSResponseUsageFromCompletedEvent(message []byte, usage *OpenAIUsage) { - if usage == nil || len(message) == 0 { - return - } - if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(message); ok { - *usage = parsedUsage - } -} - -func parseOpenAIWSErrorEventFields(message []byte) (code string, errType string, errMessage string) { - if len(message) == 0 { - return "", "", "" - } - values := gjson.GetManyBytes(message, "error.code", "error.type", "error.message") - return strings.TrimSpace(values[0].String()), strings.TrimSpace(values[1].String()), strings.TrimSpace(values[2].String()) -} - -func summarizeOpenAIWSErrorEventFieldsFromRaw(codeRaw, errTypeRaw, errMessageRaw string) (code string, errType string, errMessage string) { - code = truncateOpenAIWSLogValue(codeRaw, openAIWSLogValueMaxLen) - errType = truncateOpenAIWSLogValue(errTypeRaw, openAIWSLogValueMaxLen) - errMessage = truncateOpenAIWSLogValue(errMessageRaw, openAIWSLogValueMaxLen) - return code, errType, errMessage -} - -func summarizeOpenAIWSErrorEventFields(message []byte) (code string, errType string, errMessage string) { - if len(message) == 0 { - return "-", "-", "-" - } - return summarizeOpenAIWSErrorEventFieldsFromRaw(parseOpenAIWSErrorEventFields(message)) -} - -func summarizeOpenAIWSPayloadKeySizes(payload map[string]any, topN int) string { - if len(payload) == 0 { - return "-" - } - type keySize struct { - Key string - Size int - } - sizes := make([]keySize, 0, len(payload)) - for key, value := range payload { - size := estimateOpenAIWSPayloadValueSize(value, openAIWSPayloadSizeEstimateDepth) - sizes = append(sizes, keySize{Key: key, Size: size}) - } - sort.Slice(sizes, func(i, j int) bool { - if sizes[i].Size == sizes[j].Size { - return sizes[i].Key < sizes[j].Key - } - return sizes[i].Size > sizes[j].Size - }) - - if topN <= 0 || topN > len(sizes) { - topN = len(sizes) - } - parts := make([]string, 0, topN) - for idx := 0; idx < topN; idx++ { - item := sizes[idx] - parts = append(parts, fmt.Sprintf("%s:%d", item.Key, item.Size)) - } - return strings.Join(parts, ",") -} - -func estimateOpenAIWSPayloadValueSize(value any, depth int) int { - if depth <= 0 { - return -1 - } - switch v := value.(type) { - case nil: - return 0 - case string: - return len(v) - case []byte: - return len(v) - case bool: - return 1 - case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: - return 8 - case float32, float64: - return 8 - case map[string]any: - if len(v) == 0 { - return 2 - } - total := 2 - count := 0 - for key, item := range v { - count++ - if count > openAIWSPayloadSizeEstimateMaxItems { - return -1 - } - itemSize := estimateOpenAIWSPayloadValueSize(item, depth-1) - if itemSize < 0 { - return -1 - } - total += len(key) + itemSize + 3 - if total > openAIWSPayloadSizeEstimateMaxBytes { - return -1 - } - } - return total - case []any: - if len(v) == 0 { - return 2 - } - total := 2 - limit := len(v) - if limit > openAIWSPayloadSizeEstimateMaxItems { - return -1 - } - for i := 0; i < limit; i++ { - itemSize := estimateOpenAIWSPayloadValueSize(v[i], depth-1) - if itemSize < 0 { - return -1 - } - total += itemSize + 1 - if total > openAIWSPayloadSizeEstimateMaxBytes { - return -1 - } - } - return total - default: - raw, err := json.Marshal(v) - if err != nil { - return -1 - } - if len(raw) > openAIWSPayloadSizeEstimateMaxBytes { - return -1 - } - return len(raw) - } -} - -func openAIWSPayloadString(payload map[string]any, key string) string { - if len(payload) == 0 { - return "" - } - raw, ok := payload[key] - if !ok { - return "" - } - switch v := raw.(type) { - case nil: - return "" - case string: - return strings.TrimSpace(v) - case []byte: - return strings.TrimSpace(string(v)) - default: - return "" - } -} - -func openAIWSPayloadStringFromRaw(payload []byte, key string) string { - if len(payload) == 0 || strings.TrimSpace(key) == "" { - return "" - } - return strings.TrimSpace(gjson.GetBytes(payload, key).String()) -} - -func openAIWSPayloadBoolFromRaw(payload []byte, key string, defaultValue bool) bool { - if len(payload) == 0 || strings.TrimSpace(key) == "" { - return defaultValue - } - value := gjson.GetBytes(payload, key) - if !value.Exists() { - return defaultValue - } - if value.Type != gjson.True && value.Type != gjson.False { - return defaultValue - } - return value.Bool() -} - -func openAIWSSessionHashesFromID(sessionID string) (string, string) { - return deriveOpenAISessionHashes(sessionID) -} - -func extractOpenAIWSImageURL(value any) string { - switch v := value.(type) { - case string: - return strings.TrimSpace(v) - case map[string]any: - if raw, ok := v["url"].(string); ok { - return strings.TrimSpace(raw) - } - } - return "" -} - -func summarizeOpenAIWSInput(input any) string { - items, ok := input.([]any) - if !ok || len(items) == 0 { - return "-" - } - - itemCount := len(items) - textChars := 0 - imageDataURLs := 0 - imageDataURLChars := 0 - imageRemoteURLs := 0 - - handleContentItem := func(contentItem map[string]any) { - contentType, _ := contentItem["type"].(string) - switch strings.TrimSpace(contentType) { - case "input_text", "output_text", "text": - if text, ok := contentItem["text"].(string); ok { - textChars += len(text) - } - case "input_image": - imageURL := extractOpenAIWSImageURL(contentItem["image_url"]) - if imageURL == "" { - return - } - if strings.HasPrefix(strings.ToLower(imageURL), "data:image/") { - imageDataURLs++ - imageDataURLChars += len(imageURL) - return - } - imageRemoteURLs++ - } - } - - handleInputItem := func(inputItem map[string]any) { - if content, ok := inputItem["content"].([]any); ok { - for _, rawContent := range content { - contentItem, ok := rawContent.(map[string]any) - if !ok { - continue - } - handleContentItem(contentItem) - } - return - } - - itemType, _ := inputItem["type"].(string) - switch strings.TrimSpace(itemType) { - case "input_text", "output_text", "text": - if text, ok := inputItem["text"].(string); ok { - textChars += len(text) - } - case "input_image": - imageURL := extractOpenAIWSImageURL(inputItem["image_url"]) - if imageURL == "" { - return - } - if strings.HasPrefix(strings.ToLower(imageURL), "data:image/") { - imageDataURLs++ - imageDataURLChars += len(imageURL) - return - } - imageRemoteURLs++ - } - } - - for _, rawItem := range items { - inputItem, ok := rawItem.(map[string]any) - if !ok { - continue - } - handleInputItem(inputItem) - } - - return fmt.Sprintf( - "items=%d,text_chars=%d,image_data_urls=%d,image_data_url_chars=%d,image_remote_urls=%d", - itemCount, - textChars, - imageDataURLs, - imageDataURLChars, - imageRemoteURLs, - ) -} - -func dropOpenAIWSPayloadKey(payload map[string]any, key string, removed *[]string) { - if len(payload) == 0 || strings.TrimSpace(key) == "" { - return - } - if _, exists := payload[key]; !exists { - return - } - delete(payload, key) - *removed = append(*removed, key) -} - -// applyOpenAIWSRetryPayloadStrategy 在 WS 连续失败时仅移除无语义字段, -// 避免重试成功却改变原始请求语义。 -// 注意:prompt_cache_key 不应在重试中移除;它常用于会话稳定标识(session_id 兜底)。 -func applyOpenAIWSRetryPayloadStrategy(payload map[string]any, attempt int) (strategy string, removedKeys []string) { - if len(payload) == 0 { - return "empty", nil - } - if attempt <= 1 { - return "full", nil - } - - removed := make([]string, 0, 2) - if attempt >= 2 { - dropOpenAIWSPayloadKey(payload, "include", &removed) - } - - if len(removed) == 0 { - return "full", nil - } - sort.Strings(removed) - return "trim_optional_fields", removed -} - -func logOpenAIWSModeInfo(format string, args ...any) { - logger.LegacyPrintf("service.openai_gateway", "[OpenAI WS Mode][openai_ws_mode=true] "+format, args...) -} - -func isOpenAIWSModeDebugEnabled() bool { - return logger.L().Core().Enabled(zap.DebugLevel) -} - -func logOpenAIWSModeDebug(format string, args ...any) { - if !isOpenAIWSModeDebugEnabled() { - return - } - logger.LegacyPrintf("service.openai_gateway", "[debug] [OpenAI WS Mode][openai_ws_mode=true] "+format, args...) -} - -func logOpenAIWSBindResponseAccountWarn(groupID, accountID int64, responseID string, err error) { - if err == nil { - return - } - logger.L().Warn( - "openai.ws_bind_response_account_failed", - zap.Int64("group_id", groupID), - zap.Int64("account_id", accountID), - zap.String("response_id", truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen)), - zap.Error(err), - ) -} - -func summarizeOpenAIWSReadCloseError(err error) (status string, reason 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) { - reasonText := strings.TrimSpace(closeErr.Reason) - if reasonText != "" { - closeReason = normalizeOpenAIWSLogValue(reasonText) - } - } - return normalizeOpenAIWSLogValue(closeStatus), closeReason -} - -func unwrapOpenAIWSDialBaseError(err error) error { - if err == nil { - return nil - } - var dialErr *openAIWSDialError - if errors.As(err, &dialErr) && dialErr != nil && dialErr.Err != nil { - return dialErr.Err - } - return err -} - -func openAIWSDialRespHeaderForLog(err error, key string) string { - var dialErr *openAIWSDialError - if !errors.As(err, &dialErr) || dialErr == nil || dialErr.ResponseHeaders == nil { - return "-" - } - return truncateOpenAIWSLogValue(dialErr.ResponseHeaders.Get(key), openAIWSHeaderValueMaxLen) -} - -func classifyOpenAIWSDialError(err error) string { - if err == nil { - return "-" - } - baseErr := unwrapOpenAIWSDialBaseError(err) - if baseErr == nil { - return "-" - } - if errors.Is(baseErr, context.DeadlineExceeded) { - return "ctx_deadline_exceeded" - } - if errors.Is(baseErr, context.Canceled) { - return "ctx_canceled" - } - var netErr net.Error - if errors.As(baseErr, &netErr) && netErr.Timeout() { - return "net_timeout" - } - if status := coderws.CloseStatus(baseErr); status != -1 { - return normalizeOpenAIWSLogValue(fmt.Sprintf("ws_close_%d", int(status))) - } - message := strings.ToLower(strings.TrimSpace(baseErr.Error())) - switch { - case strings.Contains(message, "handshake not finished"): - return "handshake_not_finished" - case strings.Contains(message, "bad handshake"): - return "bad_handshake" - case strings.Contains(message, "connection refused"): - return "connection_refused" - case strings.Contains(message, "no such host"): - return "dns_not_found" - case strings.Contains(message, "tls"): - return "tls_error" - case strings.Contains(message, "i/o timeout"): - return "io_timeout" - case strings.Contains(message, "context deadline exceeded"): - return "ctx_deadline_exceeded" - default: - return "dial_error" - } -} - -func summarizeOpenAIWSDialError(err error) ( - statusCode int, - dialClass string, - closeStatus string, - closeReason string, - respServer string, - respVia string, - respCFRay string, - respRequestID string, -) { - dialClass = "-" - closeStatus = "-" - closeReason = "-" - respServer = "-" - respVia = "-" - respCFRay = "-" - respRequestID = "-" - if err == nil { - return - } - var dialErr *openAIWSDialError - if errors.As(err, &dialErr) && dialErr != nil { - statusCode = dialErr.StatusCode - respServer = openAIWSDialRespHeaderForLog(err, "server") - respVia = openAIWSDialRespHeaderForLog(err, "via") - respCFRay = openAIWSDialRespHeaderForLog(err, "cf-ray") - respRequestID = openAIWSDialRespHeaderForLog(err, "x-request-id") - } - dialClass = normalizeOpenAIWSLogValue(classifyOpenAIWSDialError(err)) - closeStatus, closeReason = summarizeOpenAIWSReadCloseError(unwrapOpenAIWSDialBaseError(err)) - return -} - -func isOpenAIWSClientDisconnectError(err error) bool { - if err == nil { - return false - } - if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || errors.Is(err, context.Canceled) { - return true - } - switch coderws.CloseStatus(err) { - case coderws.StatusNormalClosure, coderws.StatusGoingAway, coderws.StatusNoStatusRcvd, coderws.StatusAbnormalClosure: - return true - } - message := strings.ToLower(strings.TrimSpace(err.Error())) - if message == "" { - return false - } - return strings.Contains(message, "failed to read frame header: eof") || - strings.Contains(message, "unexpected eof") || - strings.Contains(message, "use of closed network connection") || - strings.Contains(message, "connection reset by peer") || - strings.Contains(message, "broken pipe") || - strings.Contains(message, "an established connection was aborted") -} - -func classifyOpenAIWSReadFallbackReason(err error) string { - if err == nil { - return "read_event" - } - switch coderws.CloseStatus(err) { - case coderws.StatusPolicyViolation: - return "policy_violation" - case coderws.StatusMessageTooBig: - return "message_too_big" - default: - return "read_event" - } -} - -func sortedKeys(m map[string]any) []string { - if len(m) == 0 { - return nil - } - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - func (s *OpenAIGatewayService) getOpenAIWSConnPool() *openAIWSConnPool { if s == nil { return nil @@ -1067,3609 +385,3 @@ func (s *OpenAIGatewayService) openAIWSAcquireTimeout() time.Duration { } return dial + 2*time.Second } - -func (s *OpenAIGatewayService) buildOpenAIResponsesWSURL(account *Account) (string, error) { - if account == nil { - return "", errors.New("account is nil") - } - var targetURL string - switch account.Type { - case AccountTypeOAuth: - targetURL = chatgptCodexURL - case AccountTypeAPIKey: - baseURL := account.GetOpenAIBaseURL() - if baseURL == "" { - targetURL = openaiPlatformAPIURL - } else { - validatedURL, err := s.validateUpstreamBaseURL(baseURL) - if err != nil { - return "", err - } - targetURL = buildOpenAIResponsesURL(validatedURL) - } - default: - targetURL = openaiPlatformAPIURL - } - - parsed, err := url.Parse(strings.TrimSpace(targetURL)) - if err != nil { - return "", fmt.Errorf("invalid target url: %w", err) - } - switch strings.ToLower(parsed.Scheme) { - case "https": - parsed.Scheme = "wss" - case "http": - parsed.Scheme = "ws" - case "wss", "ws": - // 保持不变 - default: - return "", fmt.Errorf("unsupported scheme for ws: %s", parsed.Scheme) - } - return parsed.String(), nil -} - -func (s *OpenAIGatewayService) buildOpenAIWSHeaders( - ctx context.Context, - c *gin.Context, - account *Account, - token string, - decision OpenAIWSProtocolDecision, - isCodexCLI bool, - turnState string, - turnMetadata string, - promptCacheKey string, -) (http.Header, openAIWSSessionHeaderResolution, error) { - headers := make(http.Header) - headers.Set("authorization", "Bearer "+token) - - sessionResolution := resolveOpenAIWSSessionHeaders(c, promptCacheKey) - if c != nil && c.Request != nil { - if v := strings.TrimSpace(c.Request.Header.Get("accept-language")); v != "" { - headers.Set("accept-language", v) - } - } - // OAuth 账号:将 apiKeyID 混入 session 标识符,防止跨用户会话碰撞。 - if account != nil && account.Type == AccountTypeOAuth { - apiKeyID := getAPIKeyIDFromContext(c) - if sessionResolution.SessionID != "" { - headers.Set("session_id", isolateOpenAISessionID(apiKeyID, sessionResolution.SessionID)) - } - if sessionResolution.ConversationID != "" { - headers.Set("conversation_id", isolateOpenAISessionID(apiKeyID, sessionResolution.ConversationID)) - } - } else { - if sessionResolution.SessionID != "" { - headers.Set("session_id", sessionResolution.SessionID) - } - if sessionResolution.ConversationID != "" { - headers.Set("conversation_id", sessionResolution.ConversationID) - } - } - if state := strings.TrimSpace(turnState); state != "" { - headers.Set(openAIWSTurnStateHeader, state) - } - if metadata := strings.TrimSpace(turnMetadata); metadata != "" { - headers.Set(openAIWSTurnMetadataHeader, metadata) - } - - if account != nil && account.Type == AccountTypeOAuth { - if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { - return nil, sessionResolution, fmt.Errorf("resolve chatgpt account headers: %w", err) - } - headers.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI)) - } - - betaValue := openAIWSBetaV2Value - if decision.Transport == OpenAIUpstreamTransportResponsesWebsocket { - betaValue = openAIWSBetaV1Value - } - headers.Set("OpenAI-Beta", betaValue) - - customUA := "" - if account != nil { - customUA = account.GetOpenAIUserAgent() - } - if strings.TrimSpace(customUA) != "" { - headers.Set("user-agent", customUA) - } else if c != nil { - if ua := strings.TrimSpace(c.GetHeader("User-Agent")); ua != "" { - headers.Set("user-agent", ua) - } - } - if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - headers.Set("user-agent", codexCLIUserAgent) - } - if account != nil && account.Type == AccountTypeOAuth && !openai.IsCodexCLIRequest(headers.Get("user-agent")) { - headers.Set("user-agent", codexCLIUserAgent) - } - - // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。 - // 覆盖所有 WS 模式(ctx_pool/dedicated/passthrough)的握手头。 - account.ApplyHeaderOverrides(headers) - - return headers, sessionResolution, nil -} - -func (s *OpenAIGatewayService) buildOpenAIWSCreatePayload(reqBody map[string]any, account *Account) map[string]any { - // OpenAI WS Mode 协议:response.create 字段与 HTTP /responses 基本一致。 - // 保留 stream 字段(与 Codex CLI 一致),仅移除 background。 - payload := make(map[string]any, len(reqBody)+1) - for k, v := range reqBody { - payload[k] = v - } - - delete(payload, "background") - if _, exists := payload["stream"]; !exists { - payload["stream"] = true - } - payload["type"] = "response.create" - - // OAuth 默认保持 store=false,避免误依赖服务端历史。 - if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) { - payload["store"] = false - } - return payload -} - -func setOpenAIWSTurnMetadata(payload map[string]any, turnMetadata string) { - if len(payload) == 0 { - return - } - metadata := strings.TrimSpace(turnMetadata) - if metadata == "" { - return - } - - switch existing := payload["client_metadata"].(type) { - case map[string]any: - existing[openAIWSTurnMetadataHeader] = metadata - payload["client_metadata"] = existing - case map[string]string: - next := make(map[string]any, len(existing)+1) - for k, v := range existing { - next[k] = v - } - next[openAIWSTurnMetadataHeader] = metadata - payload["client_metadata"] = next - default: - payload["client_metadata"] = map[string]any{ - openAIWSTurnMetadataHeader: metadata, - } - } -} - -func (s *OpenAIGatewayService) isOpenAIWSStoreRecoveryAllowed(account *Account) bool { - if account != nil && account.IsOpenAIWSAllowStoreRecoveryEnabled() { - return true - } - if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.AllowStoreRecovery { - return true - } - return false -} - -func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequest(reqBody map[string]any, account *Account) bool { - if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) { - return true - } - if len(reqBody) == 0 { - return false - } - rawStore, ok := reqBody["store"] - if !ok { - return false - } - storeEnabled, ok := rawStore.(bool) - if !ok { - return false - } - return !storeEnabled -} - -func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequestRaw(reqBody []byte, account *Account) bool { - if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) { - return true - } - if len(reqBody) == 0 { - return false - } - storeValue := gjson.GetBytes(reqBody, "store") - if !storeValue.Exists() { - return false - } - if storeValue.Type != gjson.True && storeValue.Type != gjson.False { - return false - } - return !storeValue.Bool() -} - -func (s *OpenAIGatewayService) openAIWSStoreDisabledConnMode() string { - if s == nil || s.cfg == nil { - return openAIWSStoreDisabledConnModeStrict - } - mode := strings.ToLower(strings.TrimSpace(s.cfg.Gateway.OpenAIWS.StoreDisabledConnMode)) - switch mode { - case openAIWSStoreDisabledConnModeStrict, openAIWSStoreDisabledConnModeAdaptive, openAIWSStoreDisabledConnModeOff: - return mode - case "": - // 兼容旧配置:仅配置了布尔开关时按旧语义推导。 - if s.cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn { - return openAIWSStoreDisabledConnModeStrict - } - return openAIWSStoreDisabledConnModeOff - default: - return openAIWSStoreDisabledConnModeStrict - } -} - -func shouldForceNewConnOnStoreDisabled(mode, lastFailureReason string) bool { - switch mode { - case openAIWSStoreDisabledConnModeOff: - return false - case openAIWSStoreDisabledConnModeAdaptive: - reason := strings.TrimPrefix(strings.TrimSpace(lastFailureReason), "prewarm_") - switch reason { - case "policy_violation", "message_too_big", "auth_failed", "write_request", "write": - return true - default: - return false - } - default: - return true - } -} - -func dropPreviousResponseIDFromRawPayload(payload []byte) ([]byte, bool, error) { - return dropPreviousResponseIDFromRawPayloadWithDeleteFn(payload, sjson.DeleteBytes) -} - -func dropPreviousResponseIDFromRawPayloadWithDeleteFn( - payload []byte, - deleteFn func([]byte, string) ([]byte, error), -) ([]byte, bool, error) { - if len(payload) == 0 { - return payload, false, nil - } - if !gjson.GetBytes(payload, "previous_response_id").Exists() { - return payload, false, nil - } - if deleteFn == nil { - deleteFn = sjson.DeleteBytes - } - - updated := payload - for i := 0; i < openAIWSMaxPrevResponseIDDeletePasses && - gjson.GetBytes(updated, "previous_response_id").Exists(); i++ { - next, err := deleteFn(updated, "previous_response_id") - if err != nil { - return payload, false, err - } - updated = next - } - return updated, !gjson.GetBytes(updated, "previous_response_id").Exists(), nil -} - -func setPreviousResponseIDToRawPayload(payload []byte, previousResponseID string) ([]byte, error) { - normalizedPrevID := strings.TrimSpace(previousResponseID) - if len(payload) == 0 || normalizedPrevID == "" { - return payload, nil - } - updated, err := sjson.SetBytes(payload, "previous_response_id", normalizedPrevID) - if err == nil { - return updated, nil - } - - var reqBody map[string]any - if unmarshalErr := json.Unmarshal(payload, &reqBody); unmarshalErr != nil { - return nil, err - } - reqBody["previous_response_id"] = normalizedPrevID - rebuilt, marshalErr := json.Marshal(reqBody) - if marshalErr != nil { - return nil, marshalErr - } - return rebuilt, nil -} - -func shouldInferIngressFunctionCallOutputPreviousResponseID( - storeDisabled bool, - turn int, - signals ToolContinuationSignals, - currentPreviousResponseID string, - expectedPreviousResponseID string, -) bool { - if !storeDisabled || turn <= 1 || !signals.HasFunctionCallOutput { - return false - } - if strings.TrimSpace(currentPreviousResponseID) != "" { - return false - } - if signals.HasFunctionCallOutputMissingCallID { - return false - } - // If the client already sent the actual tool-call context, treat this as - // a full replay / self-contained continuation payload rather than - // downgrading it into an inferred delta continuation. item_reference alone - // is not enough on the store=false WS path: it still needs a valid prior - // response anchor so upstream can resolve the referenced function_call. - if signals.HasToolCallContext { - return false - } - return strings.TrimSpace(expectedPreviousResponseID) != "" -} - -func alignStoreDisabledPreviousResponseID( - payload []byte, - expectedPreviousResponseID string, -) ([]byte, bool, error) { - if len(payload) == 0 { - return payload, false, nil - } - expected := strings.TrimSpace(expectedPreviousResponseID) - if expected == "" { - return payload, false, nil - } - current := openAIWSPayloadStringFromRaw(payload, "previous_response_id") - if current == "" || current == expected { - return payload, false, nil - } - - withoutPrev, removed, dropErr := dropPreviousResponseIDFromRawPayload(payload) - if dropErr != nil { - return payload, false, dropErr - } - if !removed { - return payload, false, nil - } - updated, setErr := setPreviousResponseIDToRawPayload(withoutPrev, expected) - if setErr != nil { - return payload, false, setErr - } - return updated, true, nil -} - -func cloneOpenAIWSPayloadBytes(payload []byte) []byte { - if len(payload) == 0 { - return nil - } - cloned := make([]byte, len(payload)) - copy(cloned, payload) - return cloned -} - -func cloneOpenAIWSRawMessages(items []json.RawMessage) []json.RawMessage { - if items == nil { - return nil - } - cloned := make([]json.RawMessage, 0, len(items)) - for idx := range items { - cloned = append(cloned, json.RawMessage(cloneOpenAIWSPayloadBytes(items[idx]))) - } - return cloned -} - -func normalizeOpenAIWSJSONForCompare(raw []byte) ([]byte, error) { - trimmed := bytes.TrimSpace(raw) - if len(trimmed) == 0 { - return nil, errors.New("json is empty") - } - var decoded any - if err := json.Unmarshal(trimmed, &decoded); err != nil { - return nil, err - } - return json.Marshal(decoded) -} - -func normalizeOpenAIWSJSONForCompareOrRaw(raw []byte) []byte { - normalized, err := normalizeOpenAIWSJSONForCompare(raw) - if err != nil { - return bytes.TrimSpace(raw) - } - return normalized -} - -func normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload []byte) ([]byte, error) { - if len(payload) == 0 { - return nil, errors.New("payload is empty") - } - var decoded map[string]any - if err := json.Unmarshal(payload, &decoded); err != nil { - return nil, err - } - delete(decoded, "input") - delete(decoded, "previous_response_id") - return json.Marshal(decoded) -} - -func openAIWSExtractNormalizedInputSequence(payload []byte) ([]json.RawMessage, bool, error) { - if len(payload) == 0 { - return nil, false, nil - } - inputValue := gjson.GetBytes(payload, "input") - if !inputValue.Exists() { - return nil, false, nil - } - if inputValue.Type == gjson.JSON { - raw := strings.TrimSpace(inputValue.Raw) - if strings.HasPrefix(raw, "[") { - var items []json.RawMessage - if err := json.Unmarshal([]byte(raw), &items); err != nil { - return nil, true, err - } - return items, true, nil - } - return []json.RawMessage{json.RawMessage(raw)}, true, nil - } - if inputValue.Type == gjson.String { - encoded, _ := json.Marshal(inputValue.String()) - return []json.RawMessage{encoded}, true, nil - } - return []json.RawMessage{json.RawMessage(inputValue.Raw)}, true, nil -} - -func openAIWSInputIsPrefixExtended(previousPayload, currentPayload []byte) (bool, error) { - previousItems, previousExists, prevErr := openAIWSExtractNormalizedInputSequence(previousPayload) - if prevErr != nil { - return false, prevErr - } - currentItems, currentExists, currentErr := openAIWSExtractNormalizedInputSequence(currentPayload) - if currentErr != nil { - return false, currentErr - } - if !previousExists && !currentExists { - return true, nil - } - if !previousExists { - return len(currentItems) == 0, nil - } - if !currentExists { - return len(previousItems) == 0, nil - } - if len(currentItems) < len(previousItems) { - return false, nil - } - - for idx := range previousItems { - previousNormalized := normalizeOpenAIWSJSONForCompareOrRaw(previousItems[idx]) - currentNormalized := normalizeOpenAIWSJSONForCompareOrRaw(currentItems[idx]) - if !bytes.Equal(previousNormalized, currentNormalized) { - return false, nil - } - } - return true, nil -} - -func openAIWSRawItemsHasPrefix(items []json.RawMessage, prefix []json.RawMessage) bool { - if len(prefix) == 0 { - return true - } - if len(items) < len(prefix) { - return false - } - for idx := range prefix { - previousNormalized := normalizeOpenAIWSJSONForCompareOrRaw(prefix[idx]) - currentNormalized := normalizeOpenAIWSJSONForCompareOrRaw(items[idx]) - if !bytes.Equal(previousNormalized, currentNormalized) { - return false - } - } - return true -} - -func openAIWSRawItemsHasFunctionCallOutput(items []json.RawMessage) bool { - for _, item := range items { - if isCodexToolCallOutputItemType(gjson.GetBytes(item, "type").String()) { - return true - } - } - return false -} - -func openAIWSRawItemsHaveToolCallContextForOutputs(items []json.RawMessage) bool { - if len(items) == 0 { - return false - } - contextCallIDs := make(map[string]struct{}) - outputCallIDs := make(map[string]struct{}) - for _, item := range items { - itemType := gjson.GetBytes(item, "type").String() - callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String()) - switch { - case isCodexToolCallContextItemType(itemType): - if callID != "" { - contextCallIDs[callID] = struct{}{} - } - case isCodexToolCallOutputItemType(itemType): - if callID == "" { - return false - } - outputCallIDs[callID] = struct{}{} - } - } - if len(outputCallIDs) == 0 || len(contextCallIDs) == 0 { - return false - } - for callID := range outputCallIDs { - if _, ok := contextCallIDs[callID]; !ok { - return false - } - } - return true -} - -func openAIWSRawPayloadHasToolCallOutput(payload []byte) bool { - if len(payload) == 0 { - return false - } - input := gjson.GetBytes(payload, "input") - if !input.Exists() { - return false - } - if input.IsArray() { - for _, item := range input.Array() { - if isCodexToolCallOutputItemType(item.Get("type").String()) { - return true - } - } - return false - } - if input.Type == gjson.JSON { - return isCodexToolCallOutputItemType(input.Get("type").String()) - } - return false -} - -func buildOpenAIWSReplayInputSequence( - previousFullInput []json.RawMessage, - previousFullInputExists bool, - currentPayload []byte, - hasPreviousResponseID bool, -) ([]json.RawMessage, bool, error) { - currentItems, currentExists, currentErr := openAIWSExtractNormalizedInputSequence(currentPayload) - if currentErr != nil { - return nil, false, currentErr - } - if !hasPreviousResponseID { - return cloneOpenAIWSRawMessages(currentItems), currentExists, nil - } - if !previousFullInputExists { - return cloneOpenAIWSRawMessages(currentItems), currentExists, nil - } - if !currentExists || len(currentItems) == 0 { - return cloneOpenAIWSRawMessages(previousFullInput), true, nil - } - if openAIWSRawItemsHasPrefix(currentItems, previousFullInput) { - return cloneOpenAIWSRawMessages(currentItems), true, nil - } - merged := make([]json.RawMessage, 0, len(previousFullInput)+len(currentItems)) - merged = append(merged, cloneOpenAIWSRawMessages(previousFullInput)...) - merged = append(merged, cloneOpenAIWSRawMessages(currentItems)...) - return merged, true, nil -} - -func setOpenAIWSPayloadInputSequence( - payload []byte, - fullInput []json.RawMessage, - fullInputExists bool, -) ([]byte, error) { - if !fullInputExists { - return payload, nil - } - // Preserve [] vs null semantics when input exists but is empty. - inputForMarshal := fullInput - if inputForMarshal == nil { - inputForMarshal = []json.RawMessage{} - } - inputRaw, marshalErr := json.Marshal(inputForMarshal) - if marshalErr != nil { - return nil, marshalErr - } - return sjson.SetRawBytes(payload, "input", inputRaw) -} - -func shouldKeepIngressPreviousResponseID( - previousPayload []byte, - currentPayload []byte, - lastTurnResponseID string, - hasFunctionCallOutput bool, -) (bool, string, error) { - if hasFunctionCallOutput { - return true, "has_function_call_output", nil - } - currentPreviousResponseID := strings.TrimSpace(openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id")) - if currentPreviousResponseID == "" { - return false, "missing_previous_response_id", nil - } - expectedPreviousResponseID := strings.TrimSpace(lastTurnResponseID) - if expectedPreviousResponseID == "" { - return false, "missing_last_turn_response_id", nil - } - if currentPreviousResponseID != expectedPreviousResponseID { - return false, "previous_response_id_mismatch", nil - } - if len(previousPayload) == 0 { - return false, "missing_previous_turn_payload", nil - } - - previousComparable, previousComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(previousPayload) - if previousComparableErr != nil { - return false, "non_input_compare_error", previousComparableErr - } - currentComparable, currentComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(currentPayload) - if currentComparableErr != nil { - return false, "non_input_compare_error", currentComparableErr - } - if !bytes.Equal(previousComparable, currentComparable) { - return false, "non_input_changed", nil - } - return true, "strict_incremental_ok", nil -} - -type openAIWSIngressPreviousTurnStrictState struct { - nonInputComparable []byte -} - -func buildOpenAIWSIngressPreviousTurnStrictState(payload []byte) (*openAIWSIngressPreviousTurnStrictState, error) { - if len(payload) == 0 { - return nil, nil - } - nonInputComparable, nonInputErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload) - if nonInputErr != nil { - return nil, nonInputErr - } - return &openAIWSIngressPreviousTurnStrictState{ - nonInputComparable: nonInputComparable, - }, nil -} - -func shouldKeepIngressPreviousResponseIDWithStrictState( - previousState *openAIWSIngressPreviousTurnStrictState, - currentPayload []byte, - lastTurnResponseID string, - hasFunctionCallOutput bool, -) (bool, string, error) { - if hasFunctionCallOutput { - return true, "has_function_call_output", nil - } - currentPreviousResponseID := strings.TrimSpace(openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id")) - if currentPreviousResponseID == "" { - return false, "missing_previous_response_id", nil - } - expectedPreviousResponseID := strings.TrimSpace(lastTurnResponseID) - if expectedPreviousResponseID == "" { - return false, "missing_last_turn_response_id", nil - } - if currentPreviousResponseID != expectedPreviousResponseID { - return false, "previous_response_id_mismatch", nil - } - if previousState == nil { - return false, "missing_previous_turn_payload", nil - } - - currentComparable, currentComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(currentPayload) - if currentComparableErr != nil { - return false, "non_input_compare_error", currentComparableErr - } - if !bytes.Equal(previousState.nonInputComparable, currentComparable) { - return false, "non_input_changed", nil - } - return true, "strict_incremental_ok", nil -} - -func (s *OpenAIGatewayService) forwardOpenAIWSV2( - ctx context.Context, - c *gin.Context, - account *Account, - reqBody map[string]any, - token string, - decision OpenAIWSProtocolDecision, - isCodexCLI bool, - reqStream bool, - originalModel string, - mappedModel string, - startTime time.Time, - attempt int, - lastFailureReason string, -) (*OpenAIForwardResult, error) { - if s == nil || account == nil { - return nil, wrapOpenAIWSFallback("invalid_state", errors.New("service or account is nil")) - } - - wsURL, err := s.buildOpenAIResponsesWSURL(account) - if err != nil { - return nil, wrapOpenAIWSFallback("build_ws_url", err) - } - wsHost := "-" - wsPath := "-" - if parsed, parseErr := url.Parse(wsURL); parseErr == nil && parsed != nil { - if h := strings.TrimSpace(parsed.Host); h != "" { - wsHost = normalizeOpenAIWSLogValue(h) - } - if p := strings.TrimSpace(parsed.Path); p != "" { - wsPath = normalizeOpenAIWSLogValue(p) - } - } - logOpenAIWSModeDebug( - "dial_target account_id=%d account_type=%s ws_host=%s ws_path=%s", - account.ID, - account.Type, - wsHost, - wsPath, - ) - - payload := s.buildOpenAIWSCreatePayload(reqBody, account) - payloadStrategy, removedKeys := applyOpenAIWSRetryPayloadStrategy(payload, attempt) - previousResponseID := openAIWSPayloadString(payload, "previous_response_id") - previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID) - promptCacheKey := openAIWSPayloadString(payload, "prompt_cache_key") - _, hasTools := payload["tools"] - debugEnabled := isOpenAIWSModeDebugEnabled() - payloadBytes := -1 - resolvePayloadBytes := func() int { - if payloadBytes >= 0 { - return payloadBytes - } - payloadBytes = len(payloadAsJSONBytes(payload)) - return payloadBytes - } - streamValue := "-" - if raw, ok := payload["stream"]; ok { - streamValue = normalizeOpenAIWSLogValue(strings.TrimSpace(fmt.Sprintf("%v", raw))) - } - turnState := "" - turnMetadata := "" - if c != nil && c.Request != nil { - turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) - turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)) - } - setOpenAIWSTurnMetadata(payload, turnMetadata) - payloadEventType := openAIWSPayloadString(payload, "type") - if payloadEventType == "" { - payloadEventType = "response.create" - } - if s.shouldEmitOpenAIWSPayloadSchema(attempt) { - logOpenAIWSModeInfo( - "[debug] payload_schema account_id=%d attempt=%d event=%s payload_keys=%s payload_bytes=%d payload_key_sizes=%s input_summary=%s stream=%s payload_strategy=%s removed_keys=%s has_previous_response_id=%v has_prompt_cache_key=%v has_tools=%v", - account.ID, - attempt, - payloadEventType, - normalizeOpenAIWSLogValue(strings.Join(sortedKeys(payload), ",")), - resolvePayloadBytes(), - normalizeOpenAIWSLogValue(summarizeOpenAIWSPayloadKeySizes(payload, openAIWSPayloadKeySizeTopN)), - normalizeOpenAIWSLogValue(summarizeOpenAIWSInput(payload["input"])), - streamValue, - normalizeOpenAIWSLogValue(payloadStrategy), - normalizeOpenAIWSLogValue(strings.Join(removedKeys, ",")), - previousResponseID != "", - promptCacheKey != "", - hasTools, - ) - } - - stateStore := s.getOpenAIWSStateStore() - groupID := getOpenAIGroupIDFromContext(c) - sessionHash := s.GenerateSessionHash(c, nil) - if sessionHash == "" { - var legacySessionHash string - sessionHash, legacySessionHash = openAIWSSessionHashesFromID(promptCacheKey) - attachOpenAILegacySessionHashToGin(c, legacySessionHash) - } - if turnState == "" && stateStore != nil && sessionHash != "" { - if savedTurnState, ok := stateStore.GetSessionTurnState(groupID, sessionHash); ok { - turnState = savedTurnState - } - } - preferredConnID := "" - if stateStore != nil && previousResponseID != "" { - if connID, ok := stateStore.GetResponseConn(previousResponseID); ok { - preferredConnID = connID - } - } - storeDisabled := s.isOpenAIWSStoreDisabledInRequest(reqBody, account) - if stateStore != nil && storeDisabled && previousResponseID == "" && sessionHash != "" { - if connID, ok := stateStore.GetSessionConn(groupID, sessionHash); ok { - preferredConnID = connID - } - } - storeDisabledConnMode := s.openAIWSStoreDisabledConnMode() - forceNewConnByPolicy := shouldForceNewConnOnStoreDisabled(storeDisabledConnMode, lastFailureReason) - forceNewConn := forceNewConnByPolicy && storeDisabled && previousResponseID == "" && sessionHash != "" && preferredConnID == "" - wsHeaders, sessionResolution, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, decision, isCodexCLI, turnState, turnMetadata, promptCacheKey) - if buildHdrErr != nil { - return nil, fmt.Errorf("build ws headers: %w", buildHdrErr) - } - logOpenAIWSModeDebug( - "acquire_start account_id=%d account_type=%s transport=%s preferred_conn_id=%s has_previous_response_id=%v session_hash=%s has_turn_state=%v turn_state_len=%d has_turn_metadata=%v turn_metadata_len=%d store_disabled=%v store_disabled_conn_mode=%s retry_last_reason=%s force_new_conn=%v header_user_agent=%s header_openai_beta=%s header_originator=%s header_accept_language=%s header_session_id=%s header_conversation_id=%s session_id_source=%s conversation_id_source=%s has_prompt_cache_key=%v has_chatgpt_account_id=%v has_authorization=%v has_session_id=%v has_conversation_id=%v proxy_enabled=%v", - account.ID, - account.Type, - normalizeOpenAIWSLogValue(string(decision.Transport)), - truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen), - previousResponseID != "", - truncateOpenAIWSLogValue(sessionHash, 12), - turnState != "", - len(turnState), - turnMetadata != "", - len(turnMetadata), - storeDisabled, - normalizeOpenAIWSLogValue(storeDisabledConnMode), - truncateOpenAIWSLogValue(lastFailureReason, openAIWSLogValueMaxLen), - forceNewConn, - openAIWSHeaderValueForLog(wsHeaders, "user-agent"), - openAIWSHeaderValueForLog(wsHeaders, "openai-beta"), - openAIWSHeaderValueForLog(wsHeaders, "originator"), - openAIWSHeaderValueForLog(wsHeaders, "accept-language"), - openAIWSHeaderValueForLog(wsHeaders, "session_id"), - openAIWSHeaderValueForLog(wsHeaders, "conversation_id"), - normalizeOpenAIWSLogValue(sessionResolution.SessionSource), - normalizeOpenAIWSLogValue(sessionResolution.ConversationSource), - promptCacheKey != "", - hasOpenAIWSHeader(wsHeaders, "chatgpt-account-id"), - hasOpenAIWSHeader(wsHeaders, "authorization"), - hasOpenAIWSHeader(wsHeaders, "session_id"), - hasOpenAIWSHeader(wsHeaders, "conversation_id"), - account.ProxyID != nil && account.Proxy != nil, - ) - - acquireCtx, acquireCancel := context.WithTimeout(ctx, s.openAIWSAcquireTimeout()) - defer acquireCancel() - - lease, err := s.getOpenAIWSConnPool().Acquire(acquireCtx, openAIWSAcquireRequest{ - Account: account, - WSURL: wsURL, - Headers: wsHeaders, - PreferredConnID: preferredConnID, - ForceNewConn: forceNewConn, - ProxyURL: func() string { - if account.ProxyID != nil && account.Proxy != nil { - return account.Proxy.URL() - } - return "" - }(), - }) - if err != nil { - dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(err) - logOpenAIWSModeInfo( - "acquire_fail account_id=%d account_type=%s transport=%s reason=%s dial_status=%d dial_class=%s dial_close_status=%s dial_close_reason=%s dial_resp_server=%s dial_resp_via=%s dial_resp_cf_ray=%s dial_resp_x_request_id=%s cause=%s preferred_conn_id=%s force_new_conn=%v ws_host=%s ws_path=%s proxy_enabled=%v", - account.ID, - account.Type, - normalizeOpenAIWSLogValue(string(decision.Transport)), - normalizeOpenAIWSLogValue(classifyOpenAIWSAcquireError(err)), - dialStatus, - dialClass, - dialCloseStatus, - truncateOpenAIWSLogValue(dialCloseReason, openAIWSHeaderValueMaxLen), - dialRespServer, - dialRespVia, - dialRespCFRay, - dialRespReqID, - truncateOpenAIWSLogValue(err.Error(), openAIWSLogValueMaxLen), - truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen), - forceNewConn, - wsHost, - wsPath, - account.ProxyID != nil && account.Proxy != nil, - ) - var dialErr *openAIWSDialError - if errors.As(err, &dialErr) && dialErr != nil && dialErr.StatusCode == http.StatusTooManyRequests { - s.persistOpenAIWSRateLimitSignal(ctx, account, dialErr.ResponseHeaders, nil, "rate_limit_exceeded", "rate_limit_error", strings.TrimSpace(err.Error())) - } - return nil, wrapOpenAIWSFallback(classifyOpenAIWSAcquireError(err), err) - } - // cleanExit 标记正常终端事件退出,此时上游不会再发送帧,连接可安全归还复用。 - // 所有异常路径(读写错误、error 事件等)已在各自分支中提前调用 MarkBroken, - // 因此 defer 中只需处理正常退出时不 MarkBroken 即可。 - cleanExit := false - defer func() { - if !cleanExit { - lease.MarkBroken() - } - lease.Release() - }() - connID := strings.TrimSpace(lease.ConnID()) - logOpenAIWSModeDebug( - "connected account_id=%d account_type=%s transport=%s conn_id=%s conn_reused=%v conn_pick_ms=%d queue_wait_ms=%d has_previous_response_id=%v", - account.ID, - account.Type, - normalizeOpenAIWSLogValue(string(decision.Transport)), - connID, - lease.Reused(), - lease.ConnPickDuration().Milliseconds(), - lease.QueueWaitDuration().Milliseconds(), - previousResponseID != "", - ) - if previousResponseID != "" { - logOpenAIWSModeInfo( - "continuation_probe account_id=%d account_type=%s conn_id=%s previous_response_id=%s previous_response_id_kind=%s preferred_conn_id=%s conn_reused=%v store_disabled=%v session_hash=%s header_session_id=%s header_conversation_id=%s session_id_source=%s conversation_id_source=%s has_turn_state=%v turn_state_len=%d has_prompt_cache_key=%v", - account.ID, - account.Type, - truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(previousResponseIDKind), - truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen), - lease.Reused(), - storeDisabled, - truncateOpenAIWSLogValue(sessionHash, 12), - openAIWSHeaderValueForLog(wsHeaders, "session_id"), - openAIWSHeaderValueForLog(wsHeaders, "conversation_id"), - normalizeOpenAIWSLogValue(sessionResolution.SessionSource), - normalizeOpenAIWSLogValue(sessionResolution.ConversationSource), - turnState != "", - len(turnState), - promptCacheKey != "", - ) - } - if c != nil { - SetOpsLatencyMs(c, OpsOpenAIWSConnPickMsKey, lease.ConnPickDuration().Milliseconds()) - SetOpsLatencyMs(c, OpsOpenAIWSQueueWaitMsKey, lease.QueueWaitDuration().Milliseconds()) - c.Set(OpsOpenAIWSConnReusedKey, lease.Reused()) - if connID != "" { - c.Set(OpsOpenAIWSConnIDKey, connID) - } - } - - handshakeTurnState := strings.TrimSpace(lease.HandshakeHeader(openAIWSTurnStateHeader)) - logOpenAIWSModeDebug( - "handshake account_id=%d conn_id=%s has_turn_state=%v turn_state_len=%d", - account.ID, - connID, - handshakeTurnState != "", - len(handshakeTurnState), - ) - if handshakeTurnState != "" { - if stateStore != nil && sessionHash != "" { - stateStore.BindSessionTurnState(groupID, sessionHash, handshakeTurnState, s.openAIWSSessionStickyTTL()) - } - if c != nil { - c.Header(http.CanonicalHeaderKey(openAIWSTurnStateHeader), handshakeTurnState) - } - } - - if err := s.performOpenAIWSGeneratePrewarm( - ctx, - lease, - decision, - payload, - previousResponseID, - reqBody, - account, - stateStore, - groupID, - ); err != nil { - return nil, err - } - - if err := lease.WriteJSONWithContextTimeout(ctx, payload, s.openAIWSWriteTimeout()); err != nil { - lease.MarkBroken() - logOpenAIWSModeInfo( - "write_request_fail account_id=%d conn_id=%s cause=%s payload_bytes=%d", - account.ID, - connID, - truncateOpenAIWSLogValue(err.Error(), openAIWSLogValueMaxLen), - resolvePayloadBytes(), - ) - return nil, wrapOpenAIWSFallback("write_request", err) - } - if debugEnabled { - logOpenAIWSModeDebug( - "write_request_sent account_id=%d conn_id=%s stream=%v payload_bytes=%d previous_response_id=%s", - account.ID, - connID, - reqStream, - resolvePayloadBytes(), - truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen), - ) - } - - usage := &OpenAIUsage{} - imageCounter := newOpenAIImageOutputCounter() - var firstTokenMs *int - responseID := "" - var finalResponse []byte - wroteDownstream := false - needModelReplace := originalModel != mappedModel - var mappedModelBytes []byte - if needModelReplace && mappedModel != "" { - mappedModelBytes = []byte(mappedModel) - } - bufferedStreamEvents := make([][]byte, 0, 4) - eventCount := 0 - tokenEventCount := 0 - terminalEventCount := 0 - bufferedEventCount := 0 - flushedBufferedEventCount := 0 - firstEventType := "" - lastEventType := "" - - var flusher http.Flusher - if reqStream { - if s.responseHeaderFilter != nil { - responseheaders.WriteFilteredHeaders(c.Writer.Header(), http.Header{}, s.responseHeaderFilter) - } - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") - f, ok := c.Writer.(http.Flusher) - if !ok { - lease.MarkBroken() - return nil, wrapOpenAIWSFallback("streaming_not_supported", errors.New("streaming not supported")) - } - flusher = f - } - - clientDisconnected := false - flushBatchSize := s.openAIWSEventFlushBatchSize() - flushInterval := s.openAIWSEventFlushInterval() - pendingFlushEvents := 0 - lastFlushAt := time.Now() - flushStreamWriter := func(force bool) { - if clientDisconnected || flusher == nil || pendingFlushEvents <= 0 { - return - } - if !force && flushBatchSize > 1 && pendingFlushEvents < flushBatchSize { - if flushInterval <= 0 || time.Since(lastFlushAt) < flushInterval { - return - } - } - flusher.Flush() - pendingFlushEvents = 0 - lastFlushAt = time.Now() - } - emitStreamMessage := func(message []byte, forceFlush bool) { - if clientDisconnected { - return - } - frame := make([]byte, 0, len(message)+8) - frame = append(frame, "data: "...) - frame = append(frame, message...) - frame = append(frame, '\n', '\n') - _, wErr := c.Writer.Write(frame) - if wErr == nil { - wroteDownstream = true - pendingFlushEvents++ - flushStreamWriter(forceFlush) - return - } - clientDisconnected = true - logger.LegacyPrintf("service.openai_gateway", "[OpenAI WS Mode] client disconnected, continue draining upstream: account=%d", account.ID) - } - flushBufferedStreamEvents := func(reason string) { - if len(bufferedStreamEvents) == 0 { - return - } - flushed := len(bufferedStreamEvents) - for _, buffered := range bufferedStreamEvents { - emitStreamMessage(buffered, false) - } - bufferedStreamEvents = bufferedStreamEvents[:0] - flushStreamWriter(true) - flushedBufferedEventCount += flushed - if debugEnabled { - logOpenAIWSModeDebug( - "buffer_flush account_id=%d conn_id=%s reason=%s flushed=%d total_flushed=%d client_disconnected=%v", - account.ID, - connID, - truncateOpenAIWSLogValue(reason, openAIWSLogValueMaxLen), - flushed, - flushedBufferedEventCount, - clientDisconnected, - ) - } - } - - readTimeout := s.openAIWSReadTimeout() - - for { - message, readErr := lease.ReadMessageWithContextTimeout(ctx, readTimeout) - if readErr != nil { - lease.MarkBroken() - closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr) - logOpenAIWSModeInfo( - "read_fail account_id=%d conn_id=%s wrote_downstream=%v close_status=%s close_reason=%s cause=%s events=%d token_events=%d terminal_events=%d buffered_pending=%d buffered_flushed=%d first_event=%s last_event=%s", - account.ID, - connID, - wroteDownstream, - closeStatus, - closeReason, - truncateOpenAIWSLogValue(readErr.Error(), openAIWSLogValueMaxLen), - eventCount, - tokenEventCount, - terminalEventCount, - len(bufferedStreamEvents), - flushedBufferedEventCount, - truncateOpenAIWSLogValue(firstEventType, openAIWSLogValueMaxLen), - truncateOpenAIWSLogValue(lastEventType, openAIWSLogValueMaxLen), - ) - if !wroteDownstream { - return nil, wrapOpenAIWSFallback(classifyOpenAIWSReadFallbackReason(readErr), readErr) - } - if clientDisconnected { - break - } - setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(readErr.Error()), "") - return nil, fmt.Errorf("openai ws read event: %w", readErr) - } - - eventType, eventResponseID, responseField := parseOpenAIWSEventEnvelope(message) - if eventType == "" { - continue - } - eventCount++ - if firstEventType == "" { - firstEventType = eventType - } - lastEventType = eventType - - if responseID == "" && eventResponseID != "" { - responseID = eventResponseID - } - - isTokenEvent := isOpenAIWSTokenEvent(eventType) - if isTokenEvent { - tokenEventCount++ - } - isTerminalEvent := isOpenAIWSTerminalEvent(eventType) - if isTerminalEvent { - terminalEventCount++ - } - if firstTokenMs == nil && isTokenEvent { - ms := int(time.Since(startTime).Milliseconds()) - firstTokenMs = &ms - } - if debugEnabled && shouldLogOpenAIWSEvent(eventCount, eventType) { - logOpenAIWSModeDebug( - "event_received account_id=%d conn_id=%s idx=%d type=%s bytes=%d token=%v terminal=%v buffered_pending=%d", - account.ID, - connID, - eventCount, - truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen), - len(message), - isTokenEvent, - isTerminalEvent, - len(bufferedStreamEvents), - ) - } - - if !clientDisconnected { - if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && bytes.Contains(message, mappedModelBytes) { - message = replaceOpenAIWSMessageModel(message, mappedModel, originalModel) - } - if openAIWSEventMayContainToolCalls(eventType) && openAIWSMessageLikelyContainsToolCalls(message) { - if corrected, changed := s.toolCorrector.CorrectToolCallsInSSEBytes(message); changed { - message = corrected - } - } - } - if openAIWSEventShouldParseUsage(eventType) { - parseOpenAIWSResponseUsageFromCompletedEvent(message, usage) - } - imageCounter.AddSSEData(message) - - if eventType == "response.failed" { - if hit, code, msg := detectOpenAICyberPolicy(message); hit { - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: msg, - Body: truncateString(string(message), 4096), - UpstreamStatus: http.StatusOK, - UpstreamInTok: usage.InputTokens, - UpstreamOutTok: usage.OutputTokens, - }) - } - } - - if eventType == "error" { - errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(message) - s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), message, errCodeRaw, errTypeRaw, errMsgRaw) - errMsg := strings.TrimSpace(errMsgRaw) - if errMsg == "" { - errMsg = "Upstream websocket error" - } - fallbackReason, canFallback := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) - errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) - logOpenAIWSModeInfo( - "error_event account_id=%d conn_id=%s idx=%d fallback_reason=%s can_fallback=%v err_code=%s err_type=%s err_message=%s", - account.ID, - connID, - eventCount, - truncateOpenAIWSLogValue(fallbackReason, openAIWSLogValueMaxLen), - canFallback, - errCode, - errType, - errMessage, - ) - if fallbackReason == "previous_response_not_found" { - logOpenAIWSModeInfo( - "previous_response_not_found_diag account_id=%d account_type=%s conn_id=%s previous_response_id=%s previous_response_id_kind=%s response_id=%s event_idx=%d req_stream=%v store_disabled=%v conn_reused=%v session_hash=%s header_session_id=%s header_conversation_id=%s session_id_source=%s conversation_id_source=%s has_turn_state=%v turn_state_len=%d has_prompt_cache_key=%v err_code=%s err_type=%s err_message=%s", - account.ID, - account.Type, - connID, - truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(previousResponseIDKind), - truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen), - eventCount, - reqStream, - storeDisabled, - lease.Reused(), - truncateOpenAIWSLogValue(sessionHash, 12), - openAIWSHeaderValueForLog(wsHeaders, "session_id"), - openAIWSHeaderValueForLog(wsHeaders, "conversation_id"), - normalizeOpenAIWSLogValue(sessionResolution.SessionSource), - normalizeOpenAIWSLogValue(sessionResolution.ConversationSource), - turnState != "", - len(turnState), - promptCacheKey != "", - errCode, - errType, - errMessage, - ) - } - // error 事件后连接不再可复用,避免回池后污染下一请求。 - lease.MarkBroken() - if !wroteDownstream && canFallback { - return nil, wrapOpenAIWSFallback(fallbackReason, errors.New(errMsg)) - } - statusCode := openAIWSErrorHTTPStatusFromRaw(errCodeRaw, errTypeRaw) - setOpsUpstreamError(c, statusCode, errMsg, "") - if reqStream && !clientDisconnected { - flushBufferedStreamEvents("error_event") - emitStreamMessage(message, true) - } - if !reqStream { - c.JSON(statusCode, gin.H{ - "error": gin.H{ - "type": "upstream_error", - "message": errMsg, - }, - }) - } - return nil, fmt.Errorf("openai ws error event: %s", errMsg) - } - - if reqStream { - // 在首个 token 前先缓冲事件(如 response.created), - // 以便上游早期断连时仍可安全回退到 HTTP,不给下游发送半截流。 - shouldBuffer := firstTokenMs == nil && !isTokenEvent && !isTerminalEvent - if shouldBuffer { - buffered := make([]byte, len(message)) - copy(buffered, message) - bufferedStreamEvents = append(bufferedStreamEvents, buffered) - bufferedEventCount++ - if debugEnabled && shouldLogOpenAIWSBufferedEvent(bufferedEventCount) { - logOpenAIWSModeDebug( - "buffer_enqueue account_id=%d conn_id=%s idx=%d event_idx=%d event_type=%s buffer_size=%d", - account.ID, - connID, - bufferedEventCount, - eventCount, - truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen), - len(bufferedStreamEvents), - ) - } - } else { - flushBufferedStreamEvents(eventType) - emitStreamMessage(message, isTerminalEvent) - } - } else { - if responseField.Exists() && responseField.Type == gjson.JSON { - finalResponse = []byte(responseField.Raw) - } - } - - if isTerminalEvent { - cleanExit = true - break - } - } - - if !reqStream { - if len(finalResponse) == 0 { - logOpenAIWSModeInfo( - "missing_final_response account_id=%d conn_id=%s events=%d token_events=%d terminal_events=%d wrote_downstream=%v", - account.ID, - connID, - eventCount, - tokenEventCount, - terminalEventCount, - wroteDownstream, - ) - if !wroteDownstream { - return nil, wrapOpenAIWSFallback("missing_final_response", errors.New("no terminal response payload")) - } - return nil, errors.New("ws finished without final response") - } - - if needModelReplace { - finalResponse = s.replaceModelInResponseBody(finalResponse, mappedModel, originalModel) - } - finalResponse = s.correctToolCallsInResponseBody(finalResponse) - populateOpenAIUsageFromResponseJSON(finalResponse, usage) - if responseID == "" { - responseID = strings.TrimSpace(gjson.GetBytes(finalResponse, "id").String()) - } - - c.Data(http.StatusOK, "application/json", finalResponse) - } else { - flushStreamWriter(true) - } - - if responseID != "" && stateStore != nil { - ttl := s.openAIWSResponseStickyTTL() - logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, stateStore.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) - stateStore.BindResponseConn(responseID, lease.ConnID(), ttl) - } - if stateStore != nil && storeDisabled && sessionHash != "" { - stateStore.BindSessionConn(groupID, sessionHash, lease.ConnID(), s.openAIWSSessionStickyTTL()) - } - firstTokenMsValue := -1 - if firstTokenMs != nil { - firstTokenMsValue = *firstTokenMs - } - logOpenAIWSModeDebug( - "completed account_id=%d conn_id=%s response_id=%s stream=%v duration_ms=%d events=%d token_events=%d terminal_events=%d buffered_events=%d buffered_flushed=%d first_event=%s last_event=%s first_token_ms=%d wrote_downstream=%v client_disconnected=%v", - account.ID, - connID, - truncateOpenAIWSLogValue(strings.TrimSpace(responseID), openAIWSIDValueMaxLen), - reqStream, - time.Since(startTime).Milliseconds(), - eventCount, - tokenEventCount, - terminalEventCount, - bufferedEventCount, - flushedBufferedEventCount, - truncateOpenAIWSLogValue(firstEventType, openAIWSLogValueMaxLen), - truncateOpenAIWSLogValue(lastEventType, openAIWSLogValueMaxLen), - firstTokenMsValue, - wroteDownstream, - clientDisconnected, - ) - - return &OpenAIForwardResult{ - RequestID: responseID, - Usage: *usage, - Model: originalModel, - UpstreamModel: mappedModel, - ImageCount: imageCounter.Count(), - ImageOutputSizes: imageCounter.Sizes(), - ServiceTier: extractOpenAIServiceTier(reqBody), - ReasoningEffort: extractOpenAIReasoningEffort(reqBody, originalModel), - Stream: reqStream, - OpenAIWSMode: true, - ResponseHeaders: lease.HandshakeHeaders(), - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - }, nil -} - -// ProxyResponsesWebSocketFromClient 处理客户端入站 WebSocket(OpenAI Responses WS Mode)并转发到上游。 -// 当前实现按“单请求 -> 终止事件 -> 下一请求”的顺序代理,适配 Codex CLI 的 turn 模式。 -// stripCodexSparkImageGenerationToolFromRawPayload removes the image_generation -// tool from a raw /responses payload when the upstream model is gpt-5.3-codex-spark. -// Spark rejects that tool upstream with HTTP 400 (invalid_request_error, param=tools); -// Codex clients advertise it by default. Returns the (possibly unchanged) payload, -// whether it changed, and any JSON decode error. -func stripCodexSparkImageGenerationToolFromRawPayload(payload []byte, model string) ([]byte, bool, error) { - if !isCodexSparkModel(model) || !openAIRequestBodyHasImageGenerationTool(payload) { - return payload, false, nil - } - return stripOpenAIImageGenerationToolFromRawPayload(payload) -} - -func stripOpenAIImageGenerationToolFromRawPayload(payload []byte) ([]byte, bool, error) { - payloadMap := make(map[string]any) - if err := json.Unmarshal(payload, &payloadMap); err != nil { - return payload, false, err - } - if !stripOpenAIImageGenerationTools(payloadMap) { - return payload, false, nil - } - rebuilt, err := json.Marshal(payloadMap) - if err != nil { - return payload, false, err - } - return rebuilt, true, nil -} - -func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( - ctx context.Context, - c *gin.Context, - clientConn *coderws.Conn, - account *Account, - token string, - firstClientMessage []byte, - hooks *OpenAIWSIngressHooks, -) error { - if s == nil { - return errors.New("service is nil") - } - if c == nil { - return errors.New("gin context is nil") - } - if clientConn == nil { - return errors.New("client websocket is nil") - } - if account == nil { - return errors.New("account is nil") - } - if strings.TrimSpace(token) == "" { - return errors.New("token is empty") - } - - // 预取一次 OpenAI Fast Policy settings,绑定到 ctx,让该 WS session - // 内所有帧的 evaluateOpenAIFastPolicy 调用复用同一份快照,避免每帧 - // 进入 DB / settingRepo。Trade-off 见 withOpenAIFastPolicyContext 注释。 - if s.settingService != nil { - if settings, err := s.settingService.GetOpenAIFastPolicySettings(ctx); err == nil && settings != nil { - ctx = withOpenAIFastPolicyContext(ctx, settings) - } - } - - wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) - forceHTTPBridge := account.Platform == PlatformGrok - modeRouterV2Enabled := s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled - ingressMode := OpenAIWSIngressModeCtxPool - if modeRouterV2Enabled && !forceHTTPBridge { - ingressMode = account.ResolveOpenAIResponsesWebSocketV2Mode(s.cfg.Gateway.OpenAIWS.IngressModeDefault) - if ingressMode == OpenAIWSIngressModeOff { - return NewOpenAIWSClientCloseError( - coderws.StatusPolicyViolation, - "websocket mode is disabled for this account", - nil, - ) - } - switch ingressMode { - case OpenAIWSIngressModePassthrough: - if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { - return fmt.Errorf("websocket ingress requires ws_v2 transport, got=%s", wsDecision.Transport) - } - return s.proxyResponsesWebSocketV2Passthrough( - ctx, - c, - clientConn, - account, - token, - firstClientMessage, - hooks, - wsDecision, - ) - case OpenAIWSIngressModeHTTPBridge: - forceHTTPBridge = true - case OpenAIWSIngressModeCtxPool, OpenAIWSIngressModeShared, OpenAIWSIngressModeDedicated: - // continue - default: - return NewOpenAIWSClientCloseError( - coderws.StatusPolicyViolation, - "websocket mode only supports ctx_pool/passthrough/http_bridge", - nil, - ) - } - } - if !forceHTTPBridge && wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { - return fmt.Errorf("websocket ingress requires ws_v2 transport, got=%s", wsDecision.Transport) - } - dedicatedMode := modeRouterV2Enabled && ingressMode == OpenAIWSIngressModeDedicated - - wsURL := "" - wsHost := "-" - wsPath := "-" - if forceHTTPBridge { - wsHost = "xai-http-bridge" - wsPath = "/v1/responses" - } else { - var err error - wsURL, err = s.buildOpenAIResponsesWSURL(account) - if err != nil { - return fmt.Errorf("build ws url: %w", err) - } - if parsedURL, parseErr := url.Parse(wsURL); parseErr == nil && parsedURL != nil { - wsHost = normalizeOpenAIWSLogValue(parsedURL.Host) - wsPath = normalizeOpenAIWSLogValue(parsedURL.Path) - } - } - debugEnabled := isOpenAIWSModeDebugEnabled() - isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI) - - type openAIWSClientPayload struct { - payloadRaw []byte - rawForHash []byte - promptCacheKey string - previousResponseID string - originalModel string - imageBillingModel string - imageSizeTier string - imageInputSize string - payloadBytes int - } - ingressSessionOriginalModel := "" - - applyPayloadMutation := func(current []byte, path string, value any) ([]byte, error) { - next, err := sjson.SetBytes(current, path, value) - if err == nil { - return next, nil - } - - // 仅在确实需要修改 payload 且 sjson 失败时,退回 map 路径确保兼容性。 - payload := make(map[string]any) - if unmarshalErr := json.Unmarshal(current, &payload); unmarshalErr != nil { - return nil, err - } - switch path { - case "type", "model": - payload[path] = value - case "client_metadata." + openAIWSTurnMetadataHeader: - setOpenAIWSTurnMetadata(payload, fmt.Sprintf("%v", value)) - default: - return nil, err - } - rebuilt, marshalErr := json.Marshal(payload) - if marshalErr != nil { - return nil, marshalErr - } - return rebuilt, nil - } - - parseClientPayload := func(raw []byte) (openAIWSClientPayload, error) { - trimmed := bytes.TrimSpace(raw) - if len(trimmed) == 0 { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "empty websocket request payload", nil) - } - if !gjson.ValidBytes(trimmed) { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", errors.New("invalid json")) - } - - values := gjson.GetManyBytes(trimmed, "type", "model", "prompt_cache_key", "previous_response_id") - eventType := strings.TrimSpace(values[0].String()) - normalized := trimmed - switch eventType { - case "": - eventType = "response.create" - next, setErr := applyPayloadMutation(normalized, "type", eventType) - if setErr != nil { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", setErr) - } - normalized = next - case "response.create": - case "response.append": - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( - coderws.StatusPolicyViolation, - "response.append is not supported in ws v2; use response.create with previous_response_id", - nil, - ) - default: - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( - coderws.StatusPolicyViolation, - fmt.Sprintf("unsupported websocket request type: %s", eventType), - nil, - ) - } - - originalModel := strings.TrimSpace(values[1].String()) - modelMissing := originalModel == "" - if originalModel == "" { - // 入站 WS 长会话里,部分客户端只在第一轮 response.create 上声明 - // model,后续 turn 复用同一 session-level model。为避免因省略 - // model 直接断开用户连接,这里回落到上一轮已通过校验的客户端模型, - // 并在下方写回上游 payload,保证账号模型映射/fast policy/图片权限 - // 仍按同一模型执行。 - originalModel = ingressSessionOriginalModel - if originalModel == "" { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( - coderws.StatusPolicyViolation, - "model is required in response.create payload", - nil, - ) - } - } - promptCacheKey := strings.TrimSpace(values[2].String()) - previousResponseID := strings.TrimSpace(values[3].String()) - previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID) - if previousResponseID != "" && previousResponseIDKind == OpenAIPreviousResponseIDKindMessageID { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( - coderws.StatusPolicyViolation, - "previous_response_id must be a response.id (resp_*), not a message id", - nil, - ) - } - if turnMetadata := strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)); turnMetadata != "" { - next, setErr := applyPayloadMutation(normalized, "client_metadata."+openAIWSTurnMetadataHeader, turnMetadata) - if setErr != nil { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", setErr) - } - normalized = next - } - apiKey := getAPIKeyFromContext(c) - imageGenerationAllowed := GroupAllowsImageGeneration(apiKeyGroup(apiKey)) - codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow - if isCodexCLI { - codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy() - } - codexBridgeEnabled := isCodexCLI && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) - if codexBridgeEnabled { - payloadMap := make(map[string]any) - if err := json.Unmarshal(normalized, &payloadMap); err != nil { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", err) - } - bridgeModified := false - if ensureOpenAIResponsesImageGenerationTool(payloadMap) { - bridgeModified = true - logOpenAIWSModeInfo("ingress_ws_codex_image_tool_injected account_id=%d", account.ID) - } - if ensureOpenAIResponsesImageGenerationToolChoiceAuto(payloadMap) { - bridgeModified = true - logOpenAIWSModeInfo("ingress_ws_codex_image_tool_choice_auto account_id=%d", account.ID) - } - if normalizeOpenAIResponsesImageGenerationTools(payloadMap) { - bridgeModified = true - } - if applyCodexImageGenerationBridgeInstructions(payloadMap) { - bridgeModified = true - logOpenAIWSModeInfo("ingress_ws_codex_image_bridge_instructions_added account_id=%d", account.ID) - } - if bridgeModified { - rebuilt, marshalErr := json.Marshal(payloadMap) - if marshalErr != nil { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", marshalErr) - } - normalized = rebuilt - } - } - upstreamModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) - if modelMissing || upstreamModel != originalModel { - next, setErr := applyPayloadMutation(normalized, "model", upstreamModel) - if setErr != nil { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", setErr) - } - normalized = next - } - if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { - if stripped, changed, stripErr := stripOpenAIImageGenerationToolFromRawPayload(normalized); stripErr != nil { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", stripErr) - } else if changed { - normalized = stripped - logOpenAIWSModeInfo("ingress_ws_codex_image_tool_stripped_by_policy account_id=%d", account.ID) - } - } - if stripped, changed, stripErr := stripCodexSparkImageGenerationToolFromRawPayload(normalized, upstreamModel); stripErr != nil { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", stripErr) - } else if changed { - normalized = stripped - logOpenAIWSModeInfo("ingress_ws_codex_spark_image_tool_stripped account_id=%d", account.ID) - } - imageIntent := IsImageGenerationIntent(openAIResponsesEndpoint, originalModel, normalized) - if imageIntent && !imageGenerationAllowed { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, ImageGenerationPermissionMessage(), nil) - } - imageBillingModel := "" - imageSizeTier := "" - imageInputSize := "" - if imageIntent { - var imageCfgErr error - imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(normalized, originalModel) - if imageCfgErr != nil { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, imageCfgErr.Error(), imageCfgErr) - } - imageBillingModel = imageCfg.Model - imageSizeTier = imageCfg.SizeTier - imageInputSize = imageCfg.InputSize - } - - // Apply OpenAI Fast Policy on the response.create frame using the same - // evaluator/normalize/scope rules as the HTTP entrypoints. This is the - // single integration point for all WS ingress turns (first + follow-up - // frames flow through here). - // - // Model fallback: first turn still requires model at the handler layer; - // follow-up response.create frames may omit it and then reuse - // ingressSessionOriginalModel. We always write a concrete upstream model - // before evaluating policy, so whitelist / filter behavior remains stable. - policyApplied, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, upstreamModel, normalized) - if policyErr != nil { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", policyErr) - } - if blocked != nil { - MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) - // Send a Realtime-style error event to the client first, then - // signal the handler to close the connection with PolicyViolation. - // We intentionally do NOT forward this frame upstream. - // - // coder/websocket@v1.8.14 Conn.Write is synchronous and flushes - // the underlying bufio writer before returning (write.go:42 → - // 307-311), and the subsequent close handshake re-acquires the - // same writeFrameMu, so the error event is guaranteed to reach - // the kernel send buffer before any close frame is queued. - eventBytes := buildOpenAIFastPolicyBlockedWSEvent(blocked) - if eventBytes != nil { - writeCtx, cancel := context.WithTimeout(ctx, s.openAIWSWriteTimeout()) - _ = clientConn.Write(writeCtx, coderws.MessageText, eventBytes) - cancel() - } - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( - coderws.StatusPolicyViolation, - blocked.Message, - blocked, - ) - } - normalized = policyApplied - ingressSessionOriginalModel = originalModel - - return openAIWSClientPayload{ - payloadRaw: normalized, - rawForHash: trimmed, - promptCacheKey: promptCacheKey, - previousResponseID: previousResponseID, - originalModel: originalModel, - imageBillingModel: imageBillingModel, - imageSizeTier: imageSizeTier, - imageInputSize: imageInputSize, - payloadBytes: len(normalized), - }, nil - } - - writeClientMessage := func(message []byte) error { - writeCtx, cancel := context.WithTimeout(ctx, s.openAIWSWriteTimeout()) - defer cancel() - return clientConn.Write(writeCtx, coderws.MessageText, message) - } - - readClientMessage := func() ([]byte, error) { - msgType, payload, readErr := clientConn.Read(ctx) - if readErr != nil { - return nil, readErr - } - if msgType != coderws.MessageText && msgType != coderws.MessageBinary { - return nil, NewOpenAIWSClientCloseError( - coderws.StatusPolicyViolation, - fmt.Sprintf("unsupported websocket client message type: %s", msgType.String()), - nil, - ) - } - return payload, nil - } - - firstPayload, err := parseClientPayload(firstClientMessage) - if err != nil { - return err - } - - turnState := strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) - stateStore := s.getOpenAIWSStateStore() - groupID := getOpenAIGroupIDFromContext(c) - storeDisabledConnMode := s.openAIWSStoreDisabledConnMode() - sessionHash := "" - preferredConnID := "" - storeDisabled := false - refreshIngressRouteState := func(payload openAIWSClientPayload) { - sessionHash = s.GenerateSessionHash(c, payload.rawForHash) - if turnState == "" && stateStore != nil && sessionHash != "" { - if savedTurnState, ok := stateStore.GetSessionTurnState(groupID, sessionHash); ok { - turnState = savedTurnState - } - } - - preferredConnID = "" - if stateStore != nil && payload.previousResponseID != "" { - if connID, ok := stateStore.GetResponseConn(payload.previousResponseID); ok { - preferredConnID = connID - } - } - - storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(payload.payloadRaw, account) - if stateStore != nil && storeDisabled && payload.previousResponseID == "" && sessionHash != "" { - if connID, ok := stateStore.GetSessionConn(groupID, sessionHash); ok { - preferredConnID = connID - } - } - } - refreshIngressRouteState(firstPayload) - - if forceHTTPBridge || s.shouldBridgeOpenAIWSHTTP(account, firstPayload.payloadBytes, firstPayload.previousResponseID) { - logOpenAIWSModeInfo( - "ingress_ws_http_bridge_start account_id=%d account_type=%s payload_bytes=%d threshold_bytes=%d has_session_hash=%v store_disabled=%v", - account.ID, - account.Type, - firstPayload.payloadBytes, - s.openAIWSHTTPBridgeThresholdBytes(), - sessionHash != "", - storeDisabled, - ) - currentBridgePayload := firstPayload - var bridgeReplayInput []json.RawMessage - bridgeReplayInputExists := false - for turn := 1; ; turn++ { - if turn > 1 && hooks != nil && hooks.BeforeRequest != nil { - if err := hooks.BeforeRequest(turn, currentBridgePayload.payloadRaw, currentBridgePayload.originalModel); err != nil { - return err - } - } - if hooks != nil && hooks.BeforeTurn != nil { - if err := hooks.BeforeTurn(turn); err != nil { - return err - } - } - if turnState != "" && c != nil && c.Request != nil { - c.Request.Header.Set(openAIWSTurnStateHeader, turnState) - } - bridgePayloadRaw := currentBridgePayload.payloadRaw - bridgePayloadBytes := currentBridgePayload.payloadBytes - needsBridgeReplay := currentBridgePayload.previousResponseID != "" || openAIWSRawPayloadHasToolCallOutput(currentBridgePayload.payloadRaw) - turnReplayInput, turnReplayInputExists, replayInputErr := buildOpenAIWSReplayInputSequence( - bridgeReplayInput, - bridgeReplayInputExists, - currentBridgePayload.payloadRaw, - needsBridgeReplay, - ) - if replayInputErr != nil { - return fmt.Errorf("build websocket http bridge replay input: %w", replayInputErr) - } - if needsBridgeReplay && turnReplayInputExists { - updatedPayload, setInputErr := setOpenAIWSPayloadInputSequence( - currentBridgePayload.payloadRaw, - turnReplayInput, - true, - ) - if setInputErr != nil { - return fmt.Errorf("set websocket http bridge replay input: %w", setInputErr) - } - bridgePayloadRaw = updatedPayload - bridgePayloadBytes = len(updatedPayload) - logOpenAIWSModeInfo( - "ingress_ws_http_bridge_replay_input account_id=%d turn=%d input_items=%d previous_response_id_present=%v has_tool_output=%v", - account.ID, - turn, - len(turnReplayInput), - currentBridgePayload.previousResponseID != "", - openAIWSRawPayloadHasToolCallOutput(currentBridgePayload.payloadRaw), - ) - } - result, bridgeErr := s.proxyOpenAIWSHTTPBridgeTurn( - ctx, - c, - account, - token, - bridgePayloadRaw, - bridgePayloadBytes, - currentBridgePayload.originalModel, - currentBridgePayload.imageBillingModel, - currentBridgePayload.imageSizeTier, - currentBridgePayload.imageInputSize, - turn, - writeClientMessage, - ) - if hooks != nil && hooks.AfterTurn != nil { - hooks.AfterTurn(turn, result, bridgeErr) - } - if bridgeErr != nil { - return bridgeErr - } - if result == nil { - return errors.New("websocket http bridge turn result is nil") - } - bridgeReplayInput = cloneOpenAIWSRawMessages(turnReplayInput) - bridgeReplayInputExists = turnReplayInputExists - if result.wsReplayInputExists { - bridgeReplayInput = append(bridgeReplayInput, cloneOpenAIWSRawMessages(result.wsReplayInput)...) - bridgeReplayInputExists = true - } - if bridgeTurnState := strings.TrimSpace(result.ResponseHeaders.Get(openAIWSTurnStateHeader)); bridgeTurnState != "" { - turnState = bridgeTurnState - if stateStore != nil && sessionHash != "" { - stateStore.BindSessionTurnState(groupID, sessionHash, bridgeTurnState, s.openAIWSSessionStickyTTL()) - } - } - responseID := strings.TrimSpace(result.RequestID) - if responseID != "" && stateStore != nil { - ttl := s.openAIWSResponseStickyTTL() - logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, stateStore.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) - } - nextClientMessage, readErr := readClientMessage() - if readErr != nil { - if isOpenAIWSClientDisconnectError(readErr) { - closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr) - logOpenAIWSModeInfo( - "ingress_ws_http_bridge_client_closed account_id=%d close_status=%s close_reason=%s", - account.ID, - closeStatus, - truncateOpenAIWSLogValue(closeReason, openAIWSHeaderValueMaxLen), - ) - return nil - } - return fmt.Errorf("read client websocket request: %w", readErr) - } - nextPayload, parseErr := parseClientPayload(nextClientMessage) - if parseErr != nil { - return parseErr - } - currentBridgePayload = nextPayload - } - } - - wsHeaders, _, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), firstPayload.promptCacheKey) - if buildHdrErr != nil { - return fmt.Errorf("build ws headers: %w", buildHdrErr) - } - baseAcquireReq := openAIWSAcquireRequest{ - Account: account, - WSURL: wsURL, - Headers: wsHeaders, - ProxyURL: func() string { - if account.ProxyID != nil && account.Proxy != nil { - return account.Proxy.URL() - } - return "" - }(), - ForceNewConn: false, - } - pool := s.getOpenAIWSConnPool() - if pool == nil { - return errors.New("openai ws conn pool is nil") - } - - logOpenAIWSModeInfo( - "ingress_ws_protocol_confirm account_id=%d account_type=%s transport=%s ws_host=%s ws_path=%s ws_mode=%s store_disabled=%v has_session_hash=%v has_previous_response_id=%v", - account.ID, - account.Type, - normalizeOpenAIWSLogValue(string(wsDecision.Transport)), - wsHost, - wsPath, - normalizeOpenAIWSLogValue(ingressMode), - storeDisabled, - sessionHash != "", - firstPayload.previousResponseID != "", - ) - - if debugEnabled { - logOpenAIWSModeDebug( - "ingress_ws_start account_id=%d account_type=%s transport=%s ws_host=%s preferred_conn_id=%s has_session_hash=%v has_previous_response_id=%v store_disabled=%v", - account.ID, - account.Type, - normalizeOpenAIWSLogValue(string(wsDecision.Transport)), - wsHost, - truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen), - sessionHash != "", - firstPayload.previousResponseID != "", - storeDisabled, - ) - } - if firstPayload.previousResponseID != "" { - firstPreviousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(firstPayload.previousResponseID) - logOpenAIWSModeInfo( - "ingress_ws_continuation_probe account_id=%d turn=%d previous_response_id=%s previous_response_id_kind=%s preferred_conn_id=%s session_hash=%s header_session_id=%s header_conversation_id=%s has_turn_state=%v turn_state_len=%d has_prompt_cache_key=%v store_disabled=%v", - account.ID, - 1, - truncateOpenAIWSLogValue(firstPayload.previousResponseID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(firstPreviousResponseIDKind), - truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(sessionHash, 12), - openAIWSHeaderValueForLog(baseAcquireReq.Headers, "session_id"), - openAIWSHeaderValueForLog(baseAcquireReq.Headers, "conversation_id"), - turnState != "", - len(turnState), - firstPayload.promptCacheKey != "", - storeDisabled, - ) - } - - acquireTimeout := s.openAIWSAcquireTimeout() - if acquireTimeout <= 0 { - acquireTimeout = 30 * time.Second - } - - acquireTurnLease := func(turn int, preferred string, forcePreferredConn bool) (*openAIWSConnLease, error) { - req := cloneOpenAIWSAcquireRequest(baseAcquireReq) - req.PreferredConnID = strings.TrimSpace(preferred) - req.ForcePreferredConn = forcePreferredConn - // dedicated 模式下每次获取均新建连接,避免跨会话复用残留上下文。 - req.ForceNewConn = dedicatedMode - acquireCtx, acquireCancel := context.WithTimeout(ctx, acquireTimeout) - lease, acquireErr := pool.Acquire(acquireCtx, req) - acquireCancel() - if acquireErr != nil { - dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(acquireErr) - logOpenAIWSModeInfo( - "ingress_ws_upstream_acquire_fail account_id=%d turn=%d reason=%s dial_status=%d dial_class=%s dial_close_status=%s dial_close_reason=%s dial_resp_server=%s dial_resp_via=%s dial_resp_cf_ray=%s dial_resp_x_request_id=%s cause=%s preferred_conn_id=%s force_preferred_conn=%v ws_host=%s ws_path=%s proxy_enabled=%v", - account.ID, - turn, - normalizeOpenAIWSLogValue(classifyOpenAIWSAcquireError(acquireErr)), - dialStatus, - dialClass, - dialCloseStatus, - truncateOpenAIWSLogValue(dialCloseReason, openAIWSHeaderValueMaxLen), - dialRespServer, - dialRespVia, - dialRespCFRay, - dialRespReqID, - truncateOpenAIWSLogValue(acquireErr.Error(), openAIWSLogValueMaxLen), - truncateOpenAIWSLogValue(preferred, openAIWSIDValueMaxLen), - forcePreferredConn, - wsHost, - wsPath, - account.ProxyID != nil && account.Proxy != nil, - ) - var dialErr *openAIWSDialError - if errors.As(acquireErr, &dialErr) && dialErr != nil && dialErr.StatusCode == http.StatusTooManyRequests { - s.persistOpenAIWSRateLimitSignal(ctx, account, dialErr.ResponseHeaders, nil, "rate_limit_exceeded", "rate_limit_error", strings.TrimSpace(acquireErr.Error())) - return nil, &UpstreamFailoverError{ - StatusCode: http.StatusTooManyRequests, - ResponseHeaders: cloneHeader(dialErr.ResponseHeaders), - } - } - if errors.Is(acquireErr, errOpenAIWSPreferredConnUnavailable) { - return nil, NewOpenAIWSClientCloseError( - coderws.StatusPolicyViolation, - "upstream continuation connection is unavailable; please restart the conversation", - acquireErr, - ) - } - if errors.Is(acquireErr, context.DeadlineExceeded) || errors.Is(acquireErr, errOpenAIWSConnQueueFull) { - return nil, NewOpenAIWSClientCloseError( - coderws.StatusTryAgainLater, - "upstream websocket is busy, please retry later", - acquireErr, - ) - } - return nil, acquireErr - } - connID := strings.TrimSpace(lease.ConnID()) - if handshakeTurnState := strings.TrimSpace(lease.HandshakeHeader(openAIWSTurnStateHeader)); handshakeTurnState != "" { - turnState = handshakeTurnState - if stateStore != nil && sessionHash != "" { - stateStore.BindSessionTurnState(groupID, sessionHash, handshakeTurnState, s.openAIWSSessionStickyTTL()) - } - updatedHeaders := cloneHeader(baseAcquireReq.Headers) - if updatedHeaders == nil { - updatedHeaders = make(http.Header) - } - updatedHeaders.Set(openAIWSTurnStateHeader, handshakeTurnState) - baseAcquireReq.Headers = updatedHeaders - } - logOpenAIWSModeInfo( - "ingress_ws_upstream_connected account_id=%d turn=%d conn_id=%s conn_reused=%v conn_pick_ms=%d queue_wait_ms=%d preferred_conn_id=%s", - account.ID, - turn, - truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), - lease.Reused(), - lease.ConnPickDuration().Milliseconds(), - lease.QueueWaitDuration().Milliseconds(), - truncateOpenAIWSLogValue(preferred, openAIWSIDValueMaxLen), - ) - return lease, nil - } - - sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string) (*OpenAIForwardResult, error) { - if lease == nil { - return nil, errors.New("upstream websocket lease is nil") - } - turnStart := time.Now() - wroteDownstream := false - if err := lease.WriteJSONWithContextTimeout(ctx, json.RawMessage(payload), s.openAIWSWriteTimeout()); err != nil { - return nil, wrapOpenAIWSIngressTurnError( - "write_upstream", - fmt.Errorf("write upstream websocket request: %w", err), - false, - ) - } - if debugEnabled { - logOpenAIWSModeDebug( - "ingress_ws_turn_request_sent account_id=%d turn=%d conn_id=%s payload_bytes=%d", - account.ID, - turn, - truncateOpenAIWSLogValue(lease.ConnID(), openAIWSIDValueMaxLen), - payloadBytes, - ) - } - - responseID := "" - usage := OpenAIUsage{} - imageCounter := newOpenAIImageOutputCounter() - var firstTokenMs *int - reqStream := openAIWSPayloadBoolFromRaw(payload, "stream", true) - turnPreviousResponseID := openAIWSPayloadStringFromRaw(payload, "previous_response_id") - turnPreviousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(turnPreviousResponseID) - turnPromptCacheKey := openAIWSPayloadStringFromRaw(payload, "prompt_cache_key") - turnStoreDisabled := s.isOpenAIWSStoreDisabledInRequestRaw(payload, account) - turnHasFunctionCallOutput := openAIWSRawPayloadHasToolCallOutput(payload) - eventCount := 0 - tokenEventCount := 0 - terminalEventCount := 0 - replayCollector := &openAIWSToolCallReplayCollector{} - firstEventType := "" - lastEventType := "" - needModelReplace := false - clientDisconnected := false - mappedModel := "" - var mappedModelBytes []byte - if originalModel != "" { - mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) - needModelReplace = mappedModel != "" && mappedModel != originalModel - if needModelReplace { - mappedModelBytes = []byte(mappedModel) - } - } - for { - upstreamMessage, readErr := lease.ReadMessageWithContextTimeout(ctx, s.openAIWSReadTimeout()) - if readErr != nil { - lease.MarkBroken() - return nil, wrapOpenAIWSIngressTurnError( - "read_upstream", - fmt.Errorf("read upstream websocket event: %w", readErr), - wroteDownstream, - ) - } - - eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage) - if responseID == "" && eventResponseID != "" { - responseID = eventResponseID - } - if eventType != "" { - eventCount++ - if firstEventType == "" { - firstEventType = eventType - } - lastEventType = eventType - } - if eventType == "error" { - errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(upstreamMessage) - s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), upstreamMessage, errCodeRaw, errTypeRaw, errMsgRaw) - fallbackReason, _ := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) - errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) - recoverablePrevNotFound := fallbackReason == openAIWSIngressStagePreviousResponseNotFound && - turnPreviousResponseID != "" && - !turnHasFunctionCallOutput && - s.openAIWSIngressPreviousResponseRecoveryEnabled() && - !wroteDownstream - if recoverablePrevNotFound { - // 可恢复场景使用非 error 关键字日志,避免被 LegacyPrintf 误判为 ERROR 级别。 - logOpenAIWSModeInfo( - "ingress_ws_prev_response_recoverable account_id=%d turn=%d conn_id=%s idx=%d reason=%s code=%s type=%s message=%s previous_response_id=%s previous_response_id_kind=%s response_id=%s store_disabled=%v has_prompt_cache_key=%v", - account.ID, - turn, - truncateOpenAIWSLogValue(lease.ConnID(), openAIWSIDValueMaxLen), - eventCount, - truncateOpenAIWSLogValue(fallbackReason, openAIWSLogValueMaxLen), - errCode, - errType, - errMessage, - truncateOpenAIWSLogValue(turnPreviousResponseID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(turnPreviousResponseIDKind), - truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen), - turnStoreDisabled, - turnPromptCacheKey != "", - ) - } else { - logOpenAIWSModeInfo( - "ingress_ws_error_event account_id=%d turn=%d conn_id=%s idx=%d fallback_reason=%s err_code=%s err_type=%s err_message=%s previous_response_id=%s previous_response_id_kind=%s response_id=%s store_disabled=%v has_prompt_cache_key=%v", - account.ID, - turn, - truncateOpenAIWSLogValue(lease.ConnID(), openAIWSIDValueMaxLen), - eventCount, - truncateOpenAIWSLogValue(fallbackReason, openAIWSLogValueMaxLen), - errCode, - errType, - errMessage, - truncateOpenAIWSLogValue(turnPreviousResponseID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(turnPreviousResponseIDKind), - truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen), - turnStoreDisabled, - turnPromptCacheKey != "", - ) - } - // previous_response_not_found 在 ingress 模式支持单次恢复重试: - // 不把该 error 直接下发客户端,而是由上层去掉 previous_response_id 后重放当前 turn。 - if recoverablePrevNotFound { - lease.MarkBroken() - errMsg := strings.TrimSpace(errMsgRaw) - if errMsg == "" { - errMsg = "previous response not found" - } - return nil, wrapOpenAIWSIngressTurnError( - openAIWSIngressStagePreviousResponseNotFound, - errors.New(errMsg), - false, - ) - } - if !wroteDownstream && isOpenAIWSRateLimitError(errCodeRaw, errTypeRaw, errMsgRaw) { - lease.MarkBroken() - return nil, &UpstreamFailoverError{ - StatusCode: http.StatusTooManyRequests, - ResponseBody: append([]byte(nil), upstreamMessage...), - ResponseHeaders: cloneHeader(lease.HandshakeHeaders()), - } - } - } - isTokenEvent := isOpenAIWSTokenEvent(eventType) - if isTokenEvent { - tokenEventCount++ - } - isTerminalEvent := isOpenAIWSTerminalEvent(eventType) - if isTerminalEvent { - terminalEventCount++ - } - if firstTokenMs == nil && isTokenEvent { - ms := int(time.Since(turnStart).Milliseconds()) - firstTokenMs = &ms - } - if openAIWSEventShouldParseUsage(eventType) { - parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage) - } - imageCounter.AddSSEData(upstreamMessage) - - if eventType == "response.failed" { - if hit, code, msg := detectOpenAICyberPolicy(upstreamMessage); hit { - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: msg, - Body: truncateString(string(upstreamMessage), 4096), - UpstreamStatus: http.StatusOK, - UpstreamInTok: usage.InputTokens, - UpstreamOutTok: usage.OutputTokens, - }) - } - } - - if !clientDisconnected { - if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && bytes.Contains(upstreamMessage, mappedModelBytes) { - upstreamMessage = replaceOpenAIWSMessageModel(upstreamMessage, mappedModel, originalModel) - } - if openAIWSEventMayContainToolCalls(eventType) && openAIWSMessageLikelyContainsToolCalls(upstreamMessage) { - if corrected, changed := s.toolCorrector.CorrectToolCallsInSSEBytes(upstreamMessage); changed { - upstreamMessage = corrected - } - } - replayCollector.AddEvent(eventType, upstreamMessage) - if err := writeClientMessage(upstreamMessage); err != nil { - if isOpenAIWSClientDisconnectError(err) { - clientDisconnected = true - closeStatus, closeReason := summarizeOpenAIWSReadCloseError(err) - logOpenAIWSModeInfo( - "ingress_ws_client_disconnected_drain account_id=%d turn=%d conn_id=%s close_status=%s close_reason=%s", - account.ID, - turn, - truncateOpenAIWSLogValue(lease.ConnID(), openAIWSIDValueMaxLen), - closeStatus, - truncateOpenAIWSLogValue(closeReason, openAIWSHeaderValueMaxLen), - ) - } else { - return nil, wrapOpenAIWSIngressTurnError( - "write_client", - fmt.Errorf("write client websocket event: %w", err), - wroteDownstream, - ) - } - } else { - wroteDownstream = true - } - } - if isTerminalEvent { - // 客户端已断连时,上游连接的 session 状态不可信,标记 broken 避免回池复用。 - if clientDisconnected { - lease.MarkBroken() - } - firstTokenMsValue := -1 - if firstTokenMs != nil { - firstTokenMsValue = *firstTokenMs - } - if debugEnabled { - logOpenAIWSModeDebug( - "ingress_ws_turn_completed account_id=%d turn=%d conn_id=%s response_id=%s duration_ms=%d events=%d token_events=%d terminal_events=%d first_event=%s last_event=%s first_token_ms=%d client_disconnected=%v", - account.ID, - turn, - truncateOpenAIWSLogValue(lease.ConnID(), openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen), - time.Since(turnStart).Milliseconds(), - eventCount, - tokenEventCount, - terminalEventCount, - truncateOpenAIWSLogValue(firstEventType, openAIWSLogValueMaxLen), - truncateOpenAIWSLogValue(lastEventType, openAIWSLogValueMaxLen), - firstTokenMsValue, - clientDisconnected, - ) - } - imageCount := imageCounter.Count() - result := &OpenAIForwardResult{ - RequestID: responseID, - Usage: usage, - Model: originalModel, - UpstreamModel: mappedModel, - ServiceTier: extractOpenAIServiceTierFromBody(payload), - ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, originalModel), payload, mappedModel), - Stream: reqStream, - OpenAIWSMode: true, - ResponseHeaders: lease.HandshakeHeaders(), - Duration: time.Since(turnStart), - FirstTokenMs: firstTokenMs, - } - if replayInput := replayCollector.Items(); len(replayInput) > 0 { - result.wsReplayInput = replayInput - result.wsReplayInputExists = true - } - if imageCount > 0 { - result.ImageCount = imageCount - result.ImageSize = imageSizeTier - result.ImageInputSize = imageInputSize - result.ImageOutputSizes = imageCounter.Sizes() - result.BillingModel = imageBillingModel - } - return result, nil - } - } - } - - currentPayload := firstPayload.payloadRaw - currentOriginalModel := firstPayload.originalModel - currentImageBillingModel := firstPayload.imageBillingModel - currentImageSizeTier := firstPayload.imageSizeTier - currentImageInputSize := firstPayload.imageInputSize - currentPayloadBytes := firstPayload.payloadBytes - isStrictAffinityTurn := func(payload []byte) bool { - if !storeDisabled { - return false - } - return strings.TrimSpace(openAIWSPayloadStringFromRaw(payload, "previous_response_id")) != "" - } - var sessionLease *openAIWSConnLease - sessionConnID := "" - pinnedSessionConnID := "" - unpinSessionConn := func(connID string) { - connID = strings.TrimSpace(connID) - if connID == "" || pinnedSessionConnID != connID { - return - } - pool.UnpinConn(account.ID, connID) - pinnedSessionConnID = "" - } - pinSessionConn := func(connID string) { - if !storeDisabled { - return - } - connID = strings.TrimSpace(connID) - if connID == "" || pinnedSessionConnID == connID { - return - } - if pinnedSessionConnID != "" { - pool.UnpinConn(account.ID, pinnedSessionConnID) - pinnedSessionConnID = "" - } - if pool.PinConn(account.ID, connID) { - pinnedSessionConnID = connID - } - } - // lastTurnClean 标记最后一轮 sendAndRelay 是否正常完成(收到终端事件且客户端未断连)。 - // 所有异常路径(读写错误、error 事件、客户端断连)已在各自分支或上层(L3403)中 MarkBroken, - // 因此 releaseSessionLease 中只需在非正常结束时 MarkBroken。 - lastTurnClean := false - releaseSessionLease := func() { - if sessionLease == nil { - return - } - if !lastTurnClean { - sessionLease.MarkBroken() - } - unpinSessionConn(sessionConnID) - sessionLease.Release() - if debugEnabled { - logOpenAIWSModeDebug( - "ingress_ws_upstream_released account_id=%d conn_id=%s", - account.ID, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - ) - } - } - defer releaseSessionLease() - - turn := 1 - turnRetry := 0 - turnPrevRecoveryTried := false - lastTurnFinishedAt := time.Time{} - lastTurnResponseID := "" - lastTurnPayload := []byte(nil) - var lastTurnStrictState *openAIWSIngressPreviousTurnStrictState - lastTurnReplayInput := []json.RawMessage(nil) - lastTurnReplayInputExists := false - currentTurnReplayInput := []json.RawMessage(nil) - currentTurnReplayInputExists := false - skipBeforeTurn := false - hasCurrentOrReplayFunctionCallOutput := func(payload []byte) bool { - if openAIWSRawPayloadHasToolCallOutput(payload) { - return true - } - return currentTurnReplayInputExists && openAIWSRawItemsHasFunctionCallOutput(currentTurnReplayInput) - } - resetSessionLease := func(markBroken bool) { - if sessionLease == nil { - return - } - if markBroken { - sessionLease.MarkBroken() - } - releaseSessionLease() - sessionLease = nil - sessionConnID = "" - preferredConnID = "" - } - recoverIngressPrevResponseNotFound := func(relayErr error, turn int, connID string) bool { - if !isOpenAIWSIngressPreviousResponseNotFound(relayErr) { - return false - } - if turnPrevRecoveryTried || !s.openAIWSIngressPreviousResponseRecoveryEnabled() { - return false - } - // 携带 function_call_output 的请求不能丢弃 previous_response_id: - // 上游 API 需要 response chain 来匹配 tool_result 与之前的 tool_use, - // 丢弃后会导致 "No tool call found for function call output" 400 错误。 - if hasCurrentOrReplayFunctionCallOutput(currentPayload) { - return false - } - if isStrictAffinityTurn(currentPayload) { - // Layer 2:严格亲和链路命中 previous_response_not_found 时,降级为“去掉 previous_response_id 后重放一次”。 - // 该错误说明续链锚点已失效,继续 strict fail-close 只会直接中断本轮请求。 - logOpenAIWSModeInfo( - "ingress_ws_prev_response_recovery_layer2 account_id=%d turn=%d conn_id=%s store_disabled_conn_mode=%s action=drop_previous_response_id_retry", - account.ID, - turn, - truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(storeDisabledConnMode), - ) - } - turnPrevRecoveryTried = true - updatedPayload, removed, dropErr := dropPreviousResponseIDFromRawPayload(currentPayload) - if dropErr != nil || !removed { - reason := "not_removed" - if dropErr != nil { - reason = "drop_error" - } - logOpenAIWSModeInfo( - "ingress_ws_prev_response_recovery_skip account_id=%d turn=%d conn_id=%s reason=%s", - account.ID, - turn, - truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(reason), - ) - return false - } - updatedWithInput, setInputErr := setOpenAIWSPayloadInputSequence( - updatedPayload, - currentTurnReplayInput, - currentTurnReplayInputExists, - ) - if setInputErr != nil { - logOpenAIWSModeInfo( - "ingress_ws_prev_response_recovery_skip account_id=%d turn=%d conn_id=%s reason=set_full_input_error cause=%s", - account.ID, - turn, - truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(setInputErr.Error(), openAIWSLogValueMaxLen), - ) - return false - } - logOpenAIWSModeInfo( - "ingress_ws_prev_response_recovery account_id=%d turn=%d conn_id=%s action=drop_previous_response_id retry=1", - account.ID, - turn, - truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), - ) - currentPayload = updatedWithInput - currentPayloadBytes = len(updatedWithInput) - resetSessionLease(true) - skipBeforeTurn = true - return true - } - retryIngressTurn := func(relayErr error, turn int, connID string) bool { - if !isOpenAIWSIngressTurnRetryable(relayErr) || turnRetry >= 1 { - return false - } - if isStrictAffinityTurn(currentPayload) { - logOpenAIWSModeInfo( - "ingress_ws_turn_retry_skip account_id=%d turn=%d conn_id=%s reason=strict_affinity", - account.ID, - turn, - truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), - ) - return false - } - turnRetry++ - logOpenAIWSModeInfo( - "ingress_ws_turn_retry account_id=%d turn=%d retry=%d reason=%s conn_id=%s", - account.ID, - turn, - turnRetry, - truncateOpenAIWSLogValue(openAIWSIngressTurnRetryReason(relayErr), openAIWSLogValueMaxLen), - truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), - ) - resetSessionLease(true) - skipBeforeTurn = true - return true - } - for { - if turn > 1 && !skipBeforeTurn && hooks != nil && hooks.BeforeRequest != nil { - if err := hooks.BeforeRequest(turn, currentPayload, currentOriginalModel); err != nil { - return err - } - } - if !skipBeforeTurn && hooks != nil && hooks.BeforeTurn != nil { - if err := hooks.BeforeTurn(turn); err != nil { - return err - } - } - skipBeforeTurn = false - currentPreviousResponseID := openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id") - expectedPrev := strings.TrimSpace(lastTurnResponseID) - toolSignals := ToolContinuationSignals{ - HasFunctionCallOutput: openAIWSRawPayloadHasToolCallOutput(currentPayload), - } - if toolSignals.HasFunctionCallOutput { - var currentReqBody map[string]any - if err := json.Unmarshal(currentPayload, ¤tReqBody); err == nil { - toolSignals = AnalyzeToolContinuationSignals(currentReqBody) - } - } - hasFunctionCallOutput := toolSignals.HasFunctionCallOutput - // store=false + function_call_output 场景必须有续链锚点。 - // 若客户端未传 previous_response_id,优先回填上一轮响应 ID,避免上游报 call_id 无法关联。 - if shouldInferIngressFunctionCallOutputPreviousResponseID( - storeDisabled, - turn, - toolSignals, - currentPreviousResponseID, - expectedPrev, - ) { - updatedPayload, setPrevErr := setPreviousResponseIDToRawPayload(currentPayload, expectedPrev) - if setPrevErr != nil { - logOpenAIWSModeInfo( - "ingress_ws_function_call_output_prev_infer_skip account_id=%d turn=%d conn_id=%s reason=set_previous_response_id_error cause=%s expected_previous_response_id=%s", - account.ID, - turn, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(setPrevErr.Error(), openAIWSLogValueMaxLen), - truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), - ) - } else { - currentPayload = updatedPayload - currentPayloadBytes = len(updatedPayload) - currentPreviousResponseID = expectedPrev - logOpenAIWSModeInfo( - "ingress_ws_function_call_output_prev_infer account_id=%d turn=%d conn_id=%s action=set_previous_response_id previous_response_id=%s", - account.ID, - turn, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), - ) - } - } - nextReplayInput, nextReplayInputExists, replayInputErr := buildOpenAIWSReplayInputSequence( - lastTurnReplayInput, - lastTurnReplayInputExists, - currentPayload, - currentPreviousResponseID != "", - ) - if replayInputErr != nil { - logOpenAIWSModeInfo( - "ingress_ws_replay_input_skip account_id=%d turn=%d conn_id=%s reason=build_error cause=%s", - account.ID, - turn, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(replayInputErr.Error(), openAIWSLogValueMaxLen), - ) - currentTurnReplayInput = nil - currentTurnReplayInputExists = false - } else { - currentTurnReplayInput = nextReplayInput - currentTurnReplayInputExists = nextReplayInputExists - } - replayHasFunctionCallOutput := currentTurnReplayInputExists && - openAIWSRawItemsHasFunctionCallOutput(currentTurnReplayInput) - hasFunctionCallOutput = hasFunctionCallOutput || replayHasFunctionCallOutput - if storeDisabled && turn > 1 && currentPreviousResponseID != "" { - shouldKeepPreviousResponseID := false - strictReason := "" - var strictErr error - if lastTurnStrictState != nil { - shouldKeepPreviousResponseID, strictReason, strictErr = shouldKeepIngressPreviousResponseIDWithStrictState( - lastTurnStrictState, - currentPayload, - lastTurnResponseID, - hasFunctionCallOutput, - ) - } else { - shouldKeepPreviousResponseID, strictReason, strictErr = shouldKeepIngressPreviousResponseID( - lastTurnPayload, - currentPayload, - lastTurnResponseID, - hasFunctionCallOutput, - ) - } - if strictErr != nil { - logOpenAIWSModeInfo( - "ingress_ws_prev_response_strict_eval account_id=%d turn=%d conn_id=%s action=keep_previous_response_id reason=%s cause=%s previous_response_id=%s expected_previous_response_id=%s has_function_call_output=%v", - account.ID, - turn, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(strictReason), - truncateOpenAIWSLogValue(strictErr.Error(), openAIWSLogValueMaxLen), - truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), - hasFunctionCallOutput, - ) - } else if !shouldKeepPreviousResponseID { - updatedPayload, removed, dropErr := dropPreviousResponseIDFromRawPayload(currentPayload) - if dropErr != nil || !removed { - dropReason := "not_removed" - if dropErr != nil { - dropReason = "drop_error" - } - logOpenAIWSModeInfo( - "ingress_ws_prev_response_strict_eval account_id=%d turn=%d conn_id=%s action=keep_previous_response_id reason=%s drop_reason=%s previous_response_id=%s expected_previous_response_id=%s has_function_call_output=%v", - account.ID, - turn, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(strictReason), - normalizeOpenAIWSLogValue(dropReason), - truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), - hasFunctionCallOutput, - ) - } else { - updatedWithInput, setInputErr := setOpenAIWSPayloadInputSequence( - updatedPayload, - currentTurnReplayInput, - currentTurnReplayInputExists, - ) - if setInputErr != nil { - logOpenAIWSModeInfo( - "ingress_ws_prev_response_strict_eval account_id=%d turn=%d conn_id=%s action=keep_previous_response_id reason=%s drop_reason=set_full_input_error previous_response_id=%s expected_previous_response_id=%s cause=%s has_function_call_output=%v", - account.ID, - turn, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(strictReason), - truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(setInputErr.Error(), openAIWSLogValueMaxLen), - hasFunctionCallOutput, - ) - } else { - currentPayload = updatedWithInput - currentPayloadBytes = len(updatedWithInput) - logOpenAIWSModeInfo( - "ingress_ws_prev_response_strict_eval account_id=%d turn=%d conn_id=%s action=drop_previous_response_id_full_create reason=%s previous_response_id=%s expected_previous_response_id=%s has_function_call_output=%v", - account.ID, - turn, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(strictReason), - truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), - hasFunctionCallOutput, - ) - currentPreviousResponseID = "" - } - } - } - } - forcePreferredConn := isStrictAffinityTurn(currentPayload) - if sessionLease == nil { - acquiredLease, acquireErr := acquireTurnLease(turn, preferredConnID, forcePreferredConn) - if acquireErr != nil { - return fmt.Errorf("acquire upstream websocket: %w", acquireErr) - } - sessionLease = acquiredLease - sessionConnID = strings.TrimSpace(sessionLease.ConnID()) - if storeDisabled { - pinSessionConn(sessionConnID) - } else { - unpinSessionConn(sessionConnID) - } - } - shouldPreflightPing := turn > 1 && sessionLease != nil && turnRetry == 0 - if shouldPreflightPing && openAIWSIngressPreflightPingIdle > 0 && !lastTurnFinishedAt.IsZero() { - if time.Since(lastTurnFinishedAt) < openAIWSIngressPreflightPingIdle { - shouldPreflightPing = false - } - } - if shouldPreflightPing { - if pingErr := sessionLease.PingWithTimeout(openAIWSConnHealthCheckTO); pingErr != nil { - logOpenAIWSModeInfo( - "ingress_ws_upstream_preflight_ping_fail account_id=%d turn=%d conn_id=%s cause=%s", - account.ID, - turn, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(pingErr.Error(), openAIWSLogValueMaxLen), - ) - if forcePreferredConn { - // 携带 function_call_output 的请求不能丢弃 previous_response_id: - // 上游 API 需要 response chain 来匹配 tool_result 与之前的 tool_use, - // 除非 replay input 已经包含与每个 tool_result 匹配的 tool_use 上下文。 - hasFCOutput := hasFunctionCallOutput - hasReplayToolContext := hasFCOutput && - currentTurnReplayInputExists && - openAIWSRawItemsHaveToolCallContextForOutputs(currentTurnReplayInput) - if !turnPrevRecoveryTried && currentPreviousResponseID != "" && (!hasFCOutput || hasReplayToolContext) { - updatedPayload, removed, dropErr := dropPreviousResponseIDFromRawPayload(currentPayload) - if dropErr != nil || !removed { - reason := "not_removed" - if dropErr != nil { - reason = "drop_error" - } - logOpenAIWSModeInfo( - "ingress_ws_preflight_ping_recovery_skip account_id=%d turn=%d conn_id=%s reason=%s previous_response_id=%s", - account.ID, - turn, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(reason), - truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), - ) - } else { - updatedWithInput, setInputErr := setOpenAIWSPayloadInputSequence( - updatedPayload, - currentTurnReplayInput, - currentTurnReplayInputExists, - ) - if setInputErr != nil { - logOpenAIWSModeInfo( - "ingress_ws_preflight_ping_recovery_skip account_id=%d turn=%d conn_id=%s reason=set_full_input_error previous_response_id=%s cause=%s", - account.ID, - turn, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(setInputErr.Error(), openAIWSLogValueMaxLen), - ) - } else { - logOpenAIWSModeInfo( - "ingress_ws_preflight_ping_recovery account_id=%d turn=%d conn_id=%s action=drop_previous_response_id_retry previous_response_id=%s has_function_call_output=%v has_replay_tool_context=%v", - account.ID, - turn, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), - hasFCOutput, - hasReplayToolContext, - ) - turnPrevRecoveryTried = true - currentPayload = updatedWithInput - currentPayloadBytes = len(updatedWithInput) - resetSessionLease(true) - skipBeforeTurn = true - continue - } - } - } - if hasFCOutput && currentPreviousResponseID != "" { - reason := "function_call_output_missing_replay_context" - if hasReplayToolContext { - reason = "function_call_output_replay_not_applied" - } - logOpenAIWSModeInfo( - "ingress_ws_preflight_ping_recovery_skip account_id=%d turn=%d conn_id=%s reason=%s action=fail_close previous_response_id=%s has_replay_tool_context=%v", - account.ID, - turn, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - reason, - truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), - hasReplayToolContext, - ) - } - resetSessionLease(true) - return NewOpenAIWSClientCloseError( - coderws.StatusPolicyViolation, - "upstream continuation connection is unavailable; please restart the conversation", - pingErr, - ) - } - resetSessionLease(true) - - acquiredLease, acquireErr := acquireTurnLease(turn, preferredConnID, forcePreferredConn) - if acquireErr != nil { - return fmt.Errorf("acquire upstream websocket after preflight ping fail: %w", acquireErr) - } - sessionLease = acquiredLease - sessionConnID = strings.TrimSpace(sessionLease.ConnID()) - if storeDisabled { - pinSessionConn(sessionConnID) - } - } - } - connID := sessionConnID - if currentPreviousResponseID != "" { - chainedFromLast := expectedPrev != "" && currentPreviousResponseID == expectedPrev - currentPreviousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(currentPreviousResponseID) - logOpenAIWSModeInfo( - "ingress_ws_turn_chain account_id=%d turn=%d conn_id=%s previous_response_id=%s previous_response_id_kind=%s last_turn_response_id=%s chained_from_last=%v preferred_conn_id=%s header_session_id=%s header_conversation_id=%s has_turn_state=%v turn_state_len=%d has_prompt_cache_key=%v store_disabled=%v", - account.ID, - turn, - truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(currentPreviousResponseIDKind), - truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), - chainedFromLast, - truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen), - openAIWSHeaderValueForLog(baseAcquireReq.Headers, "session_id"), - openAIWSHeaderValueForLog(baseAcquireReq.Headers, "conversation_id"), - turnState != "", - len(turnState), - openAIWSPayloadStringFromRaw(currentPayload, "prompt_cache_key") != "", - storeDisabled, - ) - } - - result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier, currentImageInputSize) - if relayErr != nil { - lastTurnClean = false - if recoverIngressPrevResponseNotFound(relayErr, turn, connID) { - continue - } - if retryIngressTurn(relayErr, turn, connID) { - continue - } - finalErr := relayErr - if unwrapped := errors.Unwrap(relayErr); unwrapped != nil { - finalErr = unwrapped - } - if hooks != nil && hooks.AfterTurn != nil { - hooks.AfterTurn(turn, nil, finalErr) - } - sessionLease.MarkBroken() - return finalErr - } - turnRetry = 0 - turnPrevRecoveryTried = false - lastTurnFinishedAt = time.Now() - lastTurnClean = true - if hooks != nil && hooks.AfterTurn != nil { - hooks.AfterTurn(turn, result, nil) - } - if result == nil { - return errors.New("websocket turn result is nil") - } - responseID := strings.TrimSpace(result.RequestID) - lastTurnResponseID = responseID - lastTurnPayload = cloneOpenAIWSPayloadBytes(currentPayload) - lastTurnReplayInput = cloneOpenAIWSRawMessages(currentTurnReplayInput) - lastTurnReplayInputExists = currentTurnReplayInputExists - if result.wsReplayInputExists { - lastTurnReplayInput = append(lastTurnReplayInput, cloneOpenAIWSRawMessages(result.wsReplayInput)...) - lastTurnReplayInputExists = true - } - nextStrictState, strictStateErr := buildOpenAIWSIngressPreviousTurnStrictState(currentPayload) - if strictStateErr != nil { - lastTurnStrictState = nil - logOpenAIWSModeInfo( - "ingress_ws_prev_response_strict_state_skip account_id=%d turn=%d conn_id=%s reason=build_error cause=%s", - account.ID, - turn, - truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(strictStateErr.Error(), openAIWSLogValueMaxLen), - ) - } else { - lastTurnStrictState = nextStrictState - } - - if responseID != "" && stateStore != nil { - ttl := s.openAIWSResponseStickyTTL() - logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, stateStore.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) - stateStore.BindResponseConn(responseID, connID, ttl) - } - if stateStore != nil && storeDisabled && sessionHash != "" { - stateStore.BindSessionConn(groupID, sessionHash, connID, s.openAIWSSessionStickyTTL()) - } - if connID != "" { - preferredConnID = connID - } - - nextClientMessage, readErr := readClientMessage() - if readErr != nil { - if isOpenAIWSClientDisconnectError(readErr) { - closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr) - logOpenAIWSModeInfo( - "ingress_ws_client_closed account_id=%d conn_id=%s close_status=%s close_reason=%s", - account.ID, - truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), - closeStatus, - truncateOpenAIWSLogValue(closeReason, openAIWSHeaderValueMaxLen), - ) - return nil - } - return fmt.Errorf("read client websocket request: %w", readErr) - } - - nextPayload, parseErr := parseClientPayload(nextClientMessage) - if parseErr != nil { - return parseErr - } - if nextPayload.promptCacheKey != "" { - // ingress 会话在整个客户端 WS 生命周期内复用同一上游连接; - // prompt_cache_key 对握手头的更新仅在未来需要重新建连时生效。 - updatedHeaders, _, updHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), nextPayload.promptCacheKey) - if updHdrErr != nil { - logOpenAIWSModeInfo("ingress_ws_update_headers_failed account_id=%d err=%v", account.ID, updHdrErr) - } else { - baseAcquireReq.Headers = updatedHeaders - } - } - if nextPayload.previousResponseID != "" { - expectedPrev := strings.TrimSpace(lastTurnResponseID) - chainedFromLast := expectedPrev != "" && nextPayload.previousResponseID == expectedPrev - nextPreviousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(nextPayload.previousResponseID) - logOpenAIWSModeInfo( - "ingress_ws_next_turn_chain account_id=%d turn=%d next_turn=%d conn_id=%s previous_response_id=%s previous_response_id_kind=%s last_turn_response_id=%s chained_from_last=%v has_prompt_cache_key=%v store_disabled=%v", - account.ID, - turn, - turn+1, - truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(nextPayload.previousResponseID, openAIWSIDValueMaxLen), - normalizeOpenAIWSLogValue(nextPreviousResponseIDKind), - truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), - chainedFromLast, - nextPayload.promptCacheKey != "", - storeDisabled, - ) - } - if stateStore != nil && nextPayload.previousResponseID != "" { - if stickyConnID, ok := stateStore.GetResponseConn(nextPayload.previousResponseID); ok { - if sessionConnID != "" && stickyConnID != "" && stickyConnID != sessionConnID { - logOpenAIWSModeInfo( - "ingress_ws_keep_session_conn account_id=%d turn=%d conn_id=%s sticky_conn_id=%s previous_response_id=%s", - account.ID, - turn, - truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(stickyConnID, openAIWSIDValueMaxLen), - truncateOpenAIWSLogValue(nextPayload.previousResponseID, openAIWSIDValueMaxLen), - ) - } else { - preferredConnID = stickyConnID - } - } - } - currentPayload = nextPayload.payloadRaw - currentOriginalModel = nextPayload.originalModel - currentImageBillingModel = nextPayload.imageBillingModel - currentImageSizeTier = nextPayload.imageSizeTier - currentImageInputSize = nextPayload.imageInputSize - currentPayloadBytes = nextPayload.payloadBytes - storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account) - if !storeDisabled { - unpinSessionConn(sessionConnID) - } - turn++ - } -} - -func (s *OpenAIGatewayService) isOpenAIWSGeneratePrewarmEnabled() bool { - return s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.PrewarmGenerateEnabled -} - -// performOpenAIWSGeneratePrewarm 在 WSv2 下执行可选的 generate=false 预热。 -// 预热默认关闭,仅在配置开启后生效;失败时按可恢复错误回退到 HTTP。 -func (s *OpenAIGatewayService) performOpenAIWSGeneratePrewarm( - ctx context.Context, - lease *openAIWSConnLease, - decision OpenAIWSProtocolDecision, - payload map[string]any, - previousResponseID string, - reqBody map[string]any, - account *Account, - stateStore OpenAIWSStateStore, - groupID int64, -) error { - if s == nil { - return nil - } - if lease == nil || account == nil { - logOpenAIWSModeInfo("prewarm_skip reason=invalid_state has_lease=%v has_account=%v", lease != nil, account != nil) - return nil - } - connID := strings.TrimSpace(lease.ConnID()) - if !s.isOpenAIWSGeneratePrewarmEnabled() { - return nil - } - if decision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { - logOpenAIWSModeInfo( - "prewarm_skip account_id=%d conn_id=%s reason=transport_not_v2 transport=%s", - account.ID, - connID, - normalizeOpenAIWSLogValue(string(decision.Transport)), - ) - return nil - } - if strings.TrimSpace(previousResponseID) != "" { - logOpenAIWSModeInfo( - "prewarm_skip account_id=%d conn_id=%s reason=has_previous_response_id previous_response_id=%s", - account.ID, - connID, - truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen), - ) - return nil - } - if lease.IsPrewarmed() { - logOpenAIWSModeInfo("prewarm_skip account_id=%d conn_id=%s reason=already_prewarmed", account.ID, connID) - return nil - } - if NeedsToolContinuation(reqBody) { - logOpenAIWSModeInfo("prewarm_skip account_id=%d conn_id=%s reason=tool_continuation", account.ID, connID) - return nil - } - prewarmStart := time.Now() - logOpenAIWSModeInfo("prewarm_start account_id=%d conn_id=%s", account.ID, connID) - - prewarmPayload := make(map[string]any, len(payload)+1) - for k, v := range payload { - prewarmPayload[k] = v - } - prewarmPayload["generate"] = false - prewarmPayloadJSON := payloadAsJSONBytes(prewarmPayload) - - if err := lease.WriteJSONWithContextTimeout(ctx, prewarmPayload, s.openAIWSWriteTimeout()); err != nil { - lease.MarkBroken() - logOpenAIWSModeInfo( - "prewarm_write_fail account_id=%d conn_id=%s cause=%s", - account.ID, - connID, - truncateOpenAIWSLogValue(err.Error(), openAIWSLogValueMaxLen), - ) - return wrapOpenAIWSFallback("prewarm_write", err) - } - logOpenAIWSModeInfo("prewarm_write_sent account_id=%d conn_id=%s payload_bytes=%d", account.ID, connID, len(prewarmPayloadJSON)) - - prewarmResponseID := "" - prewarmEventCount := 0 - prewarmTerminalCount := 0 - for { - message, readErr := lease.ReadMessageWithContextTimeout(ctx, s.openAIWSReadTimeout()) - if readErr != nil { - lease.MarkBroken() - closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr) - logOpenAIWSModeInfo( - "prewarm_read_fail account_id=%d conn_id=%s close_status=%s close_reason=%s cause=%s events=%d", - account.ID, - connID, - closeStatus, - closeReason, - truncateOpenAIWSLogValue(readErr.Error(), openAIWSLogValueMaxLen), - prewarmEventCount, - ) - return wrapOpenAIWSFallback("prewarm_"+classifyOpenAIWSReadFallbackReason(readErr), readErr) - } - - eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(message) - if eventType == "" { - continue - } - prewarmEventCount++ - if prewarmResponseID == "" && eventResponseID != "" { - prewarmResponseID = eventResponseID - } - if prewarmEventCount <= openAIWSPrewarmEventLogHead || eventType == "error" || isOpenAIWSTerminalEvent(eventType) { - logOpenAIWSModeInfo( - "prewarm_event account_id=%d conn_id=%s idx=%d type=%s bytes=%d", - account.ID, - connID, - prewarmEventCount, - truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen), - len(message), - ) - } - - if eventType == "error" { - errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(message) - s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), message, errCodeRaw, errTypeRaw, errMsgRaw) - errMsg := strings.TrimSpace(errMsgRaw) - if errMsg == "" { - errMsg = "OpenAI websocket prewarm error" - } - fallbackReason, canFallback := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) - errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) - logOpenAIWSModeInfo( - "prewarm_error_event account_id=%d conn_id=%s idx=%d fallback_reason=%s can_fallback=%v err_code=%s err_type=%s err_message=%s", - account.ID, - connID, - prewarmEventCount, - truncateOpenAIWSLogValue(fallbackReason, openAIWSLogValueMaxLen), - canFallback, - errCode, - errType, - errMessage, - ) - lease.MarkBroken() - if canFallback { - return wrapOpenAIWSFallback("prewarm_"+fallbackReason, errors.New(errMsg)) - } - return wrapOpenAIWSFallback("prewarm_error_event", errors.New(errMsg)) - } - - if isOpenAIWSTerminalEvent(eventType) { - prewarmTerminalCount++ - break - } - } - - lease.MarkPrewarmed() - if prewarmResponseID != "" && stateStore != nil { - ttl := s.openAIWSResponseStickyTTL() - logOpenAIWSBindResponseAccountWarn(groupID, account.ID, prewarmResponseID, stateStore.BindResponseAccount(ctx, groupID, prewarmResponseID, account.ID, ttl)) - stateStore.BindResponseConn(prewarmResponseID, lease.ConnID(), ttl) - } - logOpenAIWSModeInfo( - "prewarm_done account_id=%d conn_id=%s response_id=%s events=%d terminal_events=%d duration_ms=%d", - account.ID, - connID, - truncateOpenAIWSLogValue(prewarmResponseID, openAIWSIDValueMaxLen), - prewarmEventCount, - prewarmTerminalCount, - time.Since(prewarmStart).Milliseconds(), - ) - return nil -} - -func payloadAsJSON(payload map[string]any) string { - return string(payloadAsJSONBytes(payload)) -} - -func payloadAsJSONBytes(payload map[string]any) []byte { - if len(payload) == 0 { - return []byte("{}") - } - body, err := json.Marshal(payload) - if err != nil { - return []byte("{}") - } - return body -} - -func isOpenAIWSTerminalEvent(eventType string) bool { - switch strings.TrimSpace(eventType) { - case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": - return true - default: - return false - } -} - -func isOpenAIWSTokenEvent(eventType string) bool { - eventType = strings.TrimSpace(eventType) - if eventType == "" { - return false - } - switch eventType { - case "response.created", "response.in_progress", "response.output_item.added", "response.output_item.done": - return false - } - if strings.Contains(eventType, ".delta") { - return true - } - if strings.HasPrefix(eventType, "response.output_text") { - return true - } - if strings.HasPrefix(eventType, "response.output") { - return true - } - // 终止事件(response.completed/done/failed/...)由 isOpenAIWSTerminalEvent 单独处理。 - // 不能把它们当作 token event,否则当上游没有可识别的 delta 时, - // firstTokenMs 会被填到终止时刻,等于把"总耗时"误报为"首 token 延迟"。 - return false -} - -func replaceOpenAIWSMessageModel(message []byte, fromModel, toModel string) []byte { - if len(message) == 0 { - return message - } - if strings.TrimSpace(fromModel) == "" || strings.TrimSpace(toModel) == "" || fromModel == toModel { - return message - } - if !bytes.Contains(message, []byte(`"model"`)) || !bytes.Contains(message, []byte(fromModel)) { - return message - } - modelValues := gjson.GetManyBytes(message, "model", "response.model") - replaceModel := modelValues[0].Exists() && modelValues[0].Str == fromModel - replaceResponseModel := modelValues[1].Exists() && modelValues[1].Str == fromModel - if !replaceModel && !replaceResponseModel { - return message - } - updated := message - if replaceModel { - if next, err := sjson.SetBytes(updated, "model", toModel); err == nil { - updated = next - } - } - if replaceResponseModel { - if next, err := sjson.SetBytes(updated, "response.model", toModel); err == nil { - updated = next - } - } - return updated -} - -func populateOpenAIUsageFromResponseJSON(body []byte, usage *OpenAIUsage) { - if usage == nil || len(body) == 0 { - return - } - values := gjson.GetManyBytes( - body, - "usage.input_tokens", - "usage.output_tokens", - "usage.input_tokens_details.cached_tokens", - ) - usage.InputTokens = int(values[0].Int()) - usage.OutputTokens = int(values[1].Int()) - usage.CacheReadInputTokens = int(values[2].Int()) -} - -func getOpenAIGroupIDFromContext(c *gin.Context) int64 { - if c == nil { - return 0 - } - value, exists := c.Get("api_key") - if !exists { - return 0 - } - apiKey, ok := value.(*APIKey) - if !ok || apiKey == nil || apiKey.GroupID == nil { - return 0 - } - return *apiKey.GroupID -} - -// SelectAccountByPreviousResponseID 按 previous_response_id 命中账号粘连。 -// 未命中或账号不可用时返回 (nil, nil),由调用方继续走常规调度。 -func (s *OpenAIGatewayService) SelectAccountByPreviousResponseID( - ctx context.Context, - groupID *int64, - previousResponseID string, - requestedModel string, - excludedIDs map[int64]struct{}, - requireCompact bool, -) (*AccountSelectionResult, error) { - return s.selectAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, "", requireCompact) -} - -func (s *OpenAIGatewayService) selectAccountByPreviousResponseIDForCapability( - ctx context.Context, - groupID *int64, - previousResponseID string, - requestedModel string, - excludedIDs map[int64]struct{}, - requiredCapability OpenAIEndpointCapability, - requireCompact bool, -) (*AccountSelectionResult, error) { - if s == nil { - return nil, nil - } - accountID, account, responseID, store := s.resolveAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, requiredCapability, requireCompact) - if accountID <= 0 || account == nil || store == nil { - return nil, nil - } - - result, acquireErr := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) - if acquireErr == nil && result.Acquired { - logOpenAIWSBindResponseAccountWarn( - derefGroupID(groupID), - accountID, - responseID, - store.BindResponseAccount(ctx, derefGroupID(groupID), responseID, accountID, s.openAIWSResponseStickyTTL()), - ) - return &AccountSelectionResult{ - Account: account, - Acquired: true, - ReleaseFunc: result.ReleaseFunc, - }, nil - } - - cfg := s.schedulingConfig() - if s.concurrencyService != nil { - return &AccountSelectionResult{ - Account: account, - WaitPlan: &AccountWaitPlan{ - AccountID: accountID, - MaxConcurrency: account.Concurrency, - Timeout: cfg.StickySessionWaitTimeout, - MaxWaiting: cfg.StickySessionMaxWaiting, - }, - }, nil - } - return nil, nil -} - -func (s *OpenAIGatewayService) ResolveAccountIDByPreviousResponseIDForScheduler( - ctx context.Context, - groupID *int64, - previousResponseID string, - requestedModel string, - excludedIDs map[int64]struct{}, - requiredCapability OpenAIEndpointCapability, - requireCompact bool, -) int64 { - accountID, _, _, _ := s.resolveAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, requiredCapability, requireCompact) - return accountID -} - -func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability( - ctx context.Context, - groupID *int64, - previousResponseID string, - requestedModel string, - excludedIDs map[int64]struct{}, - requiredCapability OpenAIEndpointCapability, - requireCompact bool, -) (int64, *Account, string, OpenAIWSStateStore) { - if s == nil { - return 0, nil, "", nil - } - responseID := strings.TrimSpace(previousResponseID) - if responseID == "" { - return 0, nil, "", nil - } - store := s.getOpenAIWSStateStore() - if store == nil { - return 0, nil, "", nil - } - - accountID, err := store.GetResponseAccount(ctx, derefGroupID(groupID), responseID) - if err != nil || accountID <= 0 { - return 0, nil, "", nil - } - if excludedIDs != nil { - if _, excluded := excludedIDs[accountID]; excluded { - return 0, nil, "", nil - } - } - - account, err := s.getSchedulableAccount(ctx, accountID) - if err != nil || account == nil { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return 0, nil, "", nil - } - // 非 WSv2 场景(如 force_http/全局关闭)不应使用 previous_response_id 粘连, - // 以保持“回滚到 HTTP”后的历史行为一致性。 - if s.getOpenAIWSProtocolResolver().Resolve(account).Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { - return 0, nil, "", nil - } - if shouldClearStickySession(account, requestedModel) || !account.IsOpenAI() || !account.IsSchedulable() { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return 0, nil, "", nil - } - if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return 0, nil, "", nil - } - if requestedModel != "" && !account.IsModelSupported(requestedModel) { - return 0, nil, "", nil - } - if !account.SupportsOpenAIEndpointCapability(requiredCapability) { - return 0, nil, "", nil - } - // Quota auto-pause must also gate the previous_response_id sticky path; otherwise an - // account over its 5h/7d threshold keeps serving the same response chain even though - // normal scheduling skips it. Pause is transient, so fall through to normal scheduling - // without deleting the binding (the window may reset before the next turn). - if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { - return 0, nil, "", nil - } - if s.schedulerSnapshot != nil && s.accountRepo != nil { - latest, latestErr := s.accountRepo.GetByID(ctx, account.ID) - if latestErr != nil || latest == nil { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return 0, nil, "", nil - } - if shouldClearStickySession(latest, requestedModel) || !latest.IsOpenAI() || !latest.IsSchedulable() { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return 0, nil, "", nil - } - if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return 0, nil, "", nil - } - if requestedModel != "" && !latest.IsModelSupported(requestedModel) { - return 0, nil, "", nil - } - if !latest.SupportsOpenAIEndpointCapability(requiredCapability) { - return 0, nil, "", nil - } - if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, latest); paused { - return 0, nil, "", nil - } - if s.isOpenAIAccountRuntimeBlocked(latest) { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return 0, nil, "", nil - } - account = latest - } - if requireCompact && openAICompactSupportTier(account) == 0 { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return 0, nil, "", nil - } - return accountID, account, responseID, store -} - -func classifyOpenAIWSAcquireError(err error) string { - if err == nil { - return "acquire_conn" - } - var dialErr *openAIWSDialError - if errors.As(err, &dialErr) { - switch dialErr.StatusCode { - case 426: - return "upgrade_required" - case 401, 403: - return "auth_failed" - case 429: - return "upstream_rate_limited" - } - if dialErr.StatusCode >= 500 { - return "upstream_5xx" - } - return "dial_failed" - } - if errors.Is(err, errOpenAIWSConnQueueFull) { - return "conn_queue_full" - } - if errors.Is(err, errOpenAIWSPreferredConnUnavailable) { - return "preferred_conn_unavailable" - } - if errors.Is(err, context.DeadlineExceeded) { - return "acquire_timeout" - } - return "acquire_conn" -} - -func isOpenAIWSRateLimitError(codeRaw, errTypeRaw, msgRaw string) bool { - code := strings.ToLower(strings.TrimSpace(codeRaw)) - errType := strings.ToLower(strings.TrimSpace(errTypeRaw)) - msg := strings.ToLower(strings.TrimSpace(msgRaw)) - - if strings.Contains(errType, "rate_limit") || strings.Contains(errType, "usage_limit") { - return true - } - if strings.Contains(code, "rate_limit") || strings.Contains(code, "usage_limit") || strings.Contains(code, "insufficient_quota") { - return true - } - if strings.Contains(msg, "usage limit") && strings.Contains(msg, "reached") { - return true - } - if strings.Contains(msg, "rate limit") && (strings.Contains(msg, "reached") || strings.Contains(msg, "exceeded")) { - return true - } - return false -} - -func (s *OpenAIGatewayService) persistOpenAIWSRateLimitSignal(ctx context.Context, account *Account, headers http.Header, responseBody []byte, codeRaw, errTypeRaw, msgRaw string) { - if s == nil || s.rateLimitService == nil || account == nil || account.Platform != PlatformOpenAI { - return - } - if !isOpenAIWSRateLimitError(codeRaw, errTypeRaw, msgRaw) { - return - } - s.handleOpenAIAccountUpstreamError(ctx, account, http.StatusTooManyRequests, headers, responseBody) -} - -func classifyOpenAIWSErrorEventFromRaw(codeRaw, errTypeRaw, msgRaw string) (string, bool) { - code := strings.ToLower(strings.TrimSpace(codeRaw)) - errType := strings.ToLower(strings.TrimSpace(errTypeRaw)) - msg := strings.ToLower(strings.TrimSpace(msgRaw)) - - switch code { - case "upgrade_required": - return "upgrade_required", true - case "websocket_not_supported", "websocket_unsupported": - return "ws_unsupported", true - case "websocket_connection_limit_reached": - return "ws_connection_limit_reached", true - case "invalid_encrypted_content": - return "invalid_encrypted_content", true - case "previous_response_not_found": - return "previous_response_not_found", true - } - if isOpenAIWSRateLimitError(codeRaw, errTypeRaw, msgRaw) { - return "upstream_rate_limited", false - } - if strings.Contains(msg, "upgrade required") || strings.Contains(msg, "status 426") { - return "upgrade_required", true - } - if strings.Contains(errType, "upgrade") { - return "upgrade_required", true - } - if strings.Contains(msg, "websocket") && strings.Contains(msg, "unsupported") { - return "ws_unsupported", true - } - if strings.Contains(msg, "connection limit") && strings.Contains(msg, "websocket") { - return "ws_connection_limit_reached", true - } - if strings.Contains(msg, "invalid_encrypted_content") || - (strings.Contains(msg, "encrypted content") && strings.Contains(msg, "could not be verified")) { - return "invalid_encrypted_content", true - } - if strings.Contains(msg, "previous_response_not_found") || - (strings.Contains(msg, "previous response") && strings.Contains(msg, "not found")) { - return "previous_response_not_found", true - } - if strings.Contains(errType, "server_error") || strings.Contains(code, "server_error") { - return "upstream_error_event", true - } - return "event_error", false -} - -func classifyOpenAIWSErrorEvent(message []byte) (string, bool) { - if len(message) == 0 { - return "event_error", false - } - return classifyOpenAIWSErrorEventFromRaw(parseOpenAIWSErrorEventFields(message)) -} - -func openAIWSErrorHTTPStatusFromRaw(codeRaw, errTypeRaw string) int { - code := strings.ToLower(strings.TrimSpace(codeRaw)) - errType := strings.ToLower(strings.TrimSpace(errTypeRaw)) - switch { - case strings.Contains(errType, "invalid_request"), - strings.Contains(code, "invalid_request"), - strings.Contains(code, "bad_request"), - code == "invalid_encrypted_content", - code == "previous_response_not_found": - return http.StatusBadRequest - case strings.Contains(errType, "authentication"), - strings.Contains(code, "invalid_api_key"), - strings.Contains(code, "unauthorized"): - return http.StatusUnauthorized - case strings.Contains(errType, "permission"), - strings.Contains(code, "forbidden"): - return http.StatusForbidden - case isOpenAIWSRateLimitError(codeRaw, errTypeRaw, ""): - return http.StatusTooManyRequests - default: - return http.StatusBadGateway - } -} - -func openAIWSErrorHTTPStatus(message []byte) int { - if len(message) == 0 { - return http.StatusBadGateway - } - codeRaw, errTypeRaw, _ := parseOpenAIWSErrorEventFields(message) - return openAIWSErrorHTTPStatusFromRaw(codeRaw, errTypeRaw) -} - -func (s *OpenAIGatewayService) openAIWSFallbackCooldown() time.Duration { - if s == nil || s.cfg == nil { - return 30 * time.Second - } - seconds := s.cfg.Gateway.OpenAIWS.FallbackCooldownSeconds - if seconds <= 0 { - return 0 - } - return time.Duration(seconds) * time.Second -} - -func (s *OpenAIGatewayService) isOpenAIWSFallbackCooling(accountID int64) bool { - if s == nil || accountID <= 0 { - return false - } - cooldown := s.openAIWSFallbackCooldown() - if cooldown <= 0 { - return false - } - rawUntil, ok := s.openaiWSFallbackUntil.Load(accountID) - if !ok || rawUntil == nil { - return false - } - until, ok := rawUntil.(time.Time) - if !ok || until.IsZero() { - s.openaiWSFallbackUntil.Delete(accountID) - return false - } - if time.Now().Before(until) { - return true - } - s.openaiWSFallbackUntil.Delete(accountID) - return false -} - -func (s *OpenAIGatewayService) markOpenAIWSFallbackCooling(accountID int64, _ string) { - if s == nil || accountID <= 0 { - return - } - cooldown := s.openAIWSFallbackCooldown() - if cooldown <= 0 { - return - } - s.openaiWSFallbackUntil.Store(accountID, time.Now().Add(cooldown)) -} - -func (s *OpenAIGatewayService) clearOpenAIWSFallbackCooling(accountID int64) { - if s == nil || accountID <= 0 { - return - } - s.openaiWSFallbackUntil.Delete(accountID) -} diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go new file mode 100644 index 0000000000..5fb9395461 --- /dev/null +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -0,0 +1,1579 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + coderws "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( + ctx context.Context, + c *gin.Context, + clientConn *coderws.Conn, + account *Account, + token string, + firstClientMessage []byte, + hooks *OpenAIWSIngressHooks, +) error { + if s == nil { + return errors.New("service is nil") + } + if c == nil { + return errors.New("gin context is nil") + } + if clientConn == nil { + return errors.New("client websocket is nil") + } + if account == nil { + return errors.New("account is nil") + } + if strings.TrimSpace(token) == "" { + return errors.New("token is empty") + } + + // 预取一次 OpenAI Fast Policy settings,绑定到 ctx,让该 WS session + // 内所有帧的 evaluateOpenAIFastPolicy 调用复用同一份快照,避免每帧 + // 进入 DB / settingRepo。Trade-off 见 withOpenAIFastPolicyContext 注释。 + if s.settingService != nil { + if settings, err := s.settingService.GetOpenAIFastPolicySettings(ctx); err == nil && settings != nil { + ctx = withOpenAIFastPolicyContext(ctx, settings) + } + } + + wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) + forceHTTPBridge := account.Platform == PlatformGrok + modeRouterV2Enabled := s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled + ingressMode := OpenAIWSIngressModeCtxPool + if modeRouterV2Enabled && !forceHTTPBridge { + ingressMode = account.ResolveOpenAIResponsesWebSocketV2Mode(s.cfg.Gateway.OpenAIWS.IngressModeDefault) + if ingressMode == OpenAIWSIngressModeOff { + return NewOpenAIWSClientCloseError( + coderws.StatusPolicyViolation, + "websocket mode is disabled for this account", + nil, + ) + } + switch ingressMode { + case OpenAIWSIngressModePassthrough: + if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { + return fmt.Errorf("websocket ingress requires ws_v2 transport, got=%s", wsDecision.Transport) + } + return s.proxyResponsesWebSocketV2Passthrough( + ctx, + c, + clientConn, + account, + token, + firstClientMessage, + hooks, + wsDecision, + ) + case OpenAIWSIngressModeHTTPBridge: + forceHTTPBridge = true + case OpenAIWSIngressModeCtxPool, OpenAIWSIngressModeShared, OpenAIWSIngressModeDedicated: + // continue + default: + return NewOpenAIWSClientCloseError( + coderws.StatusPolicyViolation, + "websocket mode only supports ctx_pool/passthrough/http_bridge", + nil, + ) + } + } + if !forceHTTPBridge && wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { + return fmt.Errorf("websocket ingress requires ws_v2 transport, got=%s", wsDecision.Transport) + } + dedicatedMode := modeRouterV2Enabled && ingressMode == OpenAIWSIngressModeDedicated + + wsURL := "" + wsHost := "-" + wsPath := "-" + if forceHTTPBridge { + wsHost = "xai-http-bridge" + wsPath = "/v1/responses" + } else { + var err error + wsURL, err = s.buildOpenAIResponsesWSURL(account) + if err != nil { + return fmt.Errorf("build ws url: %w", err) + } + if parsedURL, parseErr := url.Parse(wsURL); parseErr == nil && parsedURL != nil { + wsHost = normalizeOpenAIWSLogValue(parsedURL.Host) + wsPath = normalizeOpenAIWSLogValue(parsedURL.Path) + } + } + debugEnabled := isOpenAIWSModeDebugEnabled() + isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI) + + type openAIWSClientPayload struct { + payloadRaw []byte + rawForHash []byte + promptCacheKey string + previousResponseID string + originalModel string + imageBillingModel string + imageSizeTier string + imageInputSize string + payloadBytes int + } + ingressSessionOriginalModel := "" + + applyPayloadMutation := func(current []byte, path string, value any) ([]byte, error) { + next, err := sjson.SetBytes(current, path, value) + if err == nil { + return next, nil + } + + // 仅在确实需要修改 payload 且 sjson 失败时,退回 map 路径确保兼容性。 + payload := make(map[string]any) + if unmarshalErr := json.Unmarshal(current, &payload); unmarshalErr != nil { + return nil, err + } + switch path { + case "type", "model": + payload[path] = value + case "client_metadata." + openAIWSTurnMetadataHeader: + setOpenAIWSTurnMetadata(payload, fmt.Sprintf("%v", value)) + default: + return nil, err + } + rebuilt, marshalErr := json.Marshal(payload) + if marshalErr != nil { + return nil, marshalErr + } + return rebuilt, nil + } + + parseClientPayload := func(raw []byte) (openAIWSClientPayload, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "empty websocket request payload", nil) + } + if !gjson.ValidBytes(trimmed) { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", errors.New("invalid json")) + } + + values := gjson.GetManyBytes(trimmed, "type", "model", "prompt_cache_key", "previous_response_id") + eventType := strings.TrimSpace(values[0].String()) + normalized := trimmed + switch eventType { + case "": + eventType = "response.create" + next, setErr := applyPayloadMutation(normalized, "type", eventType) + if setErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", setErr) + } + normalized = next + case "response.create": + case "response.append": + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( + coderws.StatusPolicyViolation, + "response.append is not supported in ws v2; use response.create with previous_response_id", + nil, + ) + default: + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( + coderws.StatusPolicyViolation, + fmt.Sprintf("unsupported websocket request type: %s", eventType), + nil, + ) + } + + originalModel := strings.TrimSpace(values[1].String()) + modelMissing := originalModel == "" + if originalModel == "" { + // 入站 WS 长会话里,部分客户端只在第一轮 response.create 上声明 + // model,后续 turn 复用同一 session-level model。为避免因省略 + // model 直接断开用户连接,这里回落到上一轮已通过校验的客户端模型, + // 并在下方写回上游 payload,保证账号模型映射/fast policy/图片权限 + // 仍按同一模型执行。 + originalModel = ingressSessionOriginalModel + if originalModel == "" { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( + coderws.StatusPolicyViolation, + "model is required in response.create payload", + nil, + ) + } + } + promptCacheKey := strings.TrimSpace(values[2].String()) + previousResponseID := strings.TrimSpace(values[3].String()) + previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID) + if previousResponseID != "" && previousResponseIDKind == OpenAIPreviousResponseIDKindMessageID { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( + coderws.StatusPolicyViolation, + "previous_response_id must be a response.id (resp_*), not a message id", + nil, + ) + } + if turnMetadata := strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)); turnMetadata != "" { + next, setErr := applyPayloadMutation(normalized, "client_metadata."+openAIWSTurnMetadataHeader, turnMetadata) + if setErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", setErr) + } + normalized = next + } + apiKey := getAPIKeyFromContext(c) + imageGenerationAllowed := GroupAllowsImageGeneration(apiKeyGroup(apiKey)) + codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow + if isCodexCLI { + codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy() + } + codexBridgeEnabled := isCodexCLI && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) + if codexBridgeEnabled { + payloadMap := make(map[string]any) + if err := json.Unmarshal(normalized, &payloadMap); err != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", err) + } + bridgeModified := false + if ensureOpenAIResponsesImageGenerationTool(payloadMap) { + bridgeModified = true + logOpenAIWSModeInfo("ingress_ws_codex_image_tool_injected account_id=%d", account.ID) + } + if ensureOpenAIResponsesImageGenerationToolChoiceAuto(payloadMap) { + bridgeModified = true + logOpenAIWSModeInfo("ingress_ws_codex_image_tool_choice_auto account_id=%d", account.ID) + } + if normalizeOpenAIResponsesImageGenerationTools(payloadMap) { + bridgeModified = true + } + if applyCodexImageGenerationBridgeInstructions(payloadMap) { + bridgeModified = true + logOpenAIWSModeInfo("ingress_ws_codex_image_bridge_instructions_added account_id=%d", account.ID) + } + if bridgeModified { + rebuilt, marshalErr := json.Marshal(payloadMap) + if marshalErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", marshalErr) + } + normalized = rebuilt + } + } + upstreamModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) + if modelMissing || upstreamModel != originalModel { + next, setErr := applyPayloadMutation(normalized, "model", upstreamModel) + if setErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", setErr) + } + normalized = next + } + if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { + if stripped, changed, stripErr := stripOpenAIImageGenerationToolFromRawPayload(normalized); stripErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", stripErr) + } else if changed { + normalized = stripped + logOpenAIWSModeInfo("ingress_ws_codex_image_tool_stripped_by_policy account_id=%d", account.ID) + } + } + if stripped, changed, stripErr := stripCodexSparkImageGenerationToolFromRawPayload(normalized, upstreamModel); stripErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", stripErr) + } else if changed { + normalized = stripped + logOpenAIWSModeInfo("ingress_ws_codex_spark_image_tool_stripped account_id=%d", account.ID) + } + imageIntent := IsImageGenerationIntent(openAIResponsesEndpoint, originalModel, normalized) + if imageIntent && !imageGenerationAllowed { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, ImageGenerationPermissionMessage(), nil) + } + imageBillingModel := "" + imageSizeTier := "" + imageInputSize := "" + if imageIntent { + var imageCfgErr error + imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(normalized, originalModel) + if imageCfgErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, imageCfgErr.Error(), imageCfgErr) + } + imageBillingModel = imageCfg.Model + imageSizeTier = imageCfg.SizeTier + imageInputSize = imageCfg.InputSize + } + + // Apply OpenAI Fast Policy on the response.create frame using the same + // evaluator/normalize/scope rules as the HTTP entrypoints. This is the + // single integration point for all WS ingress turns (first + follow-up + // frames flow through here). + // + // Model fallback: first turn still requires model at the handler layer; + // follow-up response.create frames may omit it and then reuse + // ingressSessionOriginalModel. We always write a concrete upstream model + // before evaluating policy, so whitelist / filter behavior remains stable. + policyApplied, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, upstreamModel, normalized) + if policyErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", policyErr) + } + if blocked != nil { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) + // Send a Realtime-style error event to the client first, then + // signal the handler to close the connection with PolicyViolation. + // We intentionally do NOT forward this frame upstream. + // + // coder/websocket@v1.8.14 Conn.Write is synchronous and flushes + // the underlying bufio writer before returning (write.go:42 → + // 307-311), and the subsequent close handshake re-acquires the + // same writeFrameMu, so the error event is guaranteed to reach + // the kernel send buffer before any close frame is queued. + eventBytes := buildOpenAIFastPolicyBlockedWSEvent(blocked) + if eventBytes != nil { + writeCtx, cancel := context.WithTimeout(ctx, s.openAIWSWriteTimeout()) + _ = clientConn.Write(writeCtx, coderws.MessageText, eventBytes) + cancel() + } + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( + coderws.StatusPolicyViolation, + blocked.Message, + blocked, + ) + } + normalized = policyApplied + ingressSessionOriginalModel = originalModel + + return openAIWSClientPayload{ + payloadRaw: normalized, + rawForHash: trimmed, + promptCacheKey: promptCacheKey, + previousResponseID: previousResponseID, + originalModel: originalModel, + imageBillingModel: imageBillingModel, + imageSizeTier: imageSizeTier, + imageInputSize: imageInputSize, + payloadBytes: len(normalized), + }, nil + } + + writeClientMessage := func(message []byte) error { + writeCtx, cancel := context.WithTimeout(ctx, s.openAIWSWriteTimeout()) + defer cancel() + return clientConn.Write(writeCtx, coderws.MessageText, message) + } + + readClientMessage := func() ([]byte, error) { + msgType, payload, readErr := clientConn.Read(ctx) + if readErr != nil { + return nil, readErr + } + if msgType != coderws.MessageText && msgType != coderws.MessageBinary { + return nil, NewOpenAIWSClientCloseError( + coderws.StatusPolicyViolation, + fmt.Sprintf("unsupported websocket client message type: %s", msgType.String()), + nil, + ) + } + return payload, nil + } + + firstPayload, err := parseClientPayload(firstClientMessage) + if err != nil { + return err + } + + turnState := strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) + stateStore := s.getOpenAIWSStateStore() + groupID := getOpenAIGroupIDFromContext(c) + storeDisabledConnMode := s.openAIWSStoreDisabledConnMode() + sessionHash := "" + preferredConnID := "" + storeDisabled := false + refreshIngressRouteState := func(payload openAIWSClientPayload) { + sessionHash = s.GenerateSessionHash(c, payload.rawForHash) + if turnState == "" && stateStore != nil && sessionHash != "" { + if savedTurnState, ok := stateStore.GetSessionTurnState(groupID, sessionHash); ok { + turnState = savedTurnState + } + } + + preferredConnID = "" + if stateStore != nil && payload.previousResponseID != "" { + if connID, ok := stateStore.GetResponseConn(payload.previousResponseID); ok { + preferredConnID = connID + } + } + + storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(payload.payloadRaw, account) + if stateStore != nil && storeDisabled && payload.previousResponseID == "" && sessionHash != "" { + if connID, ok := stateStore.GetSessionConn(groupID, sessionHash); ok { + preferredConnID = connID + } + } + } + refreshIngressRouteState(firstPayload) + + if forceHTTPBridge || s.shouldBridgeOpenAIWSHTTP(account, firstPayload.payloadBytes, firstPayload.previousResponseID) { + logOpenAIWSModeInfo( + "ingress_ws_http_bridge_start account_id=%d account_type=%s payload_bytes=%d threshold_bytes=%d has_session_hash=%v store_disabled=%v", + account.ID, + account.Type, + firstPayload.payloadBytes, + s.openAIWSHTTPBridgeThresholdBytes(), + sessionHash != "", + storeDisabled, + ) + currentBridgePayload := firstPayload + var bridgeReplayInput []json.RawMessage + bridgeReplayInputExists := false + for turn := 1; ; turn++ { + if turn > 1 && hooks != nil && hooks.BeforeRequest != nil { + if err := hooks.BeforeRequest(turn, currentBridgePayload.payloadRaw, currentBridgePayload.originalModel); err != nil { + return err + } + } + if hooks != nil && hooks.BeforeTurn != nil { + if err := hooks.BeforeTurn(turn); err != nil { + return err + } + } + if turnState != "" && c != nil && c.Request != nil { + c.Request.Header.Set(openAIWSTurnStateHeader, turnState) + } + bridgePayloadRaw := currentBridgePayload.payloadRaw + bridgePayloadBytes := currentBridgePayload.payloadBytes + needsBridgeReplay := currentBridgePayload.previousResponseID != "" || openAIWSRawPayloadHasToolCallOutput(currentBridgePayload.payloadRaw) + turnReplayInput, turnReplayInputExists, replayInputErr := buildOpenAIWSReplayInputSequence( + bridgeReplayInput, + bridgeReplayInputExists, + currentBridgePayload.payloadRaw, + needsBridgeReplay, + ) + if replayInputErr != nil { + return fmt.Errorf("build websocket http bridge replay input: %w", replayInputErr) + } + if needsBridgeReplay && turnReplayInputExists { + updatedPayload, setInputErr := setOpenAIWSPayloadInputSequence( + currentBridgePayload.payloadRaw, + turnReplayInput, + true, + ) + if setInputErr != nil { + return fmt.Errorf("set websocket http bridge replay input: %w", setInputErr) + } + bridgePayloadRaw = updatedPayload + bridgePayloadBytes = len(updatedPayload) + logOpenAIWSModeInfo( + "ingress_ws_http_bridge_replay_input account_id=%d turn=%d input_items=%d previous_response_id_present=%v has_tool_output=%v", + account.ID, + turn, + len(turnReplayInput), + currentBridgePayload.previousResponseID != "", + openAIWSRawPayloadHasToolCallOutput(currentBridgePayload.payloadRaw), + ) + } + result, bridgeErr := s.proxyOpenAIWSHTTPBridgeTurn( + ctx, + c, + account, + token, + bridgePayloadRaw, + bridgePayloadBytes, + currentBridgePayload.originalModel, + currentBridgePayload.imageBillingModel, + currentBridgePayload.imageSizeTier, + currentBridgePayload.imageInputSize, + turn, + writeClientMessage, + ) + if hooks != nil && hooks.AfterTurn != nil { + hooks.AfterTurn(turn, result, bridgeErr) + } + if bridgeErr != nil { + return bridgeErr + } + if result == nil { + return errors.New("websocket http bridge turn result is nil") + } + bridgeReplayInput = cloneOpenAIWSRawMessages(turnReplayInput) + bridgeReplayInputExists = turnReplayInputExists + if result.wsReplayInputExists { + bridgeReplayInput = append(bridgeReplayInput, cloneOpenAIWSRawMessages(result.wsReplayInput)...) + bridgeReplayInputExists = true + } + if bridgeTurnState := strings.TrimSpace(result.ResponseHeaders.Get(openAIWSTurnStateHeader)); bridgeTurnState != "" { + turnState = bridgeTurnState + if stateStore != nil && sessionHash != "" { + stateStore.BindSessionTurnState(groupID, sessionHash, bridgeTurnState, s.openAIWSSessionStickyTTL()) + } + } + responseID := strings.TrimSpace(result.RequestID) + if responseID != "" && stateStore != nil { + ttl := s.openAIWSResponseStickyTTL() + logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, stateStore.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) + } + nextClientMessage, readErr := readClientMessage() + if readErr != nil { + if isOpenAIWSClientDisconnectError(readErr) { + closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr) + logOpenAIWSModeInfo( + "ingress_ws_http_bridge_client_closed account_id=%d close_status=%s close_reason=%s", + account.ID, + closeStatus, + truncateOpenAIWSLogValue(closeReason, openAIWSHeaderValueMaxLen), + ) + return nil + } + return fmt.Errorf("read client websocket request: %w", readErr) + } + nextPayload, parseErr := parseClientPayload(nextClientMessage) + if parseErr != nil { + return parseErr + } + currentBridgePayload = nextPayload + } + } + + wsHeaders, _, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), firstPayload.promptCacheKey) + if buildHdrErr != nil { + return fmt.Errorf("build ws headers: %w", buildHdrErr) + } + baseAcquireReq := openAIWSAcquireRequest{ + Account: account, + WSURL: wsURL, + Headers: wsHeaders, + ProxyURL: func() string { + if account.ProxyID != nil && account.Proxy != nil { + return account.Proxy.URL() + } + return "" + }(), + ForceNewConn: false, + } + pool := s.getOpenAIWSConnPool() + if pool == nil { + return errors.New("openai ws conn pool is nil") + } + + logOpenAIWSModeInfo( + "ingress_ws_protocol_confirm account_id=%d account_type=%s transport=%s ws_host=%s ws_path=%s ws_mode=%s store_disabled=%v has_session_hash=%v has_previous_response_id=%v", + account.ID, + account.Type, + normalizeOpenAIWSLogValue(string(wsDecision.Transport)), + wsHost, + wsPath, + normalizeOpenAIWSLogValue(ingressMode), + storeDisabled, + sessionHash != "", + firstPayload.previousResponseID != "", + ) + + if debugEnabled { + logOpenAIWSModeDebug( + "ingress_ws_start account_id=%d account_type=%s transport=%s ws_host=%s preferred_conn_id=%s has_session_hash=%v has_previous_response_id=%v store_disabled=%v", + account.ID, + account.Type, + normalizeOpenAIWSLogValue(string(wsDecision.Transport)), + wsHost, + truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen), + sessionHash != "", + firstPayload.previousResponseID != "", + storeDisabled, + ) + } + if firstPayload.previousResponseID != "" { + firstPreviousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(firstPayload.previousResponseID) + logOpenAIWSModeInfo( + "ingress_ws_continuation_probe account_id=%d turn=%d previous_response_id=%s previous_response_id_kind=%s preferred_conn_id=%s session_hash=%s header_session_id=%s header_conversation_id=%s has_turn_state=%v turn_state_len=%d has_prompt_cache_key=%v store_disabled=%v", + account.ID, + 1, + truncateOpenAIWSLogValue(firstPayload.previousResponseID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(firstPreviousResponseIDKind), + truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(sessionHash, 12), + openAIWSHeaderValueForLog(baseAcquireReq.Headers, "session_id"), + openAIWSHeaderValueForLog(baseAcquireReq.Headers, "conversation_id"), + turnState != "", + len(turnState), + firstPayload.promptCacheKey != "", + storeDisabled, + ) + } + + acquireTimeout := s.openAIWSAcquireTimeout() + if acquireTimeout <= 0 { + acquireTimeout = 30 * time.Second + } + + acquireTurnLease := func(turn int, preferred string, forcePreferredConn bool) (*openAIWSConnLease, error) { + req := cloneOpenAIWSAcquireRequest(baseAcquireReq) + req.PreferredConnID = strings.TrimSpace(preferred) + req.ForcePreferredConn = forcePreferredConn + // dedicated 模式下每次获取均新建连接,避免跨会话复用残留上下文。 + req.ForceNewConn = dedicatedMode + acquireCtx, acquireCancel := context.WithTimeout(ctx, acquireTimeout) + lease, acquireErr := pool.Acquire(acquireCtx, req) + acquireCancel() + if acquireErr != nil { + dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(acquireErr) + logOpenAIWSModeInfo( + "ingress_ws_upstream_acquire_fail account_id=%d turn=%d reason=%s dial_status=%d dial_class=%s dial_close_status=%s dial_close_reason=%s dial_resp_server=%s dial_resp_via=%s dial_resp_cf_ray=%s dial_resp_x_request_id=%s cause=%s preferred_conn_id=%s force_preferred_conn=%v ws_host=%s ws_path=%s proxy_enabled=%v", + account.ID, + turn, + normalizeOpenAIWSLogValue(classifyOpenAIWSAcquireError(acquireErr)), + dialStatus, + dialClass, + dialCloseStatus, + truncateOpenAIWSLogValue(dialCloseReason, openAIWSHeaderValueMaxLen), + dialRespServer, + dialRespVia, + dialRespCFRay, + dialRespReqID, + truncateOpenAIWSLogValue(acquireErr.Error(), openAIWSLogValueMaxLen), + truncateOpenAIWSLogValue(preferred, openAIWSIDValueMaxLen), + forcePreferredConn, + wsHost, + wsPath, + account.ProxyID != nil && account.Proxy != nil, + ) + var dialErr *openAIWSDialError + if errors.As(acquireErr, &dialErr) && dialErr != nil && dialErr.StatusCode == http.StatusTooManyRequests { + s.persistOpenAIWSRateLimitSignal(ctx, account, dialErr.ResponseHeaders, nil, "rate_limit_exceeded", "rate_limit_error", strings.TrimSpace(acquireErr.Error())) + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusTooManyRequests, + ResponseHeaders: cloneHeader(dialErr.ResponseHeaders), + } + } + if errors.Is(acquireErr, errOpenAIWSPreferredConnUnavailable) { + return nil, NewOpenAIWSClientCloseError( + coderws.StatusPolicyViolation, + "upstream continuation connection is unavailable; please restart the conversation", + acquireErr, + ) + } + if errors.Is(acquireErr, context.DeadlineExceeded) || errors.Is(acquireErr, errOpenAIWSConnQueueFull) { + return nil, NewOpenAIWSClientCloseError( + coderws.StatusTryAgainLater, + "upstream websocket is busy, please retry later", + acquireErr, + ) + } + return nil, acquireErr + } + connID := strings.TrimSpace(lease.ConnID()) + if handshakeTurnState := strings.TrimSpace(lease.HandshakeHeader(openAIWSTurnStateHeader)); handshakeTurnState != "" { + turnState = handshakeTurnState + if stateStore != nil && sessionHash != "" { + stateStore.BindSessionTurnState(groupID, sessionHash, handshakeTurnState, s.openAIWSSessionStickyTTL()) + } + updatedHeaders := cloneHeader(baseAcquireReq.Headers) + if updatedHeaders == nil { + updatedHeaders = make(http.Header) + } + updatedHeaders.Set(openAIWSTurnStateHeader, handshakeTurnState) + baseAcquireReq.Headers = updatedHeaders + } + logOpenAIWSModeInfo( + "ingress_ws_upstream_connected account_id=%d turn=%d conn_id=%s conn_reused=%v conn_pick_ms=%d queue_wait_ms=%d preferred_conn_id=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), + lease.Reused(), + lease.ConnPickDuration().Milliseconds(), + lease.QueueWaitDuration().Milliseconds(), + truncateOpenAIWSLogValue(preferred, openAIWSIDValueMaxLen), + ) + return lease, nil + } + + sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string) (*OpenAIForwardResult, error) { + if lease == nil { + return nil, errors.New("upstream websocket lease is nil") + } + turnStart := time.Now() + wroteDownstream := false + if err := lease.WriteJSONWithContextTimeout(ctx, json.RawMessage(payload), s.openAIWSWriteTimeout()); err != nil { + return nil, wrapOpenAIWSIngressTurnError( + "write_upstream", + fmt.Errorf("write upstream websocket request: %w", err), + false, + ) + } + if debugEnabled { + logOpenAIWSModeDebug( + "ingress_ws_turn_request_sent account_id=%d turn=%d conn_id=%s payload_bytes=%d", + account.ID, + turn, + truncateOpenAIWSLogValue(lease.ConnID(), openAIWSIDValueMaxLen), + payloadBytes, + ) + } + + responseID := "" + usage := OpenAIUsage{} + imageCounter := newOpenAIImageOutputCounter() + var firstTokenMs *int + reqStream := openAIWSPayloadBoolFromRaw(payload, "stream", true) + turnPreviousResponseID := openAIWSPayloadStringFromRaw(payload, "previous_response_id") + turnPreviousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(turnPreviousResponseID) + turnPromptCacheKey := openAIWSPayloadStringFromRaw(payload, "prompt_cache_key") + turnStoreDisabled := s.isOpenAIWSStoreDisabledInRequestRaw(payload, account) + turnHasFunctionCallOutput := openAIWSRawPayloadHasToolCallOutput(payload) + eventCount := 0 + tokenEventCount := 0 + terminalEventCount := 0 + replayCollector := &openAIWSToolCallReplayCollector{} + firstEventType := "" + lastEventType := "" + needModelReplace := false + clientDisconnected := false + mappedModel := "" + var mappedModelBytes []byte + if originalModel != "" { + mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) + needModelReplace = mappedModel != "" && mappedModel != originalModel + if needModelReplace { + mappedModelBytes = []byte(mappedModel) + } + } + for { + upstreamMessage, readErr := lease.ReadMessageWithContextTimeout(ctx, s.openAIWSReadTimeout()) + if readErr != nil { + lease.MarkBroken() + return nil, wrapOpenAIWSIngressTurnError( + "read_upstream", + fmt.Errorf("read upstream websocket event: %w", readErr), + wroteDownstream, + ) + } + + eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage) + if responseID == "" && eventResponseID != "" { + responseID = eventResponseID + } + if eventType != "" { + eventCount++ + if firstEventType == "" { + firstEventType = eventType + } + lastEventType = eventType + } + if eventType == "error" { + errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(upstreamMessage) + s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), upstreamMessage, errCodeRaw, errTypeRaw, errMsgRaw) + fallbackReason, _ := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) + errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) + recoverablePrevNotFound := fallbackReason == openAIWSIngressStagePreviousResponseNotFound && + turnPreviousResponseID != "" && + !turnHasFunctionCallOutput && + s.openAIWSIngressPreviousResponseRecoveryEnabled() && + !wroteDownstream + if recoverablePrevNotFound { + // 可恢复场景使用非 error 关键字日志,避免被 LegacyPrintf 误判为 ERROR 级别。 + logOpenAIWSModeInfo( + "ingress_ws_prev_response_recoverable account_id=%d turn=%d conn_id=%s idx=%d reason=%s code=%s type=%s message=%s previous_response_id=%s previous_response_id_kind=%s response_id=%s store_disabled=%v has_prompt_cache_key=%v", + account.ID, + turn, + truncateOpenAIWSLogValue(lease.ConnID(), openAIWSIDValueMaxLen), + eventCount, + truncateOpenAIWSLogValue(fallbackReason, openAIWSLogValueMaxLen), + errCode, + errType, + errMessage, + truncateOpenAIWSLogValue(turnPreviousResponseID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(turnPreviousResponseIDKind), + truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen), + turnStoreDisabled, + turnPromptCacheKey != "", + ) + } else { + logOpenAIWSModeInfo( + "ingress_ws_error_event account_id=%d turn=%d conn_id=%s idx=%d fallback_reason=%s err_code=%s err_type=%s err_message=%s previous_response_id=%s previous_response_id_kind=%s response_id=%s store_disabled=%v has_prompt_cache_key=%v", + account.ID, + turn, + truncateOpenAIWSLogValue(lease.ConnID(), openAIWSIDValueMaxLen), + eventCount, + truncateOpenAIWSLogValue(fallbackReason, openAIWSLogValueMaxLen), + errCode, + errType, + errMessage, + truncateOpenAIWSLogValue(turnPreviousResponseID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(turnPreviousResponseIDKind), + truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen), + turnStoreDisabled, + turnPromptCacheKey != "", + ) + } + // previous_response_not_found 在 ingress 模式支持单次恢复重试: + // 不把该 error 直接下发客户端,而是由上层去掉 previous_response_id 后重放当前 turn。 + if recoverablePrevNotFound { + lease.MarkBroken() + errMsg := strings.TrimSpace(errMsgRaw) + if errMsg == "" { + errMsg = "previous response not found" + } + return nil, wrapOpenAIWSIngressTurnError( + openAIWSIngressStagePreviousResponseNotFound, + errors.New(errMsg), + false, + ) + } + if !wroteDownstream && isOpenAIWSRateLimitError(errCodeRaw, errTypeRaw, errMsgRaw) { + lease.MarkBroken() + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusTooManyRequests, + ResponseBody: append([]byte(nil), upstreamMessage...), + ResponseHeaders: cloneHeader(lease.HandshakeHeaders()), + } + } + } + isTokenEvent := isOpenAIWSTokenEvent(eventType) + if isTokenEvent { + tokenEventCount++ + } + isTerminalEvent := isOpenAIWSTerminalEvent(eventType) + if isTerminalEvent { + terminalEventCount++ + } + if firstTokenMs == nil && isTokenEvent { + ms := int(time.Since(turnStart).Milliseconds()) + firstTokenMs = &ms + } + if openAIWSEventShouldParseUsage(eventType) { + parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage) + } + imageCounter.AddSSEData(upstreamMessage) + + if eventType == "response.failed" { + if hit, code, msg := detectOpenAICyberPolicy(upstreamMessage); hit { + MarkOpsCyberPolicy(c, CyberPolicyMark{ + Code: code, + Message: msg, + Body: truncateString(string(upstreamMessage), 4096), + UpstreamStatus: http.StatusOK, + UpstreamInTok: usage.InputTokens, + UpstreamOutTok: usage.OutputTokens, + }) + } + } + + if !clientDisconnected { + if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && bytes.Contains(upstreamMessage, mappedModelBytes) { + upstreamMessage = replaceOpenAIWSMessageModel(upstreamMessage, mappedModel, originalModel) + } + if openAIWSEventMayContainToolCalls(eventType) && openAIWSMessageLikelyContainsToolCalls(upstreamMessage) { + if corrected, changed := s.toolCorrector.CorrectToolCallsInSSEBytes(upstreamMessage); changed { + upstreamMessage = corrected + } + } + replayCollector.AddEvent(eventType, upstreamMessage) + if err := writeClientMessage(upstreamMessage); err != nil { + if isOpenAIWSClientDisconnectError(err) { + clientDisconnected = true + closeStatus, closeReason := summarizeOpenAIWSReadCloseError(err) + logOpenAIWSModeInfo( + "ingress_ws_client_disconnected_drain account_id=%d turn=%d conn_id=%s close_status=%s close_reason=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(lease.ConnID(), openAIWSIDValueMaxLen), + closeStatus, + truncateOpenAIWSLogValue(closeReason, openAIWSHeaderValueMaxLen), + ) + } else { + return nil, wrapOpenAIWSIngressTurnError( + "write_client", + fmt.Errorf("write client websocket event: %w", err), + wroteDownstream, + ) + } + } else { + wroteDownstream = true + } + } + if isTerminalEvent { + // 客户端已断连时,上游连接的 session 状态不可信,标记 broken 避免回池复用。 + if clientDisconnected { + lease.MarkBroken() + } + firstTokenMsValue := -1 + if firstTokenMs != nil { + firstTokenMsValue = *firstTokenMs + } + if debugEnabled { + logOpenAIWSModeDebug( + "ingress_ws_turn_completed account_id=%d turn=%d conn_id=%s response_id=%s duration_ms=%d events=%d token_events=%d terminal_events=%d first_event=%s last_event=%s first_token_ms=%d client_disconnected=%v", + account.ID, + turn, + truncateOpenAIWSLogValue(lease.ConnID(), openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen), + time.Since(turnStart).Milliseconds(), + eventCount, + tokenEventCount, + terminalEventCount, + truncateOpenAIWSLogValue(firstEventType, openAIWSLogValueMaxLen), + truncateOpenAIWSLogValue(lastEventType, openAIWSLogValueMaxLen), + firstTokenMsValue, + clientDisconnected, + ) + } + imageCount := imageCounter.Count() + result := &OpenAIForwardResult{ + RequestID: responseID, + Usage: usage, + Model: originalModel, + UpstreamModel: mappedModel, + ServiceTier: extractOpenAIServiceTierFromBody(payload), + ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, originalModel), payload, mappedModel), + Stream: reqStream, + OpenAIWSMode: true, + ResponseHeaders: lease.HandshakeHeaders(), + Duration: time.Since(turnStart), + FirstTokenMs: firstTokenMs, + } + if replayInput := replayCollector.Items(); len(replayInput) > 0 { + result.wsReplayInput = replayInput + result.wsReplayInputExists = true + } + if imageCount > 0 { + result.ImageCount = imageCount + result.ImageSize = imageSizeTier + result.ImageInputSize = imageInputSize + result.ImageOutputSizes = imageCounter.Sizes() + result.BillingModel = imageBillingModel + } + return result, nil + } + } + } + + currentPayload := firstPayload.payloadRaw + currentOriginalModel := firstPayload.originalModel + currentImageBillingModel := firstPayload.imageBillingModel + currentImageSizeTier := firstPayload.imageSizeTier + currentImageInputSize := firstPayload.imageInputSize + currentPayloadBytes := firstPayload.payloadBytes + isStrictAffinityTurn := func(payload []byte) bool { + if !storeDisabled { + return false + } + return strings.TrimSpace(openAIWSPayloadStringFromRaw(payload, "previous_response_id")) != "" + } + var sessionLease *openAIWSConnLease + sessionConnID := "" + pinnedSessionConnID := "" + unpinSessionConn := func(connID string) { + connID = strings.TrimSpace(connID) + if connID == "" || pinnedSessionConnID != connID { + return + } + pool.UnpinConn(account.ID, connID) + pinnedSessionConnID = "" + } + pinSessionConn := func(connID string) { + if !storeDisabled { + return + } + connID = strings.TrimSpace(connID) + if connID == "" || pinnedSessionConnID == connID { + return + } + if pinnedSessionConnID != "" { + pool.UnpinConn(account.ID, pinnedSessionConnID) + pinnedSessionConnID = "" + } + if pool.PinConn(account.ID, connID) { + pinnedSessionConnID = connID + } + } + // lastTurnClean 标记最后一轮 sendAndRelay 是否正常完成(收到终端事件且客户端未断连)。 + // 所有异常路径(读写错误、error 事件、客户端断连)已在各自分支或上层(L3403)中 MarkBroken, + // 因此 releaseSessionLease 中只需在非正常结束时 MarkBroken。 + lastTurnClean := false + releaseSessionLease := func() { + if sessionLease == nil { + return + } + if !lastTurnClean { + sessionLease.MarkBroken() + } + unpinSessionConn(sessionConnID) + sessionLease.Release() + if debugEnabled { + logOpenAIWSModeDebug( + "ingress_ws_upstream_released account_id=%d conn_id=%s", + account.ID, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + ) + } + } + defer releaseSessionLease() + + turn := 1 + turnRetry := 0 + turnPrevRecoveryTried := false + lastTurnFinishedAt := time.Time{} + lastTurnResponseID := "" + lastTurnPayload := []byte(nil) + var lastTurnStrictState *openAIWSIngressPreviousTurnStrictState + lastTurnReplayInput := []json.RawMessage(nil) + lastTurnReplayInputExists := false + currentTurnReplayInput := []json.RawMessage(nil) + currentTurnReplayInputExists := false + skipBeforeTurn := false + hasCurrentOrReplayFunctionCallOutput := func(payload []byte) bool { + if openAIWSRawPayloadHasToolCallOutput(payload) { + return true + } + return currentTurnReplayInputExists && openAIWSRawItemsHasFunctionCallOutput(currentTurnReplayInput) + } + resetSessionLease := func(markBroken bool) { + if sessionLease == nil { + return + } + if markBroken { + sessionLease.MarkBroken() + } + releaseSessionLease() + sessionLease = nil + sessionConnID = "" + preferredConnID = "" + } + recoverIngressPrevResponseNotFound := func(relayErr error, turn int, connID string) bool { + if !isOpenAIWSIngressPreviousResponseNotFound(relayErr) { + return false + } + if turnPrevRecoveryTried || !s.openAIWSIngressPreviousResponseRecoveryEnabled() { + return false + } + // 携带 function_call_output 的请求不能丢弃 previous_response_id: + // 上游 API 需要 response chain 来匹配 tool_result 与之前的 tool_use, + // 丢弃后会导致 "No tool call found for function call output" 400 错误。 + if hasCurrentOrReplayFunctionCallOutput(currentPayload) { + return false + } + if isStrictAffinityTurn(currentPayload) { + // Layer 2:严格亲和链路命中 previous_response_not_found 时,降级为“去掉 previous_response_id 后重放一次”。 + // 该错误说明续链锚点已失效,继续 strict fail-close 只会直接中断本轮请求。 + logOpenAIWSModeInfo( + "ingress_ws_prev_response_recovery_layer2 account_id=%d turn=%d conn_id=%s store_disabled_conn_mode=%s action=drop_previous_response_id_retry", + account.ID, + turn, + truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(storeDisabledConnMode), + ) + } + turnPrevRecoveryTried = true + updatedPayload, removed, dropErr := dropPreviousResponseIDFromRawPayload(currentPayload) + if dropErr != nil || !removed { + reason := "not_removed" + if dropErr != nil { + reason = "drop_error" + } + logOpenAIWSModeInfo( + "ingress_ws_prev_response_recovery_skip account_id=%d turn=%d conn_id=%s reason=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(reason), + ) + return false + } + updatedWithInput, setInputErr := setOpenAIWSPayloadInputSequence( + updatedPayload, + currentTurnReplayInput, + currentTurnReplayInputExists, + ) + if setInputErr != nil { + logOpenAIWSModeInfo( + "ingress_ws_prev_response_recovery_skip account_id=%d turn=%d conn_id=%s reason=set_full_input_error cause=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(setInputErr.Error(), openAIWSLogValueMaxLen), + ) + return false + } + logOpenAIWSModeInfo( + "ingress_ws_prev_response_recovery account_id=%d turn=%d conn_id=%s action=drop_previous_response_id retry=1", + account.ID, + turn, + truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), + ) + currentPayload = updatedWithInput + currentPayloadBytes = len(updatedWithInput) + resetSessionLease(true) + skipBeforeTurn = true + return true + } + retryIngressTurn := func(relayErr error, turn int, connID string) bool { + if !isOpenAIWSIngressTurnRetryable(relayErr) || turnRetry >= 1 { + return false + } + if isStrictAffinityTurn(currentPayload) { + logOpenAIWSModeInfo( + "ingress_ws_turn_retry_skip account_id=%d turn=%d conn_id=%s reason=strict_affinity", + account.ID, + turn, + truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), + ) + return false + } + turnRetry++ + logOpenAIWSModeInfo( + "ingress_ws_turn_retry account_id=%d turn=%d retry=%d reason=%s conn_id=%s", + account.ID, + turn, + turnRetry, + truncateOpenAIWSLogValue(openAIWSIngressTurnRetryReason(relayErr), openAIWSLogValueMaxLen), + truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), + ) + resetSessionLease(true) + skipBeforeTurn = true + return true + } + for { + if turn > 1 && !skipBeforeTurn && hooks != nil && hooks.BeforeRequest != nil { + if err := hooks.BeforeRequest(turn, currentPayload, currentOriginalModel); err != nil { + return err + } + } + if !skipBeforeTurn && hooks != nil && hooks.BeforeTurn != nil { + if err := hooks.BeforeTurn(turn); err != nil { + return err + } + } + skipBeforeTurn = false + currentPreviousResponseID := openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id") + expectedPrev := strings.TrimSpace(lastTurnResponseID) + toolSignals := ToolContinuationSignals{ + HasFunctionCallOutput: openAIWSRawPayloadHasToolCallOutput(currentPayload), + } + if toolSignals.HasFunctionCallOutput { + var currentReqBody map[string]any + if err := json.Unmarshal(currentPayload, ¤tReqBody); err == nil { + toolSignals = AnalyzeToolContinuationSignals(currentReqBody) + } + } + hasFunctionCallOutput := toolSignals.HasFunctionCallOutput + // store=false + function_call_output 场景必须有续链锚点。 + // 若客户端未传 previous_response_id,优先回填上一轮响应 ID,避免上游报 call_id 无法关联。 + if shouldInferIngressFunctionCallOutputPreviousResponseID( + storeDisabled, + turn, + toolSignals, + currentPreviousResponseID, + expectedPrev, + ) { + updatedPayload, setPrevErr := setPreviousResponseIDToRawPayload(currentPayload, expectedPrev) + if setPrevErr != nil { + logOpenAIWSModeInfo( + "ingress_ws_function_call_output_prev_infer_skip account_id=%d turn=%d conn_id=%s reason=set_previous_response_id_error cause=%s expected_previous_response_id=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(setPrevErr.Error(), openAIWSLogValueMaxLen), + truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), + ) + } else { + currentPayload = updatedPayload + currentPayloadBytes = len(updatedPayload) + currentPreviousResponseID = expectedPrev + logOpenAIWSModeInfo( + "ingress_ws_function_call_output_prev_infer account_id=%d turn=%d conn_id=%s action=set_previous_response_id previous_response_id=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), + ) + } + } + nextReplayInput, nextReplayInputExists, replayInputErr := buildOpenAIWSReplayInputSequence( + lastTurnReplayInput, + lastTurnReplayInputExists, + currentPayload, + currentPreviousResponseID != "", + ) + if replayInputErr != nil { + logOpenAIWSModeInfo( + "ingress_ws_replay_input_skip account_id=%d turn=%d conn_id=%s reason=build_error cause=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(replayInputErr.Error(), openAIWSLogValueMaxLen), + ) + currentTurnReplayInput = nil + currentTurnReplayInputExists = false + } else { + currentTurnReplayInput = nextReplayInput + currentTurnReplayInputExists = nextReplayInputExists + } + replayHasFunctionCallOutput := currentTurnReplayInputExists && + openAIWSRawItemsHasFunctionCallOutput(currentTurnReplayInput) + hasFunctionCallOutput = hasFunctionCallOutput || replayHasFunctionCallOutput + if storeDisabled && turn > 1 && currentPreviousResponseID != "" { + shouldKeepPreviousResponseID := false + strictReason := "" + var strictErr error + if lastTurnStrictState != nil { + shouldKeepPreviousResponseID, strictReason, strictErr = shouldKeepIngressPreviousResponseIDWithStrictState( + lastTurnStrictState, + currentPayload, + lastTurnResponseID, + hasFunctionCallOutput, + ) + } else { + shouldKeepPreviousResponseID, strictReason, strictErr = shouldKeepIngressPreviousResponseID( + lastTurnPayload, + currentPayload, + lastTurnResponseID, + hasFunctionCallOutput, + ) + } + if strictErr != nil { + logOpenAIWSModeInfo( + "ingress_ws_prev_response_strict_eval account_id=%d turn=%d conn_id=%s action=keep_previous_response_id reason=%s cause=%s previous_response_id=%s expected_previous_response_id=%s has_function_call_output=%v", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(strictReason), + truncateOpenAIWSLogValue(strictErr.Error(), openAIWSLogValueMaxLen), + truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), + hasFunctionCallOutput, + ) + } else if !shouldKeepPreviousResponseID { + updatedPayload, removed, dropErr := dropPreviousResponseIDFromRawPayload(currentPayload) + if dropErr != nil || !removed { + dropReason := "not_removed" + if dropErr != nil { + dropReason = "drop_error" + } + logOpenAIWSModeInfo( + "ingress_ws_prev_response_strict_eval account_id=%d turn=%d conn_id=%s action=keep_previous_response_id reason=%s drop_reason=%s previous_response_id=%s expected_previous_response_id=%s has_function_call_output=%v", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(strictReason), + normalizeOpenAIWSLogValue(dropReason), + truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), + hasFunctionCallOutput, + ) + } else { + updatedWithInput, setInputErr := setOpenAIWSPayloadInputSequence( + updatedPayload, + currentTurnReplayInput, + currentTurnReplayInputExists, + ) + if setInputErr != nil { + logOpenAIWSModeInfo( + "ingress_ws_prev_response_strict_eval account_id=%d turn=%d conn_id=%s action=keep_previous_response_id reason=%s drop_reason=set_full_input_error previous_response_id=%s expected_previous_response_id=%s cause=%s has_function_call_output=%v", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(strictReason), + truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(setInputErr.Error(), openAIWSLogValueMaxLen), + hasFunctionCallOutput, + ) + } else { + currentPayload = updatedWithInput + currentPayloadBytes = len(updatedWithInput) + logOpenAIWSModeInfo( + "ingress_ws_prev_response_strict_eval account_id=%d turn=%d conn_id=%s action=drop_previous_response_id_full_create reason=%s previous_response_id=%s expected_previous_response_id=%s has_function_call_output=%v", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(strictReason), + truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), + hasFunctionCallOutput, + ) + currentPreviousResponseID = "" + } + } + } + } + forcePreferredConn := isStrictAffinityTurn(currentPayload) + if sessionLease == nil { + acquiredLease, acquireErr := acquireTurnLease(turn, preferredConnID, forcePreferredConn) + if acquireErr != nil { + return fmt.Errorf("acquire upstream websocket: %w", acquireErr) + } + sessionLease = acquiredLease + sessionConnID = strings.TrimSpace(sessionLease.ConnID()) + if storeDisabled { + pinSessionConn(sessionConnID) + } else { + unpinSessionConn(sessionConnID) + } + } + shouldPreflightPing := turn > 1 && sessionLease != nil && turnRetry == 0 + if shouldPreflightPing && openAIWSIngressPreflightPingIdle > 0 && !lastTurnFinishedAt.IsZero() { + if time.Since(lastTurnFinishedAt) < openAIWSIngressPreflightPingIdle { + shouldPreflightPing = false + } + } + if shouldPreflightPing { + if pingErr := sessionLease.PingWithTimeout(openAIWSConnHealthCheckTO); pingErr != nil { + logOpenAIWSModeInfo( + "ingress_ws_upstream_preflight_ping_fail account_id=%d turn=%d conn_id=%s cause=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(pingErr.Error(), openAIWSLogValueMaxLen), + ) + if forcePreferredConn { + // 携带 function_call_output 的请求不能丢弃 previous_response_id: + // 上游 API 需要 response chain 来匹配 tool_result 与之前的 tool_use, + // 除非 replay input 已经包含与每个 tool_result 匹配的 tool_use 上下文。 + hasFCOutput := hasFunctionCallOutput + hasReplayToolContext := hasFCOutput && + currentTurnReplayInputExists && + openAIWSRawItemsHaveToolCallContextForOutputs(currentTurnReplayInput) + if !turnPrevRecoveryTried && currentPreviousResponseID != "" && (!hasFCOutput || hasReplayToolContext) { + updatedPayload, removed, dropErr := dropPreviousResponseIDFromRawPayload(currentPayload) + if dropErr != nil || !removed { + reason := "not_removed" + if dropErr != nil { + reason = "drop_error" + } + logOpenAIWSModeInfo( + "ingress_ws_preflight_ping_recovery_skip account_id=%d turn=%d conn_id=%s reason=%s previous_response_id=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(reason), + truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), + ) + } else { + updatedWithInput, setInputErr := setOpenAIWSPayloadInputSequence( + updatedPayload, + currentTurnReplayInput, + currentTurnReplayInputExists, + ) + if setInputErr != nil { + logOpenAIWSModeInfo( + "ingress_ws_preflight_ping_recovery_skip account_id=%d turn=%d conn_id=%s reason=set_full_input_error previous_response_id=%s cause=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(setInputErr.Error(), openAIWSLogValueMaxLen), + ) + } else { + logOpenAIWSModeInfo( + "ingress_ws_preflight_ping_recovery account_id=%d turn=%d conn_id=%s action=drop_previous_response_id_retry previous_response_id=%s has_function_call_output=%v has_replay_tool_context=%v", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), + hasFCOutput, + hasReplayToolContext, + ) + turnPrevRecoveryTried = true + currentPayload = updatedWithInput + currentPayloadBytes = len(updatedWithInput) + resetSessionLease(true) + skipBeforeTurn = true + continue + } + } + } + if hasFCOutput && currentPreviousResponseID != "" { + reason := "function_call_output_missing_replay_context" + if hasReplayToolContext { + reason = "function_call_output_replay_not_applied" + } + logOpenAIWSModeInfo( + "ingress_ws_preflight_ping_recovery_skip account_id=%d turn=%d conn_id=%s reason=%s action=fail_close previous_response_id=%s has_replay_tool_context=%v", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + reason, + truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), + hasReplayToolContext, + ) + } + resetSessionLease(true) + return NewOpenAIWSClientCloseError( + coderws.StatusPolicyViolation, + "upstream continuation connection is unavailable; please restart the conversation", + pingErr, + ) + } + resetSessionLease(true) + + acquiredLease, acquireErr := acquireTurnLease(turn, preferredConnID, forcePreferredConn) + if acquireErr != nil { + return fmt.Errorf("acquire upstream websocket after preflight ping fail: %w", acquireErr) + } + sessionLease = acquiredLease + sessionConnID = strings.TrimSpace(sessionLease.ConnID()) + if storeDisabled { + pinSessionConn(sessionConnID) + } + } + } + connID := sessionConnID + if currentPreviousResponseID != "" { + chainedFromLast := expectedPrev != "" && currentPreviousResponseID == expectedPrev + currentPreviousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(currentPreviousResponseID) + logOpenAIWSModeInfo( + "ingress_ws_turn_chain account_id=%d turn=%d conn_id=%s previous_response_id=%s previous_response_id_kind=%s last_turn_response_id=%s chained_from_last=%v preferred_conn_id=%s header_session_id=%s header_conversation_id=%s has_turn_state=%v turn_state_len=%d has_prompt_cache_key=%v store_disabled=%v", + account.ID, + turn, + truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(currentPreviousResponseID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(currentPreviousResponseIDKind), + truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), + chainedFromLast, + truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen), + openAIWSHeaderValueForLog(baseAcquireReq.Headers, "session_id"), + openAIWSHeaderValueForLog(baseAcquireReq.Headers, "conversation_id"), + turnState != "", + len(turnState), + openAIWSPayloadStringFromRaw(currentPayload, "prompt_cache_key") != "", + storeDisabled, + ) + } + + result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier, currentImageInputSize) + if relayErr != nil { + lastTurnClean = false + if recoverIngressPrevResponseNotFound(relayErr, turn, connID) { + continue + } + if retryIngressTurn(relayErr, turn, connID) { + continue + } + finalErr := relayErr + if unwrapped := errors.Unwrap(relayErr); unwrapped != nil { + finalErr = unwrapped + } + if hooks != nil && hooks.AfterTurn != nil { + hooks.AfterTurn(turn, nil, finalErr) + } + sessionLease.MarkBroken() + return finalErr + } + turnRetry = 0 + turnPrevRecoveryTried = false + lastTurnFinishedAt = time.Now() + lastTurnClean = true + if hooks != nil && hooks.AfterTurn != nil { + hooks.AfterTurn(turn, result, nil) + } + if result == nil { + return errors.New("websocket turn result is nil") + } + responseID := strings.TrimSpace(result.RequestID) + lastTurnResponseID = responseID + lastTurnPayload = cloneOpenAIWSPayloadBytes(currentPayload) + lastTurnReplayInput = cloneOpenAIWSRawMessages(currentTurnReplayInput) + lastTurnReplayInputExists = currentTurnReplayInputExists + if result.wsReplayInputExists { + lastTurnReplayInput = append(lastTurnReplayInput, cloneOpenAIWSRawMessages(result.wsReplayInput)...) + lastTurnReplayInputExists = true + } + nextStrictState, strictStateErr := buildOpenAIWSIngressPreviousTurnStrictState(currentPayload) + if strictStateErr != nil { + lastTurnStrictState = nil + logOpenAIWSModeInfo( + "ingress_ws_prev_response_strict_state_skip account_id=%d turn=%d conn_id=%s reason=build_error cause=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(strictStateErr.Error(), openAIWSLogValueMaxLen), + ) + } else { + lastTurnStrictState = nextStrictState + } + + if responseID != "" && stateStore != nil { + ttl := s.openAIWSResponseStickyTTL() + logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, stateStore.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) + stateStore.BindResponseConn(responseID, connID, ttl) + } + if stateStore != nil && storeDisabled && sessionHash != "" { + stateStore.BindSessionConn(groupID, sessionHash, connID, s.openAIWSSessionStickyTTL()) + } + if connID != "" { + preferredConnID = connID + } + + nextClientMessage, readErr := readClientMessage() + if readErr != nil { + if isOpenAIWSClientDisconnectError(readErr) { + closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr) + logOpenAIWSModeInfo( + "ingress_ws_client_closed account_id=%d conn_id=%s close_status=%s close_reason=%s", + account.ID, + truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), + closeStatus, + truncateOpenAIWSLogValue(closeReason, openAIWSHeaderValueMaxLen), + ) + return nil + } + return fmt.Errorf("read client websocket request: %w", readErr) + } + + nextPayload, parseErr := parseClientPayload(nextClientMessage) + if parseErr != nil { + return parseErr + } + if nextPayload.promptCacheKey != "" { + // ingress 会话在整个客户端 WS 生命周期内复用同一上游连接; + // prompt_cache_key 对握手头的更新仅在未来需要重新建连时生效。 + updatedHeaders, _, updHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), nextPayload.promptCacheKey) + if updHdrErr != nil { + logOpenAIWSModeInfo("ingress_ws_update_headers_failed account_id=%d err=%v", account.ID, updHdrErr) + } else { + baseAcquireReq.Headers = updatedHeaders + } + } + if nextPayload.previousResponseID != "" { + expectedPrev := strings.TrimSpace(lastTurnResponseID) + chainedFromLast := expectedPrev != "" && nextPayload.previousResponseID == expectedPrev + nextPreviousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(nextPayload.previousResponseID) + logOpenAIWSModeInfo( + "ingress_ws_next_turn_chain account_id=%d turn=%d next_turn=%d conn_id=%s previous_response_id=%s previous_response_id_kind=%s last_turn_response_id=%s chained_from_last=%v has_prompt_cache_key=%v store_disabled=%v", + account.ID, + turn, + turn+1, + truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(nextPayload.previousResponseID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(nextPreviousResponseIDKind), + truncateOpenAIWSLogValue(expectedPrev, openAIWSIDValueMaxLen), + chainedFromLast, + nextPayload.promptCacheKey != "", + storeDisabled, + ) + } + if stateStore != nil && nextPayload.previousResponseID != "" { + if stickyConnID, ok := stateStore.GetResponseConn(nextPayload.previousResponseID); ok { + if sessionConnID != "" && stickyConnID != "" && stickyConnID != sessionConnID { + logOpenAIWSModeInfo( + "ingress_ws_keep_session_conn account_id=%d turn=%d conn_id=%s sticky_conn_id=%s previous_response_id=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(stickyConnID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(nextPayload.previousResponseID, openAIWSIDValueMaxLen), + ) + } else { + preferredConnID = stickyConnID + } + } + } + currentPayload = nextPayload.payloadRaw + currentOriginalModel = nextPayload.originalModel + currentImageBillingModel = nextPayload.imageBillingModel + currentImageSizeTier = nextPayload.imageSizeTier + currentImageInputSize = nextPayload.imageInputSize + currentPayloadBytes = nextPayload.payloadBytes + storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account) + if !storeDisabled { + unpinSessionConn(sessionConnID) + } + turn++ + } +} diff --git a/backend/internal/service/openai_ws_forwarder_logutil.go b/backend/internal/service/openai_ws_forwarder_logutil.go new file mode 100644 index 0000000000..611938091d --- /dev/null +++ b/backend/internal/service/openai_ws_forwarder_logutil.go @@ -0,0 +1,690 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "sort" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + coderws "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "go.uber.org/zap" +) + +func normalizeOpenAIWSLogValue(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "-" + } + return openAIWSLogValueReplacer.Replace(trimmed) +} + +func truncateOpenAIWSLogValue(value string, maxLen int) string { + normalized := normalizeOpenAIWSLogValue(value) + if normalized == "-" || maxLen <= 0 { + return normalized + } + if len(normalized) <= maxLen { + return normalized + } + return normalized[:maxLen] + "..." +} + +func openAIWSHeaderValueForLog(headers http.Header, key string) string { + if headers == nil { + return "-" + } + return truncateOpenAIWSLogValue(headers.Get(key), openAIWSHeaderValueMaxLen) +} + +func hasOpenAIWSHeader(headers http.Header, key string) bool { + if headers == nil { + return false + } + return strings.TrimSpace(headers.Get(key)) != "" +} + +type openAIWSSessionHeaderResolution struct { + SessionID string + ConversationID string + SessionSource string + ConversationSource string +} + +func resolveOpenAIWSSessionHeaders(c *gin.Context, promptCacheKey string) openAIWSSessionHeaderResolution { + resolution := openAIWSSessionHeaderResolution{ + SessionSource: "none", + ConversationSource: "none", + } + if c != nil && c.Request != nil { + if sessionID := strings.TrimSpace(c.Request.Header.Get("session_id")); sessionID != "" { + resolution.SessionID = sessionID + resolution.SessionSource = "header_session_id" + } + if conversationID := strings.TrimSpace(c.Request.Header.Get("conversation_id")); conversationID != "" { + resolution.ConversationID = conversationID + resolution.ConversationSource = "header_conversation_id" + if resolution.SessionID == "" { + resolution.SessionID = conversationID + resolution.SessionSource = "header_conversation_id" + } + } + } + + cacheKey := strings.TrimSpace(promptCacheKey) + if cacheKey != "" { + if resolution.SessionID == "" { + resolution.SessionID = cacheKey + resolution.SessionSource = "prompt_cache_key" + } + } + return resolution +} + +func shouldLogOpenAIWSEvent(idx int, eventType string) bool { + if idx <= openAIWSEventLogHeadLimit { + return true + } + if openAIWSEventLogEveryN > 0 && idx%openAIWSEventLogEveryN == 0 { + return true + } + if eventType == "error" || isOpenAIWSTerminalEvent(eventType) { + return true + } + return false +} + +func shouldLogOpenAIWSBufferedEvent(idx int) bool { + if idx <= openAIWSBufferLogHeadLimit { + return true + } + if openAIWSBufferLogEveryN > 0 && idx%openAIWSBufferLogEveryN == 0 { + return true + } + return false +} + +func openAIWSEventMayContainModel(eventType string) bool { + switch eventType { + case "response.created", + "response.in_progress", + "response.completed", + "response.done", + "response.failed", + "response.incomplete", + "response.cancelled", + "response.canceled": + return true + default: + trimmed := strings.TrimSpace(eventType) + if trimmed == eventType { + return false + } + switch trimmed { + case "response.created", + "response.in_progress", + "response.completed", + "response.done", + "response.failed", + "response.incomplete", + "response.cancelled", + "response.canceled": + return true + default: + return false + } + } +} + +func openAIWSEventMayContainToolCalls(eventType string) bool { + eventType = strings.TrimSpace(eventType) + if eventType == "" { + return false + } + if strings.Contains(eventType, "function_call") || strings.Contains(eventType, "tool_call") { + return true + } + switch eventType { + case "response.output_item.added", "response.output_item.done", "response.completed", "response.done": + return true + default: + return false + } +} + +func openAIWSEventShouldParseUsage(eventType string) bool { + switch strings.TrimSpace(eventType) { + case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + return true + default: + return false + } +} + +func parseOpenAIWSEventEnvelope(message []byte) (eventType string, responseID string, response gjson.Result) { + if len(message) == 0 { + return "", "", gjson.Result{} + } + values := gjson.GetManyBytes(message, "type", "response.id", "id", "response") + eventType = strings.TrimSpace(values[0].String()) + if id := strings.TrimSpace(values[1].String()); id != "" { + responseID = id + } else { + responseID = strings.TrimSpace(values[2].String()) + } + return eventType, responseID, values[3] +} + +func openAIWSMessageLikelyContainsToolCalls(message []byte) bool { + if len(message) == 0 { + return false + } + return bytes.Contains(message, []byte(`"tool_calls"`)) || + bytes.Contains(message, []byte(`"tool_call"`)) || + bytes.Contains(message, []byte(`"function_call"`)) +} + +func parseOpenAIWSResponseUsageFromCompletedEvent(message []byte, usage *OpenAIUsage) { + if usage == nil || len(message) == 0 { + return + } + if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(message); ok { + *usage = parsedUsage + } +} + +func parseOpenAIWSErrorEventFields(message []byte) (code string, errType string, errMessage string) { + if len(message) == 0 { + return "", "", "" + } + values := gjson.GetManyBytes(message, "error.code", "error.type", "error.message") + return strings.TrimSpace(values[0].String()), strings.TrimSpace(values[1].String()), strings.TrimSpace(values[2].String()) +} + +func summarizeOpenAIWSErrorEventFieldsFromRaw(codeRaw, errTypeRaw, errMessageRaw string) (code string, errType string, errMessage string) { + code = truncateOpenAIWSLogValue(codeRaw, openAIWSLogValueMaxLen) + errType = truncateOpenAIWSLogValue(errTypeRaw, openAIWSLogValueMaxLen) + errMessage = truncateOpenAIWSLogValue(errMessageRaw, openAIWSLogValueMaxLen) + return code, errType, errMessage +} + +func summarizeOpenAIWSErrorEventFields(message []byte) (code string, errType string, errMessage string) { + if len(message) == 0 { + return "-", "-", "-" + } + return summarizeOpenAIWSErrorEventFieldsFromRaw(parseOpenAIWSErrorEventFields(message)) +} + +func summarizeOpenAIWSPayloadKeySizes(payload map[string]any, topN int) string { + if len(payload) == 0 { + return "-" + } + type keySize struct { + Key string + Size int + } + sizes := make([]keySize, 0, len(payload)) + for key, value := range payload { + size := estimateOpenAIWSPayloadValueSize(value, openAIWSPayloadSizeEstimateDepth) + sizes = append(sizes, keySize{Key: key, Size: size}) + } + sort.Slice(sizes, func(i, j int) bool { + if sizes[i].Size == sizes[j].Size { + return sizes[i].Key < sizes[j].Key + } + return sizes[i].Size > sizes[j].Size + }) + + if topN <= 0 || topN > len(sizes) { + topN = len(sizes) + } + parts := make([]string, 0, topN) + for idx := 0; idx < topN; idx++ { + item := sizes[idx] + parts = append(parts, fmt.Sprintf("%s:%d", item.Key, item.Size)) + } + return strings.Join(parts, ",") +} + +func estimateOpenAIWSPayloadValueSize(value any, depth int) int { + if depth <= 0 { + return -1 + } + switch v := value.(type) { + case nil: + return 0 + case string: + return len(v) + case []byte: + return len(v) + case bool: + return 1 + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return 8 + case float32, float64: + return 8 + case map[string]any: + if len(v) == 0 { + return 2 + } + total := 2 + count := 0 + for key, item := range v { + count++ + if count > openAIWSPayloadSizeEstimateMaxItems { + return -1 + } + itemSize := estimateOpenAIWSPayloadValueSize(item, depth-1) + if itemSize < 0 { + return -1 + } + total += len(key) + itemSize + 3 + if total > openAIWSPayloadSizeEstimateMaxBytes { + return -1 + } + } + return total + case []any: + if len(v) == 0 { + return 2 + } + total := 2 + limit := len(v) + if limit > openAIWSPayloadSizeEstimateMaxItems { + return -1 + } + for i := 0; i < limit; i++ { + itemSize := estimateOpenAIWSPayloadValueSize(v[i], depth-1) + if itemSize < 0 { + return -1 + } + total += itemSize + 1 + if total > openAIWSPayloadSizeEstimateMaxBytes { + return -1 + } + } + return total + default: + raw, err := json.Marshal(v) + if err != nil { + return -1 + } + if len(raw) > openAIWSPayloadSizeEstimateMaxBytes { + return -1 + } + return len(raw) + } +} + +func openAIWSPayloadString(payload map[string]any, key string) string { + if len(payload) == 0 { + return "" + } + raw, ok := payload[key] + if !ok { + return "" + } + switch v := raw.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(v) + case []byte: + return strings.TrimSpace(string(v)) + default: + return "" + } +} + +func openAIWSPayloadStringFromRaw(payload []byte, key string) string { + if len(payload) == 0 || strings.TrimSpace(key) == "" { + return "" + } + return strings.TrimSpace(gjson.GetBytes(payload, key).String()) +} + +func openAIWSPayloadBoolFromRaw(payload []byte, key string, defaultValue bool) bool { + if len(payload) == 0 || strings.TrimSpace(key) == "" { + return defaultValue + } + value := gjson.GetBytes(payload, key) + if !value.Exists() { + return defaultValue + } + if value.Type != gjson.True && value.Type != gjson.False { + return defaultValue + } + return value.Bool() +} + +func openAIWSSessionHashesFromID(sessionID string) (string, string) { + return deriveOpenAISessionHashes(sessionID) +} + +func extractOpenAIWSImageURL(value any) string { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + case map[string]any: + if raw, ok := v["url"].(string); ok { + return strings.TrimSpace(raw) + } + } + return "" +} + +func summarizeOpenAIWSInput(input any) string { + items, ok := input.([]any) + if !ok || len(items) == 0 { + return "-" + } + + itemCount := len(items) + textChars := 0 + imageDataURLs := 0 + imageDataURLChars := 0 + imageRemoteURLs := 0 + + handleContentItem := func(contentItem map[string]any) { + contentType, _ := contentItem["type"].(string) + switch strings.TrimSpace(contentType) { + case "input_text", "output_text", "text": + if text, ok := contentItem["text"].(string); ok { + textChars += len(text) + } + case "input_image": + imageURL := extractOpenAIWSImageURL(contentItem["image_url"]) + if imageURL == "" { + return + } + if strings.HasPrefix(strings.ToLower(imageURL), "data:image/") { + imageDataURLs++ + imageDataURLChars += len(imageURL) + return + } + imageRemoteURLs++ + } + } + + handleInputItem := func(inputItem map[string]any) { + if content, ok := inputItem["content"].([]any); ok { + for _, rawContent := range content { + contentItem, ok := rawContent.(map[string]any) + if !ok { + continue + } + handleContentItem(contentItem) + } + return + } + + itemType, _ := inputItem["type"].(string) + switch strings.TrimSpace(itemType) { + case "input_text", "output_text", "text": + if text, ok := inputItem["text"].(string); ok { + textChars += len(text) + } + case "input_image": + imageURL := extractOpenAIWSImageURL(inputItem["image_url"]) + if imageURL == "" { + return + } + if strings.HasPrefix(strings.ToLower(imageURL), "data:image/") { + imageDataURLs++ + imageDataURLChars += len(imageURL) + return + } + imageRemoteURLs++ + } + } + + for _, rawItem := range items { + inputItem, ok := rawItem.(map[string]any) + if !ok { + continue + } + handleInputItem(inputItem) + } + + return fmt.Sprintf( + "items=%d,text_chars=%d,image_data_urls=%d,image_data_url_chars=%d,image_remote_urls=%d", + itemCount, + textChars, + imageDataURLs, + imageDataURLChars, + imageRemoteURLs, + ) +} + +func dropOpenAIWSPayloadKey(payload map[string]any, key string, removed *[]string) { + if len(payload) == 0 || strings.TrimSpace(key) == "" { + return + } + if _, exists := payload[key]; !exists { + return + } + delete(payload, key) + *removed = append(*removed, key) +} + +// applyOpenAIWSRetryPayloadStrategy 在 WS 连续失败时仅移除无语义字段, +// 避免重试成功却改变原始请求语义。 +// 注意:prompt_cache_key 不应在重试中移除;它常用于会话稳定标识(session_id 兜底)。 +func applyOpenAIWSRetryPayloadStrategy(payload map[string]any, attempt int) (strategy string, removedKeys []string) { + if len(payload) == 0 { + return "empty", nil + } + if attempt <= 1 { + return "full", nil + } + + removed := make([]string, 0, 2) + if attempt >= 2 { + dropOpenAIWSPayloadKey(payload, "include", &removed) + } + + if len(removed) == 0 { + return "full", nil + } + sort.Strings(removed) + return "trim_optional_fields", removed +} + +func logOpenAIWSModeInfo(format string, args ...any) { + logger.LegacyPrintf("service.openai_gateway", "[OpenAI WS Mode][openai_ws_mode=true] "+format, args...) +} + +func isOpenAIWSModeDebugEnabled() bool { + return logger.L().Core().Enabled(zap.DebugLevel) +} + +func logOpenAIWSModeDebug(format string, args ...any) { + if !isOpenAIWSModeDebugEnabled() { + return + } + logger.LegacyPrintf("service.openai_gateway", "[debug] [OpenAI WS Mode][openai_ws_mode=true] "+format, args...) +} + +func logOpenAIWSBindResponseAccountWarn(groupID, accountID int64, responseID string, err error) { + if err == nil { + return + } + logger.L().Warn( + "openai.ws_bind_response_account_failed", + zap.Int64("group_id", groupID), + zap.Int64("account_id", accountID), + zap.String("response_id", truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen)), + zap.Error(err), + ) +} + +func summarizeOpenAIWSReadCloseError(err error) (status string, reason 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) { + reasonText := strings.TrimSpace(closeErr.Reason) + if reasonText != "" { + closeReason = normalizeOpenAIWSLogValue(reasonText) + } + } + return normalizeOpenAIWSLogValue(closeStatus), closeReason +} + +func unwrapOpenAIWSDialBaseError(err error) error { + if err == nil { + return nil + } + var dialErr *openAIWSDialError + if errors.As(err, &dialErr) && dialErr != nil && dialErr.Err != nil { + return dialErr.Err + } + return err +} + +func openAIWSDialRespHeaderForLog(err error, key string) string { + var dialErr *openAIWSDialError + if !errors.As(err, &dialErr) || dialErr == nil || dialErr.ResponseHeaders == nil { + return "-" + } + return truncateOpenAIWSLogValue(dialErr.ResponseHeaders.Get(key), openAIWSHeaderValueMaxLen) +} + +func classifyOpenAIWSDialError(err error) string { + if err == nil { + return "-" + } + baseErr := unwrapOpenAIWSDialBaseError(err) + if baseErr == nil { + return "-" + } + if errors.Is(baseErr, context.DeadlineExceeded) { + return "ctx_deadline_exceeded" + } + if errors.Is(baseErr, context.Canceled) { + return "ctx_canceled" + } + var netErr net.Error + if errors.As(baseErr, &netErr) && netErr.Timeout() { + return "net_timeout" + } + if status := coderws.CloseStatus(baseErr); status != -1 { + return normalizeOpenAIWSLogValue(fmt.Sprintf("ws_close_%d", int(status))) + } + message := strings.ToLower(strings.TrimSpace(baseErr.Error())) + switch { + case strings.Contains(message, "handshake not finished"): + return "handshake_not_finished" + case strings.Contains(message, "bad handshake"): + return "bad_handshake" + case strings.Contains(message, "connection refused"): + return "connection_refused" + case strings.Contains(message, "no such host"): + return "dns_not_found" + case strings.Contains(message, "tls"): + return "tls_error" + case strings.Contains(message, "i/o timeout"): + return "io_timeout" + case strings.Contains(message, "context deadline exceeded"): + return "ctx_deadline_exceeded" + default: + return "dial_error" + } +} + +func summarizeOpenAIWSDialError(err error) ( + statusCode int, + dialClass string, + closeStatus string, + closeReason string, + respServer string, + respVia string, + respCFRay string, + respRequestID string, +) { + dialClass = "-" + closeStatus = "-" + closeReason = "-" + respServer = "-" + respVia = "-" + respCFRay = "-" + respRequestID = "-" + if err == nil { + return + } + var dialErr *openAIWSDialError + if errors.As(err, &dialErr) && dialErr != nil { + statusCode = dialErr.StatusCode + respServer = openAIWSDialRespHeaderForLog(err, "server") + respVia = openAIWSDialRespHeaderForLog(err, "via") + respCFRay = openAIWSDialRespHeaderForLog(err, "cf-ray") + respRequestID = openAIWSDialRespHeaderForLog(err, "x-request-id") + } + dialClass = normalizeOpenAIWSLogValue(classifyOpenAIWSDialError(err)) + closeStatus, closeReason = summarizeOpenAIWSReadCloseError(unwrapOpenAIWSDialBaseError(err)) + return +} + +func isOpenAIWSClientDisconnectError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || errors.Is(err, context.Canceled) { + return true + } + switch coderws.CloseStatus(err) { + case coderws.StatusNormalClosure, coderws.StatusGoingAway, coderws.StatusNoStatusRcvd, coderws.StatusAbnormalClosure: + return true + } + message := strings.ToLower(strings.TrimSpace(err.Error())) + if message == "" { + return false + } + return strings.Contains(message, "failed to read frame header: eof") || + strings.Contains(message, "unexpected eof") || + strings.Contains(message, "use of closed network connection") || + strings.Contains(message, "connection reset by peer") || + strings.Contains(message, "broken pipe") || + strings.Contains(message, "an established connection was aborted") +} + +func classifyOpenAIWSReadFallbackReason(err error) string { + if err == nil { + return "read_event" + } + switch coderws.CloseStatus(err) { + case coderws.StatusPolicyViolation: + return "policy_violation" + case coderws.StatusMessageTooBig: + return "message_too_big" + default: + return "read_event" + } +} + +func sortedKeys(m map[string]any) []string { + if len(m) == 0 { + return nil + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go new file mode 100644 index 0000000000..cd84e6f25b --- /dev/null +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -0,0 +1,705 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func (s *OpenAIGatewayService) buildOpenAIResponsesWSURL(account *Account) (string, error) { + if account == nil { + return "", errors.New("account is nil") + } + var targetURL string + switch account.Type { + case AccountTypeOAuth: + targetURL = chatgptCodexURL + case AccountTypeAPIKey: + baseURL := account.GetOpenAIBaseURL() + if baseURL == "" { + targetURL = openaiPlatformAPIURL + } else { + validatedURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return "", err + } + targetURL = buildOpenAIResponsesURL(validatedURL) + } + default: + targetURL = openaiPlatformAPIURL + } + + parsed, err := url.Parse(strings.TrimSpace(targetURL)) + if err != nil { + return "", fmt.Errorf("invalid target url: %w", err) + } + switch strings.ToLower(parsed.Scheme) { + case "https": + parsed.Scheme = "wss" + case "http": + parsed.Scheme = "ws" + case "wss", "ws": + // 保持不变 + default: + return "", fmt.Errorf("unsupported scheme for ws: %s", parsed.Scheme) + } + return parsed.String(), nil +} + +func (s *OpenAIGatewayService) buildOpenAIWSHeaders( + ctx context.Context, + c *gin.Context, + account *Account, + token string, + decision OpenAIWSProtocolDecision, + isCodexCLI bool, + turnState string, + turnMetadata string, + promptCacheKey string, +) (http.Header, openAIWSSessionHeaderResolution, error) { + headers := make(http.Header) + headers.Set("authorization", "Bearer "+token) + + sessionResolution := resolveOpenAIWSSessionHeaders(c, promptCacheKey) + if c != nil && c.Request != nil { + if v := strings.TrimSpace(c.Request.Header.Get("accept-language")); v != "" { + headers.Set("accept-language", v) + } + } + // OAuth 账号:将 apiKeyID 混入 session 标识符,防止跨用户会话碰撞。 + if account != nil && account.Type == AccountTypeOAuth { + apiKeyID := getAPIKeyIDFromContext(c) + if sessionResolution.SessionID != "" { + headers.Set("session_id", isolateOpenAISessionID(apiKeyID, sessionResolution.SessionID)) + } + if sessionResolution.ConversationID != "" { + headers.Set("conversation_id", isolateOpenAISessionID(apiKeyID, sessionResolution.ConversationID)) + } + } else { + if sessionResolution.SessionID != "" { + headers.Set("session_id", sessionResolution.SessionID) + } + if sessionResolution.ConversationID != "" { + headers.Set("conversation_id", sessionResolution.ConversationID) + } + } + if state := strings.TrimSpace(turnState); state != "" { + headers.Set(openAIWSTurnStateHeader, state) + } + if metadata := strings.TrimSpace(turnMetadata); metadata != "" { + headers.Set(openAIWSTurnMetadataHeader, metadata) + } + + if account != nil && account.Type == AccountTypeOAuth { + if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { + return nil, sessionResolution, fmt.Errorf("resolve chatgpt account headers: %w", err) + } + headers.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI)) + } + + betaValue := openAIWSBetaV2Value + if decision.Transport == OpenAIUpstreamTransportResponsesWebsocket { + betaValue = openAIWSBetaV1Value + } + headers.Set("OpenAI-Beta", betaValue) + + customUA := "" + if account != nil { + customUA = account.GetOpenAIUserAgent() + } + if strings.TrimSpace(customUA) != "" { + headers.Set("user-agent", customUA) + } else if c != nil { + if ua := strings.TrimSpace(c.GetHeader("User-Agent")); ua != "" { + headers.Set("user-agent", ua) + } + } + if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { + headers.Set("user-agent", codexCLIUserAgent) + } + if account != nil && account.Type == AccountTypeOAuth && !openai.IsCodexCLIRequest(headers.Get("user-agent")) { + headers.Set("user-agent", codexCLIUserAgent) + } + + // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。 + // 覆盖所有 WS 模式(ctx_pool/dedicated/passthrough)的握手头。 + account.ApplyHeaderOverrides(headers) + + return headers, sessionResolution, nil +} + +func (s *OpenAIGatewayService) buildOpenAIWSCreatePayload(reqBody map[string]any, account *Account) map[string]any { + // OpenAI WS Mode 协议:response.create 字段与 HTTP /responses 基本一致。 + // 保留 stream 字段(与 Codex CLI 一致),仅移除 background。 + payload := make(map[string]any, len(reqBody)+1) + for k, v := range reqBody { + payload[k] = v + } + + delete(payload, "background") + if _, exists := payload["stream"]; !exists { + payload["stream"] = true + } + payload["type"] = "response.create" + + // OAuth 默认保持 store=false,避免误依赖服务端历史。 + if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) { + payload["store"] = false + } + return payload +} + +func setOpenAIWSTurnMetadata(payload map[string]any, turnMetadata string) { + if len(payload) == 0 { + return + } + metadata := strings.TrimSpace(turnMetadata) + if metadata == "" { + return + } + + switch existing := payload["client_metadata"].(type) { + case map[string]any: + existing[openAIWSTurnMetadataHeader] = metadata + payload["client_metadata"] = existing + case map[string]string: + next := make(map[string]any, len(existing)+1) + for k, v := range existing { + next[k] = v + } + next[openAIWSTurnMetadataHeader] = metadata + payload["client_metadata"] = next + default: + payload["client_metadata"] = map[string]any{ + openAIWSTurnMetadataHeader: metadata, + } + } +} + +func (s *OpenAIGatewayService) isOpenAIWSStoreRecoveryAllowed(account *Account) bool { + if account != nil && account.IsOpenAIWSAllowStoreRecoveryEnabled() { + return true + } + if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.AllowStoreRecovery { + return true + } + return false +} + +func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequest(reqBody map[string]any, account *Account) bool { + if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) { + return true + } + if len(reqBody) == 0 { + return false + } + rawStore, ok := reqBody["store"] + if !ok { + return false + } + storeEnabled, ok := rawStore.(bool) + if !ok { + return false + } + return !storeEnabled +} + +func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequestRaw(reqBody []byte, account *Account) bool { + if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) { + return true + } + if len(reqBody) == 0 { + return false + } + storeValue := gjson.GetBytes(reqBody, "store") + if !storeValue.Exists() { + return false + } + if storeValue.Type != gjson.True && storeValue.Type != gjson.False { + return false + } + return !storeValue.Bool() +} + +func (s *OpenAIGatewayService) openAIWSStoreDisabledConnMode() string { + if s == nil || s.cfg == nil { + return openAIWSStoreDisabledConnModeStrict + } + mode := strings.ToLower(strings.TrimSpace(s.cfg.Gateway.OpenAIWS.StoreDisabledConnMode)) + switch mode { + case openAIWSStoreDisabledConnModeStrict, openAIWSStoreDisabledConnModeAdaptive, openAIWSStoreDisabledConnModeOff: + return mode + case "": + // 兼容旧配置:仅配置了布尔开关时按旧语义推导。 + if s.cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn { + return openAIWSStoreDisabledConnModeStrict + } + return openAIWSStoreDisabledConnModeOff + default: + return openAIWSStoreDisabledConnModeStrict + } +} + +func shouldForceNewConnOnStoreDisabled(mode, lastFailureReason string) bool { + switch mode { + case openAIWSStoreDisabledConnModeOff: + return false + case openAIWSStoreDisabledConnModeAdaptive: + reason := strings.TrimPrefix(strings.TrimSpace(lastFailureReason), "prewarm_") + switch reason { + case "policy_violation", "message_too_big", "auth_failed", "write_request", "write": + return true + default: + return false + } + default: + return true + } +} + +func dropPreviousResponseIDFromRawPayload(payload []byte) ([]byte, bool, error) { + return dropPreviousResponseIDFromRawPayloadWithDeleteFn(payload, sjson.DeleteBytes) +} + +func dropPreviousResponseIDFromRawPayloadWithDeleteFn( + payload []byte, + deleteFn func([]byte, string) ([]byte, error), +) ([]byte, bool, error) { + if len(payload) == 0 { + return payload, false, nil + } + if !gjson.GetBytes(payload, "previous_response_id").Exists() { + return payload, false, nil + } + if deleteFn == nil { + deleteFn = sjson.DeleteBytes + } + + updated := payload + for i := 0; i < openAIWSMaxPrevResponseIDDeletePasses && + gjson.GetBytes(updated, "previous_response_id").Exists(); i++ { + next, err := deleteFn(updated, "previous_response_id") + if err != nil { + return payload, false, err + } + updated = next + } + return updated, !gjson.GetBytes(updated, "previous_response_id").Exists(), nil +} + +func setPreviousResponseIDToRawPayload(payload []byte, previousResponseID string) ([]byte, error) { + normalizedPrevID := strings.TrimSpace(previousResponseID) + if len(payload) == 0 || normalizedPrevID == "" { + return payload, nil + } + updated, err := sjson.SetBytes(payload, "previous_response_id", normalizedPrevID) + if err == nil { + return updated, nil + } + + var reqBody map[string]any + if unmarshalErr := json.Unmarshal(payload, &reqBody); unmarshalErr != nil { + return nil, err + } + reqBody["previous_response_id"] = normalizedPrevID + rebuilt, marshalErr := json.Marshal(reqBody) + if marshalErr != nil { + return nil, marshalErr + } + return rebuilt, nil +} + +func shouldInferIngressFunctionCallOutputPreviousResponseID( + storeDisabled bool, + turn int, + signals ToolContinuationSignals, + currentPreviousResponseID string, + expectedPreviousResponseID string, +) bool { + if !storeDisabled || turn <= 1 || !signals.HasFunctionCallOutput { + return false + } + if strings.TrimSpace(currentPreviousResponseID) != "" { + return false + } + if signals.HasFunctionCallOutputMissingCallID { + return false + } + // If the client already sent the actual tool-call context, treat this as + // a full replay / self-contained continuation payload rather than + // downgrading it into an inferred delta continuation. item_reference alone + // is not enough on the store=false WS path: it still needs a valid prior + // response anchor so upstream can resolve the referenced function_call. + if signals.HasToolCallContext { + return false + } + return strings.TrimSpace(expectedPreviousResponseID) != "" +} + +func alignStoreDisabledPreviousResponseID( + payload []byte, + expectedPreviousResponseID string, +) ([]byte, bool, error) { + if len(payload) == 0 { + return payload, false, nil + } + expected := strings.TrimSpace(expectedPreviousResponseID) + if expected == "" { + return payload, false, nil + } + current := openAIWSPayloadStringFromRaw(payload, "previous_response_id") + if current == "" || current == expected { + return payload, false, nil + } + + withoutPrev, removed, dropErr := dropPreviousResponseIDFromRawPayload(payload) + if dropErr != nil { + return payload, false, dropErr + } + if !removed { + return payload, false, nil + } + updated, setErr := setPreviousResponseIDToRawPayload(withoutPrev, expected) + if setErr != nil { + return payload, false, setErr + } + return updated, true, nil +} + +func cloneOpenAIWSPayloadBytes(payload []byte) []byte { + if len(payload) == 0 { + return nil + } + cloned := make([]byte, len(payload)) + copy(cloned, payload) + return cloned +} + +func cloneOpenAIWSRawMessages(items []json.RawMessage) []json.RawMessage { + if items == nil { + return nil + } + cloned := make([]json.RawMessage, 0, len(items)) + for idx := range items { + cloned = append(cloned, json.RawMessage(cloneOpenAIWSPayloadBytes(items[idx]))) + } + return cloned +} + +func normalizeOpenAIWSJSONForCompare(raw []byte) ([]byte, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 { + return nil, errors.New("json is empty") + } + var decoded any + if err := json.Unmarshal(trimmed, &decoded); err != nil { + return nil, err + } + return json.Marshal(decoded) +} + +func normalizeOpenAIWSJSONForCompareOrRaw(raw []byte) []byte { + normalized, err := normalizeOpenAIWSJSONForCompare(raw) + if err != nil { + return bytes.TrimSpace(raw) + } + return normalized +} + +func normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload []byte) ([]byte, error) { + if len(payload) == 0 { + return nil, errors.New("payload is empty") + } + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + return nil, err + } + delete(decoded, "input") + delete(decoded, "previous_response_id") + return json.Marshal(decoded) +} + +func openAIWSExtractNormalizedInputSequence(payload []byte) ([]json.RawMessage, bool, error) { + if len(payload) == 0 { + return nil, false, nil + } + inputValue := gjson.GetBytes(payload, "input") + if !inputValue.Exists() { + return nil, false, nil + } + if inputValue.Type == gjson.JSON { + raw := strings.TrimSpace(inputValue.Raw) + if strings.HasPrefix(raw, "[") { + var items []json.RawMessage + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return nil, true, err + } + return items, true, nil + } + return []json.RawMessage{json.RawMessage(raw)}, true, nil + } + if inputValue.Type == gjson.String { + encoded, _ := json.Marshal(inputValue.String()) + return []json.RawMessage{encoded}, true, nil + } + return []json.RawMessage{json.RawMessage(inputValue.Raw)}, true, nil +} + +func openAIWSInputIsPrefixExtended(previousPayload, currentPayload []byte) (bool, error) { + previousItems, previousExists, prevErr := openAIWSExtractNormalizedInputSequence(previousPayload) + if prevErr != nil { + return false, prevErr + } + currentItems, currentExists, currentErr := openAIWSExtractNormalizedInputSequence(currentPayload) + if currentErr != nil { + return false, currentErr + } + if !previousExists && !currentExists { + return true, nil + } + if !previousExists { + return len(currentItems) == 0, nil + } + if !currentExists { + return len(previousItems) == 0, nil + } + if len(currentItems) < len(previousItems) { + return false, nil + } + + for idx := range previousItems { + previousNormalized := normalizeOpenAIWSJSONForCompareOrRaw(previousItems[idx]) + currentNormalized := normalizeOpenAIWSJSONForCompareOrRaw(currentItems[idx]) + if !bytes.Equal(previousNormalized, currentNormalized) { + return false, nil + } + } + return true, nil +} + +func openAIWSRawItemsHasPrefix(items []json.RawMessage, prefix []json.RawMessage) bool { + if len(prefix) == 0 { + return true + } + if len(items) < len(prefix) { + return false + } + for idx := range prefix { + previousNormalized := normalizeOpenAIWSJSONForCompareOrRaw(prefix[idx]) + currentNormalized := normalizeOpenAIWSJSONForCompareOrRaw(items[idx]) + if !bytes.Equal(previousNormalized, currentNormalized) { + return false + } + } + return true +} + +func openAIWSRawItemsHasFunctionCallOutput(items []json.RawMessage) bool { + for _, item := range items { + if isCodexToolCallOutputItemType(gjson.GetBytes(item, "type").String()) { + return true + } + } + return false +} + +func openAIWSRawItemsHaveToolCallContextForOutputs(items []json.RawMessage) bool { + if len(items) == 0 { + return false + } + contextCallIDs := make(map[string]struct{}) + outputCallIDs := make(map[string]struct{}) + for _, item := range items { + itemType := gjson.GetBytes(item, "type").String() + callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String()) + switch { + case isCodexToolCallContextItemType(itemType): + if callID != "" { + contextCallIDs[callID] = struct{}{} + } + case isCodexToolCallOutputItemType(itemType): + if callID == "" { + return false + } + outputCallIDs[callID] = struct{}{} + } + } + if len(outputCallIDs) == 0 || len(contextCallIDs) == 0 { + return false + } + for callID := range outputCallIDs { + if _, ok := contextCallIDs[callID]; !ok { + return false + } + } + return true +} + +func openAIWSRawPayloadHasToolCallOutput(payload []byte) bool { + if len(payload) == 0 { + return false + } + input := gjson.GetBytes(payload, "input") + if !input.Exists() { + return false + } + if input.IsArray() { + for _, item := range input.Array() { + if isCodexToolCallOutputItemType(item.Get("type").String()) { + return true + } + } + return false + } + if input.Type == gjson.JSON { + return isCodexToolCallOutputItemType(input.Get("type").String()) + } + return false +} + +func buildOpenAIWSReplayInputSequence( + previousFullInput []json.RawMessage, + previousFullInputExists bool, + currentPayload []byte, + hasPreviousResponseID bool, +) ([]json.RawMessage, bool, error) { + currentItems, currentExists, currentErr := openAIWSExtractNormalizedInputSequence(currentPayload) + if currentErr != nil { + return nil, false, currentErr + } + if !hasPreviousResponseID { + return cloneOpenAIWSRawMessages(currentItems), currentExists, nil + } + if !previousFullInputExists { + return cloneOpenAIWSRawMessages(currentItems), currentExists, nil + } + if !currentExists || len(currentItems) == 0 { + return cloneOpenAIWSRawMessages(previousFullInput), true, nil + } + if openAIWSRawItemsHasPrefix(currentItems, previousFullInput) { + return cloneOpenAIWSRawMessages(currentItems), true, nil + } + merged := make([]json.RawMessage, 0, len(previousFullInput)+len(currentItems)) + merged = append(merged, cloneOpenAIWSRawMessages(previousFullInput)...) + merged = append(merged, cloneOpenAIWSRawMessages(currentItems)...) + return merged, true, nil +} + +func setOpenAIWSPayloadInputSequence( + payload []byte, + fullInput []json.RawMessage, + fullInputExists bool, +) ([]byte, error) { + if !fullInputExists { + return payload, nil + } + // Preserve [] vs null semantics when input exists but is empty. + inputForMarshal := fullInput + if inputForMarshal == nil { + inputForMarshal = []json.RawMessage{} + } + inputRaw, marshalErr := json.Marshal(inputForMarshal) + if marshalErr != nil { + return nil, marshalErr + } + return sjson.SetRawBytes(payload, "input", inputRaw) +} + +func shouldKeepIngressPreviousResponseID( + previousPayload []byte, + currentPayload []byte, + lastTurnResponseID string, + hasFunctionCallOutput bool, +) (bool, string, error) { + if hasFunctionCallOutput { + return true, "has_function_call_output", nil + } + currentPreviousResponseID := strings.TrimSpace(openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id")) + if currentPreviousResponseID == "" { + return false, "missing_previous_response_id", nil + } + expectedPreviousResponseID := strings.TrimSpace(lastTurnResponseID) + if expectedPreviousResponseID == "" { + return false, "missing_last_turn_response_id", nil + } + if currentPreviousResponseID != expectedPreviousResponseID { + return false, "previous_response_id_mismatch", nil + } + if len(previousPayload) == 0 { + return false, "missing_previous_turn_payload", nil + } + + previousComparable, previousComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(previousPayload) + if previousComparableErr != nil { + return false, "non_input_compare_error", previousComparableErr + } + currentComparable, currentComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(currentPayload) + if currentComparableErr != nil { + return false, "non_input_compare_error", currentComparableErr + } + if !bytes.Equal(previousComparable, currentComparable) { + return false, "non_input_changed", nil + } + return true, "strict_incremental_ok", nil +} + +type openAIWSIngressPreviousTurnStrictState struct { + nonInputComparable []byte +} + +func buildOpenAIWSIngressPreviousTurnStrictState(payload []byte) (*openAIWSIngressPreviousTurnStrictState, error) { + if len(payload) == 0 { + return nil, nil + } + nonInputComparable, nonInputErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload) + if nonInputErr != nil { + return nil, nonInputErr + } + return &openAIWSIngressPreviousTurnStrictState{ + nonInputComparable: nonInputComparable, + }, nil +} + +func shouldKeepIngressPreviousResponseIDWithStrictState( + previousState *openAIWSIngressPreviousTurnStrictState, + currentPayload []byte, + lastTurnResponseID string, + hasFunctionCallOutput bool, +) (bool, string, error) { + if hasFunctionCallOutput { + return true, "has_function_call_output", nil + } + currentPreviousResponseID := strings.TrimSpace(openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id")) + if currentPreviousResponseID == "" { + return false, "missing_previous_response_id", nil + } + expectedPreviousResponseID := strings.TrimSpace(lastTurnResponseID) + if expectedPreviousResponseID == "" { + return false, "missing_last_turn_response_id", nil + } + if currentPreviousResponseID != expectedPreviousResponseID { + return false, "previous_response_id_mismatch", nil + } + if previousState == nil { + return false, "missing_previous_turn_payload", nil + } + + currentComparable, currentComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(currentPayload) + if currentComparableErr != nil { + return false, "non_input_compare_error", currentComparableErr + } + if !bytes.Equal(previousState.nonInputComparable, currentComparable) { + return false, "non_input_changed", nil + } + return true, "strict_incremental_ok", nil +} diff --git a/backend/internal/service/openai_ws_forwarder_support.go b/backend/internal/service/openai_ws_forwarder_support.go new file mode 100644 index 0000000000..a2fc28d9e7 --- /dev/null +++ b/backend/internal/service/openai_ws_forwarder_support.go @@ -0,0 +1,659 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func (s *OpenAIGatewayService) isOpenAIWSGeneratePrewarmEnabled() bool { + return s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.PrewarmGenerateEnabled +} + +// performOpenAIWSGeneratePrewarm 在 WSv2 下执行可选的 generate=false 预热。 +// 预热默认关闭,仅在配置开启后生效;失败时按可恢复错误回退到 HTTP。 +func (s *OpenAIGatewayService) performOpenAIWSGeneratePrewarm( + ctx context.Context, + lease *openAIWSConnLease, + decision OpenAIWSProtocolDecision, + payload map[string]any, + previousResponseID string, + reqBody map[string]any, + account *Account, + stateStore OpenAIWSStateStore, + groupID int64, +) error { + if s == nil { + return nil + } + if lease == nil || account == nil { + logOpenAIWSModeInfo("prewarm_skip reason=invalid_state has_lease=%v has_account=%v", lease != nil, account != nil) + return nil + } + connID := strings.TrimSpace(lease.ConnID()) + if !s.isOpenAIWSGeneratePrewarmEnabled() { + return nil + } + if decision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { + logOpenAIWSModeInfo( + "prewarm_skip account_id=%d conn_id=%s reason=transport_not_v2 transport=%s", + account.ID, + connID, + normalizeOpenAIWSLogValue(string(decision.Transport)), + ) + return nil + } + if strings.TrimSpace(previousResponseID) != "" { + logOpenAIWSModeInfo( + "prewarm_skip account_id=%d conn_id=%s reason=has_previous_response_id previous_response_id=%s", + account.ID, + connID, + truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen), + ) + return nil + } + if lease.IsPrewarmed() { + logOpenAIWSModeInfo("prewarm_skip account_id=%d conn_id=%s reason=already_prewarmed", account.ID, connID) + return nil + } + if NeedsToolContinuation(reqBody) { + logOpenAIWSModeInfo("prewarm_skip account_id=%d conn_id=%s reason=tool_continuation", account.ID, connID) + return nil + } + prewarmStart := time.Now() + logOpenAIWSModeInfo("prewarm_start account_id=%d conn_id=%s", account.ID, connID) + + prewarmPayload := make(map[string]any, len(payload)+1) + for k, v := range payload { + prewarmPayload[k] = v + } + prewarmPayload["generate"] = false + prewarmPayloadJSON := payloadAsJSONBytes(prewarmPayload) + + if err := lease.WriteJSONWithContextTimeout(ctx, prewarmPayload, s.openAIWSWriteTimeout()); err != nil { + lease.MarkBroken() + logOpenAIWSModeInfo( + "prewarm_write_fail account_id=%d conn_id=%s cause=%s", + account.ID, + connID, + truncateOpenAIWSLogValue(err.Error(), openAIWSLogValueMaxLen), + ) + return wrapOpenAIWSFallback("prewarm_write", err) + } + logOpenAIWSModeInfo("prewarm_write_sent account_id=%d conn_id=%s payload_bytes=%d", account.ID, connID, len(prewarmPayloadJSON)) + + prewarmResponseID := "" + prewarmEventCount := 0 + prewarmTerminalCount := 0 + for { + message, readErr := lease.ReadMessageWithContextTimeout(ctx, s.openAIWSReadTimeout()) + if readErr != nil { + lease.MarkBroken() + closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr) + logOpenAIWSModeInfo( + "prewarm_read_fail account_id=%d conn_id=%s close_status=%s close_reason=%s cause=%s events=%d", + account.ID, + connID, + closeStatus, + closeReason, + truncateOpenAIWSLogValue(readErr.Error(), openAIWSLogValueMaxLen), + prewarmEventCount, + ) + return wrapOpenAIWSFallback("prewarm_"+classifyOpenAIWSReadFallbackReason(readErr), readErr) + } + + eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(message) + if eventType == "" { + continue + } + prewarmEventCount++ + if prewarmResponseID == "" && eventResponseID != "" { + prewarmResponseID = eventResponseID + } + if prewarmEventCount <= openAIWSPrewarmEventLogHead || eventType == "error" || isOpenAIWSTerminalEvent(eventType) { + logOpenAIWSModeInfo( + "prewarm_event account_id=%d conn_id=%s idx=%d type=%s bytes=%d", + account.ID, + connID, + prewarmEventCount, + truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen), + len(message), + ) + } + + if eventType == "error" { + errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(message) + s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), message, errCodeRaw, errTypeRaw, errMsgRaw) + errMsg := strings.TrimSpace(errMsgRaw) + if errMsg == "" { + errMsg = "OpenAI websocket prewarm error" + } + fallbackReason, canFallback := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) + errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) + logOpenAIWSModeInfo( + "prewarm_error_event account_id=%d conn_id=%s idx=%d fallback_reason=%s can_fallback=%v err_code=%s err_type=%s err_message=%s", + account.ID, + connID, + prewarmEventCount, + truncateOpenAIWSLogValue(fallbackReason, openAIWSLogValueMaxLen), + canFallback, + errCode, + errType, + errMessage, + ) + lease.MarkBroken() + if canFallback { + return wrapOpenAIWSFallback("prewarm_"+fallbackReason, errors.New(errMsg)) + } + return wrapOpenAIWSFallback("prewarm_error_event", errors.New(errMsg)) + } + + if isOpenAIWSTerminalEvent(eventType) { + prewarmTerminalCount++ + break + } + } + + lease.MarkPrewarmed() + if prewarmResponseID != "" && stateStore != nil { + ttl := s.openAIWSResponseStickyTTL() + logOpenAIWSBindResponseAccountWarn(groupID, account.ID, prewarmResponseID, stateStore.BindResponseAccount(ctx, groupID, prewarmResponseID, account.ID, ttl)) + stateStore.BindResponseConn(prewarmResponseID, lease.ConnID(), ttl) + } + logOpenAIWSModeInfo( + "prewarm_done account_id=%d conn_id=%s response_id=%s events=%d terminal_events=%d duration_ms=%d", + account.ID, + connID, + truncateOpenAIWSLogValue(prewarmResponseID, openAIWSIDValueMaxLen), + prewarmEventCount, + prewarmTerminalCount, + time.Since(prewarmStart).Milliseconds(), + ) + return nil +} + +func payloadAsJSON(payload map[string]any) string { + return string(payloadAsJSONBytes(payload)) +} + +func payloadAsJSONBytes(payload map[string]any) []byte { + if len(payload) == 0 { + return []byte("{}") + } + body, err := json.Marshal(payload) + if err != nil { + return []byte("{}") + } + return body +} + +func isOpenAIWSTerminalEvent(eventType string) bool { + switch strings.TrimSpace(eventType) { + case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + return true + default: + return false + } +} + +func isOpenAIWSTokenEvent(eventType string) bool { + eventType = strings.TrimSpace(eventType) + if eventType == "" { + return false + } + switch eventType { + case "response.created", "response.in_progress", "response.output_item.added", "response.output_item.done": + return false + } + if strings.Contains(eventType, ".delta") { + return true + } + if strings.HasPrefix(eventType, "response.output_text") { + return true + } + if strings.HasPrefix(eventType, "response.output") { + return true + } + // 终止事件(response.completed/done/failed/...)由 isOpenAIWSTerminalEvent 单独处理。 + // 不能把它们当作 token event,否则当上游没有可识别的 delta 时, + // firstTokenMs 会被填到终止时刻,等于把"总耗时"误报为"首 token 延迟"。 + return false +} + +func replaceOpenAIWSMessageModel(message []byte, fromModel, toModel string) []byte { + if len(message) == 0 { + return message + } + if strings.TrimSpace(fromModel) == "" || strings.TrimSpace(toModel) == "" || fromModel == toModel { + return message + } + if !bytes.Contains(message, []byte(`"model"`)) || !bytes.Contains(message, []byte(fromModel)) { + return message + } + modelValues := gjson.GetManyBytes(message, "model", "response.model") + replaceModel := modelValues[0].Exists() && modelValues[0].Str == fromModel + replaceResponseModel := modelValues[1].Exists() && modelValues[1].Str == fromModel + if !replaceModel && !replaceResponseModel { + return message + } + updated := message + if replaceModel { + if next, err := sjson.SetBytes(updated, "model", toModel); err == nil { + updated = next + } + } + if replaceResponseModel { + if next, err := sjson.SetBytes(updated, "response.model", toModel); err == nil { + updated = next + } + } + return updated +} + +func populateOpenAIUsageFromResponseJSON(body []byte, usage *OpenAIUsage) { + if usage == nil || len(body) == 0 { + return + } + values := gjson.GetManyBytes( + body, + "usage.input_tokens", + "usage.output_tokens", + "usage.input_tokens_details.cached_tokens", + ) + usage.InputTokens = int(values[0].Int()) + usage.OutputTokens = int(values[1].Int()) + usage.CacheReadInputTokens = int(values[2].Int()) +} + +func getOpenAIGroupIDFromContext(c *gin.Context) int64 { + if c == nil { + return 0 + } + value, exists := c.Get("api_key") + if !exists { + return 0 + } + apiKey, ok := value.(*APIKey) + if !ok || apiKey == nil || apiKey.GroupID == nil { + return 0 + } + return *apiKey.GroupID +} + +// SelectAccountByPreviousResponseID 按 previous_response_id 命中账号粘连。 +// 未命中或账号不可用时返回 (nil, nil),由调用方继续走常规调度。 +func (s *OpenAIGatewayService) SelectAccountByPreviousResponseID( + ctx context.Context, + groupID *int64, + previousResponseID string, + requestedModel string, + excludedIDs map[int64]struct{}, + requireCompact bool, +) (*AccountSelectionResult, error) { + return s.selectAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, "", requireCompact) +} + +func (s *OpenAIGatewayService) selectAccountByPreviousResponseIDForCapability( + ctx context.Context, + groupID *int64, + previousResponseID string, + requestedModel string, + excludedIDs map[int64]struct{}, + requiredCapability OpenAIEndpointCapability, + requireCompact bool, +) (*AccountSelectionResult, error) { + if s == nil { + return nil, nil + } + accountID, account, responseID, store := s.resolveAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, requiredCapability, requireCompact) + if accountID <= 0 || account == nil || store == nil { + return nil, nil + } + + result, acquireErr := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) + if acquireErr == nil && result.Acquired { + logOpenAIWSBindResponseAccountWarn( + derefGroupID(groupID), + accountID, + responseID, + store.BindResponseAccount(ctx, derefGroupID(groupID), responseID, accountID, s.openAIWSResponseStickyTTL()), + ) + return &AccountSelectionResult{ + Account: account, + Acquired: true, + ReleaseFunc: result.ReleaseFunc, + }, nil + } + + cfg := s.schedulingConfig() + if s.concurrencyService != nil { + return &AccountSelectionResult{ + Account: account, + WaitPlan: &AccountWaitPlan{ + AccountID: accountID, + MaxConcurrency: account.Concurrency, + Timeout: cfg.StickySessionWaitTimeout, + MaxWaiting: cfg.StickySessionMaxWaiting, + }, + }, nil + } + return nil, nil +} + +func (s *OpenAIGatewayService) ResolveAccountIDByPreviousResponseIDForScheduler( + ctx context.Context, + groupID *int64, + previousResponseID string, + requestedModel string, + excludedIDs map[int64]struct{}, + requiredCapability OpenAIEndpointCapability, + requireCompact bool, +) int64 { + accountID, _, _, _ := s.resolveAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, requiredCapability, requireCompact) + return accountID +} + +func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability( + ctx context.Context, + groupID *int64, + previousResponseID string, + requestedModel string, + excludedIDs map[int64]struct{}, + requiredCapability OpenAIEndpointCapability, + requireCompact bool, +) (int64, *Account, string, OpenAIWSStateStore) { + if s == nil { + return 0, nil, "", nil + } + responseID := strings.TrimSpace(previousResponseID) + if responseID == "" { + return 0, nil, "", nil + } + store := s.getOpenAIWSStateStore() + if store == nil { + return 0, nil, "", nil + } + + accountID, err := store.GetResponseAccount(ctx, derefGroupID(groupID), responseID) + if err != nil || accountID <= 0 { + return 0, nil, "", nil + } + if excludedIDs != nil { + if _, excluded := excludedIDs[accountID]; excluded { + return 0, nil, "", nil + } + } + + account, err := s.getSchedulableAccount(ctx, accountID) + if err != nil || account == nil { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + // 非 WSv2 场景(如 force_http/全局关闭)不应使用 previous_response_id 粘连, + // 以保持“回滚到 HTTP”后的历史行为一致性。 + if s.getOpenAIWSProtocolResolver().Resolve(account).Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { + return 0, nil, "", nil + } + if shouldClearStickySession(account, requestedModel) || !account.IsOpenAI() || !account.IsSchedulable() { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + if requestedModel != "" && !account.IsModelSupported(requestedModel) { + return 0, nil, "", nil + } + if !account.SupportsOpenAIEndpointCapability(requiredCapability) { + return 0, nil, "", nil + } + // Quota auto-pause must also gate the previous_response_id sticky path; otherwise an + // account over its 5h/7d threshold keeps serving the same response chain even though + // normal scheduling skips it. Pause is transient, so fall through to normal scheduling + // without deleting the binding (the window may reset before the next turn). + if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { + return 0, nil, "", nil + } + if s.schedulerSnapshot != nil && s.accountRepo != nil { + latest, latestErr := s.accountRepo.GetByID(ctx, account.ID) + if latestErr != nil || latest == nil { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + if shouldClearStickySession(latest, requestedModel) || !latest.IsOpenAI() || !latest.IsSchedulable() { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + if requestedModel != "" && !latest.IsModelSupported(requestedModel) { + return 0, nil, "", nil + } + if !latest.SupportsOpenAIEndpointCapability(requiredCapability) { + return 0, nil, "", nil + } + if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, latest); paused { + return 0, nil, "", nil + } + if s.isOpenAIAccountRuntimeBlocked(latest) { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + account = latest + } + if requireCompact && openAICompactSupportTier(account) == 0 { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + return accountID, account, responseID, store +} + +func classifyOpenAIWSAcquireError(err error) string { + if err == nil { + return "acquire_conn" + } + var dialErr *openAIWSDialError + if errors.As(err, &dialErr) { + switch dialErr.StatusCode { + case 426: + return "upgrade_required" + case 401, 403: + return "auth_failed" + case 429: + return "upstream_rate_limited" + } + if dialErr.StatusCode >= 500 { + return "upstream_5xx" + } + return "dial_failed" + } + if errors.Is(err, errOpenAIWSConnQueueFull) { + return "conn_queue_full" + } + if errors.Is(err, errOpenAIWSPreferredConnUnavailable) { + return "preferred_conn_unavailable" + } + if errors.Is(err, context.DeadlineExceeded) { + return "acquire_timeout" + } + return "acquire_conn" +} + +func isOpenAIWSRateLimitError(codeRaw, errTypeRaw, msgRaw string) bool { + code := strings.ToLower(strings.TrimSpace(codeRaw)) + errType := strings.ToLower(strings.TrimSpace(errTypeRaw)) + msg := strings.ToLower(strings.TrimSpace(msgRaw)) + + if strings.Contains(errType, "rate_limit") || strings.Contains(errType, "usage_limit") { + return true + } + if strings.Contains(code, "rate_limit") || strings.Contains(code, "usage_limit") || strings.Contains(code, "insufficient_quota") { + return true + } + if strings.Contains(msg, "usage limit") && strings.Contains(msg, "reached") { + return true + } + if strings.Contains(msg, "rate limit") && (strings.Contains(msg, "reached") || strings.Contains(msg, "exceeded")) { + return true + } + return false +} + +func (s *OpenAIGatewayService) persistOpenAIWSRateLimitSignal(ctx context.Context, account *Account, headers http.Header, responseBody []byte, codeRaw, errTypeRaw, msgRaw string) { + if s == nil || s.rateLimitService == nil || account == nil || account.Platform != PlatformOpenAI { + return + } + if !isOpenAIWSRateLimitError(codeRaw, errTypeRaw, msgRaw) { + return + } + s.handleOpenAIAccountUpstreamError(ctx, account, http.StatusTooManyRequests, headers, responseBody) +} + +func classifyOpenAIWSErrorEventFromRaw(codeRaw, errTypeRaw, msgRaw string) (string, bool) { + code := strings.ToLower(strings.TrimSpace(codeRaw)) + errType := strings.ToLower(strings.TrimSpace(errTypeRaw)) + msg := strings.ToLower(strings.TrimSpace(msgRaw)) + + switch code { + case "upgrade_required": + return "upgrade_required", true + case "websocket_not_supported", "websocket_unsupported": + return "ws_unsupported", true + case "websocket_connection_limit_reached": + return "ws_connection_limit_reached", true + case "invalid_encrypted_content": + return "invalid_encrypted_content", true + case "previous_response_not_found": + return "previous_response_not_found", true + } + if isOpenAIWSRateLimitError(codeRaw, errTypeRaw, msgRaw) { + return "upstream_rate_limited", false + } + if strings.Contains(msg, "upgrade required") || strings.Contains(msg, "status 426") { + return "upgrade_required", true + } + if strings.Contains(errType, "upgrade") { + return "upgrade_required", true + } + if strings.Contains(msg, "websocket") && strings.Contains(msg, "unsupported") { + return "ws_unsupported", true + } + if strings.Contains(msg, "connection limit") && strings.Contains(msg, "websocket") { + return "ws_connection_limit_reached", true + } + if strings.Contains(msg, "invalid_encrypted_content") || + (strings.Contains(msg, "encrypted content") && strings.Contains(msg, "could not be verified")) { + return "invalid_encrypted_content", true + } + if strings.Contains(msg, "previous_response_not_found") || + (strings.Contains(msg, "previous response") && strings.Contains(msg, "not found")) { + return "previous_response_not_found", true + } + if strings.Contains(errType, "server_error") || strings.Contains(code, "server_error") { + return "upstream_error_event", true + } + return "event_error", false +} + +func classifyOpenAIWSErrorEvent(message []byte) (string, bool) { + if len(message) == 0 { + return "event_error", false + } + return classifyOpenAIWSErrorEventFromRaw(parseOpenAIWSErrorEventFields(message)) +} + +func openAIWSErrorHTTPStatusFromRaw(codeRaw, errTypeRaw string) int { + code := strings.ToLower(strings.TrimSpace(codeRaw)) + errType := strings.ToLower(strings.TrimSpace(errTypeRaw)) + switch { + case strings.Contains(errType, "invalid_request"), + strings.Contains(code, "invalid_request"), + strings.Contains(code, "bad_request"), + code == "invalid_encrypted_content", + code == "previous_response_not_found": + return http.StatusBadRequest + case strings.Contains(errType, "authentication"), + strings.Contains(code, "invalid_api_key"), + strings.Contains(code, "unauthorized"): + return http.StatusUnauthorized + case strings.Contains(errType, "permission"), + strings.Contains(code, "forbidden"): + return http.StatusForbidden + case isOpenAIWSRateLimitError(codeRaw, errTypeRaw, ""): + return http.StatusTooManyRequests + default: + return http.StatusBadGateway + } +} + +func openAIWSErrorHTTPStatus(message []byte) int { + if len(message) == 0 { + return http.StatusBadGateway + } + codeRaw, errTypeRaw, _ := parseOpenAIWSErrorEventFields(message) + return openAIWSErrorHTTPStatusFromRaw(codeRaw, errTypeRaw) +} + +func (s *OpenAIGatewayService) openAIWSFallbackCooldown() time.Duration { + if s == nil || s.cfg == nil { + return 30 * time.Second + } + seconds := s.cfg.Gateway.OpenAIWS.FallbackCooldownSeconds + if seconds <= 0 { + return 0 + } + return time.Duration(seconds) * time.Second +} + +func (s *OpenAIGatewayService) isOpenAIWSFallbackCooling(accountID int64) bool { + if s == nil || accountID <= 0 { + return false + } + cooldown := s.openAIWSFallbackCooldown() + if cooldown <= 0 { + return false + } + rawUntil, ok := s.openaiWSFallbackUntil.Load(accountID) + if !ok || rawUntil == nil { + return false + } + until, ok := rawUntil.(time.Time) + if !ok || until.IsZero() { + s.openaiWSFallbackUntil.Delete(accountID) + return false + } + if time.Now().Before(until) { + return true + } + s.openaiWSFallbackUntil.Delete(accountID) + return false +} + +func (s *OpenAIGatewayService) markOpenAIWSFallbackCooling(accountID int64, _ string) { + if s == nil || accountID <= 0 { + return + } + cooldown := s.openAIWSFallbackCooldown() + if cooldown <= 0 { + return + } + s.openaiWSFallbackUntil.Store(accountID, time.Now().Add(cooldown)) +} + +func (s *OpenAIGatewayService) clearOpenAIWSFallbackCooling(accountID int64) { + if s == nil || accountID <= 0 { + return + } + s.openaiWSFallbackUntil.Delete(accountID) +} diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go new file mode 100644 index 0000000000..ea8c0a0722 --- /dev/null +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -0,0 +1,732 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +func (s *OpenAIGatewayService) forwardOpenAIWSV2( + ctx context.Context, + c *gin.Context, + account *Account, + reqBody map[string]any, + token string, + decision OpenAIWSProtocolDecision, + isCodexCLI bool, + reqStream bool, + originalModel string, + mappedModel string, + startTime time.Time, + attempt int, + lastFailureReason string, +) (*OpenAIForwardResult, error) { + if s == nil || account == nil { + return nil, wrapOpenAIWSFallback("invalid_state", errors.New("service or account is nil")) + } + + wsURL, err := s.buildOpenAIResponsesWSURL(account) + if err != nil { + return nil, wrapOpenAIWSFallback("build_ws_url", err) + } + wsHost := "-" + wsPath := "-" + if parsed, parseErr := url.Parse(wsURL); parseErr == nil && parsed != nil { + if h := strings.TrimSpace(parsed.Host); h != "" { + wsHost = normalizeOpenAIWSLogValue(h) + } + if p := strings.TrimSpace(parsed.Path); p != "" { + wsPath = normalizeOpenAIWSLogValue(p) + } + } + logOpenAIWSModeDebug( + "dial_target account_id=%d account_type=%s ws_host=%s ws_path=%s", + account.ID, + account.Type, + wsHost, + wsPath, + ) + + payload := s.buildOpenAIWSCreatePayload(reqBody, account) + payloadStrategy, removedKeys := applyOpenAIWSRetryPayloadStrategy(payload, attempt) + previousResponseID := openAIWSPayloadString(payload, "previous_response_id") + previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID) + promptCacheKey := openAIWSPayloadString(payload, "prompt_cache_key") + _, hasTools := payload["tools"] + debugEnabled := isOpenAIWSModeDebugEnabled() + payloadBytes := -1 + resolvePayloadBytes := func() int { + if payloadBytes >= 0 { + return payloadBytes + } + payloadBytes = len(payloadAsJSONBytes(payload)) + return payloadBytes + } + streamValue := "-" + if raw, ok := payload["stream"]; ok { + streamValue = normalizeOpenAIWSLogValue(strings.TrimSpace(fmt.Sprintf("%v", raw))) + } + turnState := "" + turnMetadata := "" + if c != nil && c.Request != nil { + turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) + turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)) + } + setOpenAIWSTurnMetadata(payload, turnMetadata) + payloadEventType := openAIWSPayloadString(payload, "type") + if payloadEventType == "" { + payloadEventType = "response.create" + } + if s.shouldEmitOpenAIWSPayloadSchema(attempt) { + logOpenAIWSModeInfo( + "[debug] payload_schema account_id=%d attempt=%d event=%s payload_keys=%s payload_bytes=%d payload_key_sizes=%s input_summary=%s stream=%s payload_strategy=%s removed_keys=%s has_previous_response_id=%v has_prompt_cache_key=%v has_tools=%v", + account.ID, + attempt, + payloadEventType, + normalizeOpenAIWSLogValue(strings.Join(sortedKeys(payload), ",")), + resolvePayloadBytes(), + normalizeOpenAIWSLogValue(summarizeOpenAIWSPayloadKeySizes(payload, openAIWSPayloadKeySizeTopN)), + normalizeOpenAIWSLogValue(summarizeOpenAIWSInput(payload["input"])), + streamValue, + normalizeOpenAIWSLogValue(payloadStrategy), + normalizeOpenAIWSLogValue(strings.Join(removedKeys, ",")), + previousResponseID != "", + promptCacheKey != "", + hasTools, + ) + } + + stateStore := s.getOpenAIWSStateStore() + groupID := getOpenAIGroupIDFromContext(c) + sessionHash := s.GenerateSessionHash(c, nil) + if sessionHash == "" { + var legacySessionHash string + sessionHash, legacySessionHash = openAIWSSessionHashesFromID(promptCacheKey) + attachOpenAILegacySessionHashToGin(c, legacySessionHash) + } + if turnState == "" && stateStore != nil && sessionHash != "" { + if savedTurnState, ok := stateStore.GetSessionTurnState(groupID, sessionHash); ok { + turnState = savedTurnState + } + } + preferredConnID := "" + if stateStore != nil && previousResponseID != "" { + if connID, ok := stateStore.GetResponseConn(previousResponseID); ok { + preferredConnID = connID + } + } + storeDisabled := s.isOpenAIWSStoreDisabledInRequest(reqBody, account) + if stateStore != nil && storeDisabled && previousResponseID == "" && sessionHash != "" { + if connID, ok := stateStore.GetSessionConn(groupID, sessionHash); ok { + preferredConnID = connID + } + } + storeDisabledConnMode := s.openAIWSStoreDisabledConnMode() + forceNewConnByPolicy := shouldForceNewConnOnStoreDisabled(storeDisabledConnMode, lastFailureReason) + forceNewConn := forceNewConnByPolicy && storeDisabled && previousResponseID == "" && sessionHash != "" && preferredConnID == "" + wsHeaders, sessionResolution, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, decision, isCodexCLI, turnState, turnMetadata, promptCacheKey) + if buildHdrErr != nil { + return nil, fmt.Errorf("build ws headers: %w", buildHdrErr) + } + logOpenAIWSModeDebug( + "acquire_start account_id=%d account_type=%s transport=%s preferred_conn_id=%s has_previous_response_id=%v session_hash=%s has_turn_state=%v turn_state_len=%d has_turn_metadata=%v turn_metadata_len=%d store_disabled=%v store_disabled_conn_mode=%s retry_last_reason=%s force_new_conn=%v header_user_agent=%s header_openai_beta=%s header_originator=%s header_accept_language=%s header_session_id=%s header_conversation_id=%s session_id_source=%s conversation_id_source=%s has_prompt_cache_key=%v has_chatgpt_account_id=%v has_authorization=%v has_session_id=%v has_conversation_id=%v proxy_enabled=%v", + account.ID, + account.Type, + normalizeOpenAIWSLogValue(string(decision.Transport)), + truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen), + previousResponseID != "", + truncateOpenAIWSLogValue(sessionHash, 12), + turnState != "", + len(turnState), + turnMetadata != "", + len(turnMetadata), + storeDisabled, + normalizeOpenAIWSLogValue(storeDisabledConnMode), + truncateOpenAIWSLogValue(lastFailureReason, openAIWSLogValueMaxLen), + forceNewConn, + openAIWSHeaderValueForLog(wsHeaders, "user-agent"), + openAIWSHeaderValueForLog(wsHeaders, "openai-beta"), + openAIWSHeaderValueForLog(wsHeaders, "originator"), + openAIWSHeaderValueForLog(wsHeaders, "accept-language"), + openAIWSHeaderValueForLog(wsHeaders, "session_id"), + openAIWSHeaderValueForLog(wsHeaders, "conversation_id"), + normalizeOpenAIWSLogValue(sessionResolution.SessionSource), + normalizeOpenAIWSLogValue(sessionResolution.ConversationSource), + promptCacheKey != "", + hasOpenAIWSHeader(wsHeaders, "chatgpt-account-id"), + hasOpenAIWSHeader(wsHeaders, "authorization"), + hasOpenAIWSHeader(wsHeaders, "session_id"), + hasOpenAIWSHeader(wsHeaders, "conversation_id"), + account.ProxyID != nil && account.Proxy != nil, + ) + + acquireCtx, acquireCancel := context.WithTimeout(ctx, s.openAIWSAcquireTimeout()) + defer acquireCancel() + + lease, err := s.getOpenAIWSConnPool().Acquire(acquireCtx, openAIWSAcquireRequest{ + Account: account, + WSURL: wsURL, + Headers: wsHeaders, + PreferredConnID: preferredConnID, + ForceNewConn: forceNewConn, + ProxyURL: func() string { + if account.ProxyID != nil && account.Proxy != nil { + return account.Proxy.URL() + } + return "" + }(), + }) + if err != nil { + dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(err) + logOpenAIWSModeInfo( + "acquire_fail account_id=%d account_type=%s transport=%s reason=%s dial_status=%d dial_class=%s dial_close_status=%s dial_close_reason=%s dial_resp_server=%s dial_resp_via=%s dial_resp_cf_ray=%s dial_resp_x_request_id=%s cause=%s preferred_conn_id=%s force_new_conn=%v ws_host=%s ws_path=%s proxy_enabled=%v", + account.ID, + account.Type, + normalizeOpenAIWSLogValue(string(decision.Transport)), + normalizeOpenAIWSLogValue(classifyOpenAIWSAcquireError(err)), + dialStatus, + dialClass, + dialCloseStatus, + truncateOpenAIWSLogValue(dialCloseReason, openAIWSHeaderValueMaxLen), + dialRespServer, + dialRespVia, + dialRespCFRay, + dialRespReqID, + truncateOpenAIWSLogValue(err.Error(), openAIWSLogValueMaxLen), + truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen), + forceNewConn, + wsHost, + wsPath, + account.ProxyID != nil && account.Proxy != nil, + ) + var dialErr *openAIWSDialError + if errors.As(err, &dialErr) && dialErr != nil && dialErr.StatusCode == http.StatusTooManyRequests { + s.persistOpenAIWSRateLimitSignal(ctx, account, dialErr.ResponseHeaders, nil, "rate_limit_exceeded", "rate_limit_error", strings.TrimSpace(err.Error())) + } + return nil, wrapOpenAIWSFallback(classifyOpenAIWSAcquireError(err), err) + } + // cleanExit 标记正常终端事件退出,此时上游不会再发送帧,连接可安全归还复用。 + // 所有异常路径(读写错误、error 事件等)已在各自分支中提前调用 MarkBroken, + // 因此 defer 中只需处理正常退出时不 MarkBroken 即可。 + cleanExit := false + defer func() { + if !cleanExit { + lease.MarkBroken() + } + lease.Release() + }() + connID := strings.TrimSpace(lease.ConnID()) + logOpenAIWSModeDebug( + "connected account_id=%d account_type=%s transport=%s conn_id=%s conn_reused=%v conn_pick_ms=%d queue_wait_ms=%d has_previous_response_id=%v", + account.ID, + account.Type, + normalizeOpenAIWSLogValue(string(decision.Transport)), + connID, + lease.Reused(), + lease.ConnPickDuration().Milliseconds(), + lease.QueueWaitDuration().Milliseconds(), + previousResponseID != "", + ) + if previousResponseID != "" { + logOpenAIWSModeInfo( + "continuation_probe account_id=%d account_type=%s conn_id=%s previous_response_id=%s previous_response_id_kind=%s preferred_conn_id=%s conn_reused=%v store_disabled=%v session_hash=%s header_session_id=%s header_conversation_id=%s session_id_source=%s conversation_id_source=%s has_turn_state=%v turn_state_len=%d has_prompt_cache_key=%v", + account.ID, + account.Type, + truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(previousResponseIDKind), + truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen), + lease.Reused(), + storeDisabled, + truncateOpenAIWSLogValue(sessionHash, 12), + openAIWSHeaderValueForLog(wsHeaders, "session_id"), + openAIWSHeaderValueForLog(wsHeaders, "conversation_id"), + normalizeOpenAIWSLogValue(sessionResolution.SessionSource), + normalizeOpenAIWSLogValue(sessionResolution.ConversationSource), + turnState != "", + len(turnState), + promptCacheKey != "", + ) + } + if c != nil { + SetOpsLatencyMs(c, OpsOpenAIWSConnPickMsKey, lease.ConnPickDuration().Milliseconds()) + SetOpsLatencyMs(c, OpsOpenAIWSQueueWaitMsKey, lease.QueueWaitDuration().Milliseconds()) + c.Set(OpsOpenAIWSConnReusedKey, lease.Reused()) + if connID != "" { + c.Set(OpsOpenAIWSConnIDKey, connID) + } + } + + handshakeTurnState := strings.TrimSpace(lease.HandshakeHeader(openAIWSTurnStateHeader)) + logOpenAIWSModeDebug( + "handshake account_id=%d conn_id=%s has_turn_state=%v turn_state_len=%d", + account.ID, + connID, + handshakeTurnState != "", + len(handshakeTurnState), + ) + if handshakeTurnState != "" { + if stateStore != nil && sessionHash != "" { + stateStore.BindSessionTurnState(groupID, sessionHash, handshakeTurnState, s.openAIWSSessionStickyTTL()) + } + if c != nil { + c.Header(http.CanonicalHeaderKey(openAIWSTurnStateHeader), handshakeTurnState) + } + } + + if err := s.performOpenAIWSGeneratePrewarm( + ctx, + lease, + decision, + payload, + previousResponseID, + reqBody, + account, + stateStore, + groupID, + ); err != nil { + return nil, err + } + + if err := lease.WriteJSONWithContextTimeout(ctx, payload, s.openAIWSWriteTimeout()); err != nil { + lease.MarkBroken() + logOpenAIWSModeInfo( + "write_request_fail account_id=%d conn_id=%s cause=%s payload_bytes=%d", + account.ID, + connID, + truncateOpenAIWSLogValue(err.Error(), openAIWSLogValueMaxLen), + resolvePayloadBytes(), + ) + return nil, wrapOpenAIWSFallback("write_request", err) + } + if debugEnabled { + logOpenAIWSModeDebug( + "write_request_sent account_id=%d conn_id=%s stream=%v payload_bytes=%d previous_response_id=%s", + account.ID, + connID, + reqStream, + resolvePayloadBytes(), + truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen), + ) + } + + usage := &OpenAIUsage{} + imageCounter := newOpenAIImageOutputCounter() + var firstTokenMs *int + responseID := "" + var finalResponse []byte + wroteDownstream := false + needModelReplace := originalModel != mappedModel + var mappedModelBytes []byte + if needModelReplace && mappedModel != "" { + mappedModelBytes = []byte(mappedModel) + } + bufferedStreamEvents := make([][]byte, 0, 4) + eventCount := 0 + tokenEventCount := 0 + terminalEventCount := 0 + bufferedEventCount := 0 + flushedBufferedEventCount := 0 + firstEventType := "" + lastEventType := "" + + var flusher http.Flusher + if reqStream { + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), http.Header{}, s.responseHeaderFilter) + } + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + f, ok := c.Writer.(http.Flusher) + if !ok { + lease.MarkBroken() + return nil, wrapOpenAIWSFallback("streaming_not_supported", errors.New("streaming not supported")) + } + flusher = f + } + + clientDisconnected := false + flushBatchSize := s.openAIWSEventFlushBatchSize() + flushInterval := s.openAIWSEventFlushInterval() + pendingFlushEvents := 0 + lastFlushAt := time.Now() + flushStreamWriter := func(force bool) { + if clientDisconnected || flusher == nil || pendingFlushEvents <= 0 { + return + } + if !force && flushBatchSize > 1 && pendingFlushEvents < flushBatchSize { + if flushInterval <= 0 || time.Since(lastFlushAt) < flushInterval { + return + } + } + flusher.Flush() + pendingFlushEvents = 0 + lastFlushAt = time.Now() + } + emitStreamMessage := func(message []byte, forceFlush bool) { + if clientDisconnected { + return + } + frame := make([]byte, 0, len(message)+8) + frame = append(frame, "data: "...) + frame = append(frame, message...) + frame = append(frame, '\n', '\n') + _, wErr := c.Writer.Write(frame) + if wErr == nil { + wroteDownstream = true + pendingFlushEvents++ + flushStreamWriter(forceFlush) + return + } + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "[OpenAI WS Mode] client disconnected, continue draining upstream: account=%d", account.ID) + } + flushBufferedStreamEvents := func(reason string) { + if len(bufferedStreamEvents) == 0 { + return + } + flushed := len(bufferedStreamEvents) + for _, buffered := range bufferedStreamEvents { + emitStreamMessage(buffered, false) + } + bufferedStreamEvents = bufferedStreamEvents[:0] + flushStreamWriter(true) + flushedBufferedEventCount += flushed + if debugEnabled { + logOpenAIWSModeDebug( + "buffer_flush account_id=%d conn_id=%s reason=%s flushed=%d total_flushed=%d client_disconnected=%v", + account.ID, + connID, + truncateOpenAIWSLogValue(reason, openAIWSLogValueMaxLen), + flushed, + flushedBufferedEventCount, + clientDisconnected, + ) + } + } + + readTimeout := s.openAIWSReadTimeout() + + for { + message, readErr := lease.ReadMessageWithContextTimeout(ctx, readTimeout) + if readErr != nil { + lease.MarkBroken() + closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr) + logOpenAIWSModeInfo( + "read_fail account_id=%d conn_id=%s wrote_downstream=%v close_status=%s close_reason=%s cause=%s events=%d token_events=%d terminal_events=%d buffered_pending=%d buffered_flushed=%d first_event=%s last_event=%s", + account.ID, + connID, + wroteDownstream, + closeStatus, + closeReason, + truncateOpenAIWSLogValue(readErr.Error(), openAIWSLogValueMaxLen), + eventCount, + tokenEventCount, + terminalEventCount, + len(bufferedStreamEvents), + flushedBufferedEventCount, + truncateOpenAIWSLogValue(firstEventType, openAIWSLogValueMaxLen), + truncateOpenAIWSLogValue(lastEventType, openAIWSLogValueMaxLen), + ) + if !wroteDownstream { + return nil, wrapOpenAIWSFallback(classifyOpenAIWSReadFallbackReason(readErr), readErr) + } + if clientDisconnected { + break + } + setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(readErr.Error()), "") + return nil, fmt.Errorf("openai ws read event: %w", readErr) + } + + eventType, eventResponseID, responseField := parseOpenAIWSEventEnvelope(message) + if eventType == "" { + continue + } + eventCount++ + if firstEventType == "" { + firstEventType = eventType + } + lastEventType = eventType + + if responseID == "" && eventResponseID != "" { + responseID = eventResponseID + } + + isTokenEvent := isOpenAIWSTokenEvent(eventType) + if isTokenEvent { + tokenEventCount++ + } + isTerminalEvent := isOpenAIWSTerminalEvent(eventType) + if isTerminalEvent { + terminalEventCount++ + } + if firstTokenMs == nil && isTokenEvent { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + if debugEnabled && shouldLogOpenAIWSEvent(eventCount, eventType) { + logOpenAIWSModeDebug( + "event_received account_id=%d conn_id=%s idx=%d type=%s bytes=%d token=%v terminal=%v buffered_pending=%d", + account.ID, + connID, + eventCount, + truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen), + len(message), + isTokenEvent, + isTerminalEvent, + len(bufferedStreamEvents), + ) + } + + if !clientDisconnected { + if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && bytes.Contains(message, mappedModelBytes) { + message = replaceOpenAIWSMessageModel(message, mappedModel, originalModel) + } + if openAIWSEventMayContainToolCalls(eventType) && openAIWSMessageLikelyContainsToolCalls(message) { + if corrected, changed := s.toolCorrector.CorrectToolCallsInSSEBytes(message); changed { + message = corrected + } + } + } + if openAIWSEventShouldParseUsage(eventType) { + parseOpenAIWSResponseUsageFromCompletedEvent(message, usage) + } + imageCounter.AddSSEData(message) + + if eventType == "response.failed" { + if hit, code, msg := detectOpenAICyberPolicy(message); hit { + MarkOpsCyberPolicy(c, CyberPolicyMark{ + Code: code, + Message: msg, + Body: truncateString(string(message), 4096), + UpstreamStatus: http.StatusOK, + UpstreamInTok: usage.InputTokens, + UpstreamOutTok: usage.OutputTokens, + }) + } + } + + if eventType == "error" { + errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(message) + s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), message, errCodeRaw, errTypeRaw, errMsgRaw) + errMsg := strings.TrimSpace(errMsgRaw) + if errMsg == "" { + errMsg = "Upstream websocket error" + } + fallbackReason, canFallback := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) + errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) + logOpenAIWSModeInfo( + "error_event account_id=%d conn_id=%s idx=%d fallback_reason=%s can_fallback=%v err_code=%s err_type=%s err_message=%s", + account.ID, + connID, + eventCount, + truncateOpenAIWSLogValue(fallbackReason, openAIWSLogValueMaxLen), + canFallback, + errCode, + errType, + errMessage, + ) + if fallbackReason == "previous_response_not_found" { + logOpenAIWSModeInfo( + "previous_response_not_found_diag account_id=%d account_type=%s conn_id=%s previous_response_id=%s previous_response_id_kind=%s response_id=%s event_idx=%d req_stream=%v store_disabled=%v conn_reused=%v session_hash=%s header_session_id=%s header_conversation_id=%s session_id_source=%s conversation_id_source=%s has_turn_state=%v turn_state_len=%d has_prompt_cache_key=%v err_code=%s err_type=%s err_message=%s", + account.ID, + account.Type, + connID, + truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen), + normalizeOpenAIWSLogValue(previousResponseIDKind), + truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen), + eventCount, + reqStream, + storeDisabled, + lease.Reused(), + truncateOpenAIWSLogValue(sessionHash, 12), + openAIWSHeaderValueForLog(wsHeaders, "session_id"), + openAIWSHeaderValueForLog(wsHeaders, "conversation_id"), + normalizeOpenAIWSLogValue(sessionResolution.SessionSource), + normalizeOpenAIWSLogValue(sessionResolution.ConversationSource), + turnState != "", + len(turnState), + promptCacheKey != "", + errCode, + errType, + errMessage, + ) + } + // error 事件后连接不再可复用,避免回池后污染下一请求。 + lease.MarkBroken() + if !wroteDownstream && canFallback { + return nil, wrapOpenAIWSFallback(fallbackReason, errors.New(errMsg)) + } + statusCode := openAIWSErrorHTTPStatusFromRaw(errCodeRaw, errTypeRaw) + setOpsUpstreamError(c, statusCode, errMsg, "") + if reqStream && !clientDisconnected { + flushBufferedStreamEvents("error_event") + emitStreamMessage(message, true) + } + if !reqStream { + c.JSON(statusCode, gin.H{ + "error": gin.H{ + "type": "upstream_error", + "message": errMsg, + }, + }) + } + return nil, fmt.Errorf("openai ws error event: %s", errMsg) + } + + if reqStream { + // 在首个 token 前先缓冲事件(如 response.created), + // 以便上游早期断连时仍可安全回退到 HTTP,不给下游发送半截流。 + shouldBuffer := firstTokenMs == nil && !isTokenEvent && !isTerminalEvent + if shouldBuffer { + buffered := make([]byte, len(message)) + copy(buffered, message) + bufferedStreamEvents = append(bufferedStreamEvents, buffered) + bufferedEventCount++ + if debugEnabled && shouldLogOpenAIWSBufferedEvent(bufferedEventCount) { + logOpenAIWSModeDebug( + "buffer_enqueue account_id=%d conn_id=%s idx=%d event_idx=%d event_type=%s buffer_size=%d", + account.ID, + connID, + bufferedEventCount, + eventCount, + truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen), + len(bufferedStreamEvents), + ) + } + } else { + flushBufferedStreamEvents(eventType) + emitStreamMessage(message, isTerminalEvent) + } + } else { + if responseField.Exists() && responseField.Type == gjson.JSON { + finalResponse = []byte(responseField.Raw) + } + } + + if isTerminalEvent { + cleanExit = true + break + } + } + + if !reqStream { + if len(finalResponse) == 0 { + logOpenAIWSModeInfo( + "missing_final_response account_id=%d conn_id=%s events=%d token_events=%d terminal_events=%d wrote_downstream=%v", + account.ID, + connID, + eventCount, + tokenEventCount, + terminalEventCount, + wroteDownstream, + ) + if !wroteDownstream { + return nil, wrapOpenAIWSFallback("missing_final_response", errors.New("no terminal response payload")) + } + return nil, errors.New("ws finished without final response") + } + + if needModelReplace { + finalResponse = s.replaceModelInResponseBody(finalResponse, mappedModel, originalModel) + } + finalResponse = s.correctToolCallsInResponseBody(finalResponse) + populateOpenAIUsageFromResponseJSON(finalResponse, usage) + if responseID == "" { + responseID = strings.TrimSpace(gjson.GetBytes(finalResponse, "id").String()) + } + + c.Data(http.StatusOK, "application/json", finalResponse) + } else { + flushStreamWriter(true) + } + + if responseID != "" && stateStore != nil { + ttl := s.openAIWSResponseStickyTTL() + logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, stateStore.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) + stateStore.BindResponseConn(responseID, lease.ConnID(), ttl) + } + if stateStore != nil && storeDisabled && sessionHash != "" { + stateStore.BindSessionConn(groupID, sessionHash, lease.ConnID(), s.openAIWSSessionStickyTTL()) + } + firstTokenMsValue := -1 + if firstTokenMs != nil { + firstTokenMsValue = *firstTokenMs + } + logOpenAIWSModeDebug( + "completed account_id=%d conn_id=%s response_id=%s stream=%v duration_ms=%d events=%d token_events=%d terminal_events=%d buffered_events=%d buffered_flushed=%d first_event=%s last_event=%s first_token_ms=%d wrote_downstream=%v client_disconnected=%v", + account.ID, + connID, + truncateOpenAIWSLogValue(strings.TrimSpace(responseID), openAIWSIDValueMaxLen), + reqStream, + time.Since(startTime).Milliseconds(), + eventCount, + tokenEventCount, + terminalEventCount, + bufferedEventCount, + flushedBufferedEventCount, + truncateOpenAIWSLogValue(firstEventType, openAIWSLogValueMaxLen), + truncateOpenAIWSLogValue(lastEventType, openAIWSLogValueMaxLen), + firstTokenMsValue, + wroteDownstream, + clientDisconnected, + ) + + return &OpenAIForwardResult{ + RequestID: responseID, + Usage: *usage, + Model: originalModel, + UpstreamModel: mappedModel, + ImageCount: imageCounter.Count(), + ImageOutputSizes: imageCounter.Sizes(), + ServiceTier: extractOpenAIServiceTier(reqBody), + ReasoningEffort: extractOpenAIReasoningEffort(reqBody, originalModel), + Stream: reqStream, + OpenAIWSMode: true, + ResponseHeaders: lease.HandshakeHeaders(), + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + }, nil +} + +// ProxyResponsesWebSocketFromClient 处理客户端入站 WebSocket(OpenAI Responses WS Mode)并转发到上游。 +// 当前实现按“单请求 -> 终止事件 -> 下一请求”的顺序代理,适配 Codex CLI 的 turn 模式。 +// stripCodexSparkImageGenerationToolFromRawPayload removes the image_generation +// tool from a raw /responses payload when the upstream model is gpt-5.3-codex-spark. +// Spark rejects that tool upstream with HTTP 400 (invalid_request_error, param=tools); +// Codex clients advertise it by default. Returns the (possibly unchanged) payload, +// whether it changed, and any JSON decode error. +func stripCodexSparkImageGenerationToolFromRawPayload(payload []byte, model string) ([]byte, bool, error) { + if !isCodexSparkModel(model) || !openAIRequestBodyHasImageGenerationTool(payload) { + return payload, false, nil + } + return stripOpenAIImageGenerationToolFromRawPayload(payload) +} + +func stripOpenAIImageGenerationToolFromRawPayload(payload []byte) ([]byte, bool, error) { + payloadMap := make(map[string]any) + if err := json.Unmarshal(payload, &payloadMap); err != nil { + return payload, false, err + } + if !stripOpenAIImageGenerationTools(payloadMap) { + return payload, false, nil + } + rebuilt, err := json.Marshal(payloadMap) + if err != nil { + return payload, false, err + } + return rebuilt, true, nil +} diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go new file mode 100644 index 0000000000..23dc32efa9 --- /dev/null +++ b/backend/internal/service/setting_features.go @@ -0,0 +1,912 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "math" + "strconv" + "strings" +) + +// IsRegistrationEnabled 检查是否开放注册 +func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled) + if err != nil { + // 安全默认:如果设置不存在或查询出错,默认关闭注册 + return false + } + return value == "true" +} + +// IsEmailVerifyEnabled 检查是否开启邮件验证 +func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled) + if err != nil { + return false + } + return value == "true" +} + +// GetRegistrationEmailSuffixWhitelist returns normalized registration email suffix whitelist. +func (s *SettingService) GetRegistrationEmailSuffixWhitelist(ctx context.Context) []string { + value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailSuffixWhitelist) + if err != nil { + return []string{} + } + return ParseRegistrationEmailSuffixWhitelist(value) +} + +// IsPromoCodeEnabled 检查是否启用优惠码功能 +func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled) + if err != nil { + return true // 默认启用 + } + return value != "false" +} + +// IsInvitationCodeEnabled 检查是否启用邀请码注册功能 +func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled) + if err != nil { + return false // 默认关闭 + } + return value == "true" +} + +// GetCustomMenuItemsRaw returns the raw JSON string of custom_menu_items setting. +func (s *SettingService) GetCustomMenuItemsRaw(ctx context.Context) string { + value, err := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems) + if err != nil { + return "[]" + } + return value +} + +// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关) +func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled) + if err != nil { + return false // 默认关闭 + } + return value == "true" +} + +// GetAffiliateRebateRatePercent 读取并 clamp 全局返利比例。 +// 解析失败、缺失或越界都回退到 AffiliateRebateRateDefault — 该比例从不抛错, +// 调用方只关心一个可用的数值。 +func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) float64 { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateRate) + if err != nil { + return AffiliateRebateRateDefault + } + rate, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil || math.IsNaN(rate) || math.IsInf(rate, 0) { + return AffiliateRebateRateDefault + } + return clampAffiliateRebateRate(rate) +} + +// GetAffiliateRebateFreezeHours 返回返利冻结期(小时)。 +// 返回 0 表示不冻结(向后兼容)。 +func (s *SettingService) GetAffiliateRebateFreezeHours(ctx context.Context) int { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateFreezeHours) + if err != nil { + return AffiliateRebateFreezeHoursDefault + } + hours, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || hours < 0 { + return AffiliateRebateFreezeHoursDefault + } + if hours > AffiliateRebateFreezeHoursMax { + return AffiliateRebateFreezeHoursMax + } + return hours +} + +// GetAffiliateRebateDurationDays 返回返利有效期(天)。 +// 返回 0 表示永久有效。 +func (s *SettingService) GetAffiliateRebateDurationDays(ctx context.Context) int { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateDurationDays) + if err != nil { + return AffiliateRebateDurationDaysDefault + } + days, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || days < 0 { + return AffiliateRebateDurationDaysDefault + } + if days > AffiliateRebateDurationDaysMax { + return AffiliateRebateDurationDaysMax + } + return days +} + +// GetAffiliateRebatePerInviteeCap 返回单人返利上限。 +// 返回 0 表示无上限。 +func (s *SettingService) GetAffiliateRebatePerInviteeCap(ctx context.Context) float64 { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebatePerInviteeCap) + if err != nil { + return AffiliateRebatePerInviteeCapDefault + } + cap, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil || cap < 0 || math.IsNaN(cap) || math.IsInf(cap, 0) { + return AffiliateRebatePerInviteeCapDefault + } + return cap +} + +// IsPasswordResetEnabled 检查是否启用密码重置功能 +// 要求:必须同时开启邮件验证 +func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool { + // Password reset requires email verification to be enabled + if !s.IsEmailVerifyEnabled(ctx) { + return false + } + value, err := s.settingRepo.GetValue(ctx, SettingKeyPasswordResetEnabled) + if err != nil { + return false // 默认关闭 + } + return value == "true" +} + +// IsTotpEnabled 检查是否启用 TOTP 双因素认证功能 +func (s *SettingService) IsTotpEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyTotpEnabled) + if err != nil { + return false // 默认关闭 + } + return value == "true" +} + +// IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置 +// 只有手动配置了密钥才允许在管理后台启用 TOTP 功能 +func (s *SettingService) IsTotpEncryptionKeyConfigured() bool { + return s.cfg.Totp.EncryptionKeyConfigured +} + +// GetSiteName 获取网站名称 +func (s *SettingService) GetSiteName(ctx context.Context) string { + value, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) + if err != nil || value == "" { + return "Sub2API" + } + return value +} + +// GetDefaultConcurrency 获取默认并发量 +func (s *SettingService) GetDefaultConcurrency(ctx context.Context) int { + value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultConcurrency) + if err != nil { + return s.cfg.Default.UserConcurrency + } + if v, err := strconv.Atoi(value); err == nil && v > 0 { + return v + } + return s.cfg.Default.UserConcurrency +} + +// GetDefaultBalance 获取默认余额 +func (s *SettingService) GetDefaultBalance(ctx context.Context) float64 { + value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultBalance) + if err != nil { + return s.cfg.Default.UserBalance + } + if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 { + return v + } + return s.cfg.Default.UserBalance +} + +// GetDefaultUserRPMLimit 获取新用户默认 RPM 限制(0 = 不限制)。未配置则返回 0。 +func (s *SettingService) GetDefaultUserRPMLimit(ctx context.Context) int { + value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultUserRPMLimit) + if err != nil || value == "" { + return 0 + } + if v, err := strconv.Atoi(value); err == nil && v >= 0 { + return v + } + return 0 +} + +// GetDefaultSubscriptions 获取新用户默认订阅配置列表。 +func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultSubscriptionSetting { + value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultSubscriptions) + if err != nil { + return nil + } + return parseDefaultSubscriptions(value) +} + +func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*AuthSourceDefaultSettings, error) { + keys := []string{ + SettingKeyAuthSourceDefaultEmailBalance, + SettingKeyAuthSourceDefaultEmailConcurrency, + SettingKeyAuthSourceDefaultEmailSubscriptions, + SettingKeyAuthSourceDefaultEmailGrantOnSignup, + SettingKeyAuthSourceDefaultEmailGrantOnFirstBind, + SettingKeyAuthSourceDefaultLinuxDoBalance, + SettingKeyAuthSourceDefaultLinuxDoConcurrency, + SettingKeyAuthSourceDefaultLinuxDoSubscriptions, + SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup, + SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind, + SettingKeyAuthSourceDefaultOIDCBalance, + SettingKeyAuthSourceDefaultOIDCConcurrency, + SettingKeyAuthSourceDefaultOIDCSubscriptions, + SettingKeyAuthSourceDefaultOIDCGrantOnSignup, + SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind, + SettingKeyAuthSourceDefaultWeChatBalance, + SettingKeyAuthSourceDefaultWeChatConcurrency, + SettingKeyAuthSourceDefaultWeChatSubscriptions, + SettingKeyAuthSourceDefaultWeChatGrantOnSignup, + SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind, + SettingKeyAuthSourceDefaultGitHubBalance, + SettingKeyAuthSourceDefaultGitHubConcurrency, + SettingKeyAuthSourceDefaultGitHubSubscriptions, + SettingKeyAuthSourceDefaultGitHubGrantOnSignup, + SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind, + SettingKeyAuthSourceDefaultGoogleBalance, + SettingKeyAuthSourceDefaultGoogleConcurrency, + SettingKeyAuthSourceDefaultGoogleSubscriptions, + SettingKeyAuthSourceDefaultGoogleGrantOnSignup, + SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind, + SettingKeyAuthSourceDefaultDingTalkBalance, + SettingKeyAuthSourceDefaultDingTalkConcurrency, + SettingKeyAuthSourceDefaultDingTalkSubscriptions, + SettingKeyAuthSourceDefaultDingTalkGrantOnSignup, + SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind, + SettingKeyAuthSourcePlatformQuotas("email"), + SettingKeyAuthSourcePlatformQuotas("linuxdo"), + SettingKeyAuthSourcePlatformQuotas("oidc"), + SettingKeyAuthSourcePlatformQuotas("wechat"), + SettingKeyAuthSourcePlatformQuotas("github"), + SettingKeyAuthSourcePlatformQuotas("google"), + SettingKeyAuthSourcePlatformQuotas("dingtalk"), + SettingKeyForceEmailOnThirdPartySignup, + } + + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return nil, fmt.Errorf("get auth source default settings: %w", err) + } + + return &AuthSourceDefaultSettings{ + Email: parseProviderDefaultGrantSettings(settings, emailAuthSourceDefaultKeys), + LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys), + OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys), + WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys), + GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys), + Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys), + DingTalk: parseProviderDefaultGrantSettings(settings, dingTalkAuthSourceDefaultKeys), + ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", + }, nil +} + +func (s *SettingService) ResolveAuthSourceGrantSettings(ctx context.Context, signupSource string, firstBind bool) (ProviderDefaultGrantSettings, bool, error) { + result := ProviderDefaultGrantSettings{ + Balance: s.GetDefaultBalance(ctx), + Concurrency: s.GetDefaultConcurrency(ctx), + Subscriptions: s.GetDefaultSubscriptions(ctx), + } + + defaults, err := s.GetAuthSourceDefaultSettings(ctx) + if err != nil { + return result, false, err + } + + providerDefaults, ok := authSourceSignupSettings(defaults, signupSource) + if !ok { + return result, false, nil + } + + enabled := providerDefaults.GrantOnSignup + if firstBind { + enabled = providerDefaults.GrantOnFirstBind + } + if !enabled { + return result, false, nil + } + + return mergeProviderDefaultGrantSettings(result, providerDefaults), true, nil +} + +func (s *SettingService) UpdateAuthSourceDefaultSettings(ctx context.Context, settings *AuthSourceDefaultSettings) error { + updates, err := s.buildAuthSourceDefaultUpdates(ctx, settings) + if err != nil { + return err + } + if len(updates) == 0 { + return nil + } + + if err := s.settingRepo.SetMultiple(ctx, updates); err != nil { + return fmt.Errorf("update auth source default settings: %w", err) + } + return nil +} + +// IsTurnstileEnabled 检查是否启用 Turnstile 验证 +func (s *SettingService) IsTurnstileEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileEnabled) + if err != nil { + return false + } + return value == "true" +} + +// GetTurnstileSecretKey 获取 Turnstile Secret Key +func (s *SettingService) GetTurnstileSecretKey(ctx context.Context) string { + value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileSecretKey) + if err != nil { + return "" + } + return value +} + +// IsIdentityPatchEnabled 检查是否启用身份补丁(Claude -> Gemini systemInstruction 注入) +func (s *SettingService) IsIdentityPatchEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableIdentityPatch) + if err != nil { + // 默认开启,保持兼容 + return true + } + return value == "true" +} + +// GetIdentityPatchPrompt 获取自定义身份补丁提示词(为空表示使用内置默认模板) +func (s *SettingService) GetIdentityPatchPrompt(ctx context.Context) string { + value, err := s.settingRepo.GetValue(ctx, SettingKeyIdentityPatchPrompt) + if err != nil { + return "" + } + return value +} + +// GenerateAdminAPIKey 生成新的管理员 API Key +func (s *SettingService) GenerateAdminAPIKey(ctx context.Context) (string, error) { + // 生成 32 字节随机数 = 64 位十六进制字符 + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("generate random bytes: %w", err) + } + + key := AdminAPIKeyPrefix + hex.EncodeToString(bytes) + + // 存储到 settings 表 + if err := s.settingRepo.Set(ctx, SettingKeyAdminAPIKey, key); err != nil { + return "", fmt.Errorf("save admin api key: %w", err) + } + + return key, nil +} + +// GetAdminAPIKeyStatus 获取管理员 API Key 状态 +// 返回脱敏的 key、是否存在、错误 +func (s *SettingService) GetAdminAPIKeyStatus(ctx context.Context) (maskedKey string, exists bool, err error) { + key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return "", false, nil + } + return "", false, err + } + if key == "" { + return "", false, nil + } + + // 脱敏:显示前 10 位和后 4 位 + if len(key) > 14 { + maskedKey = key[:10] + "..." + key[len(key)-4:] + } else { + maskedKey = key + } + + return maskedKey, true, nil +} + +// GetAdminAPIKey 获取完整的管理员 API Key(仅供内部验证使用) +// 如果未配置返回空字符串和 nil 错误,只有数据库错误时才返回 error +func (s *SettingService) GetAdminAPIKey(ctx context.Context) (string, error) { + key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return "", nil // 未配置,返回空字符串 + } + return "", err // 数据库错误 + } + return key, nil +} + +// DeleteAdminAPIKey 删除管理员 API Key +func (s *SettingService) DeleteAdminAPIKey(ctx context.Context) error { + return s.settingRepo.Delete(ctx, SettingKeyAdminAPIKey) +} + +// IsModelFallbackEnabled 检查是否启用模型兜底机制 +func (s *SettingService) IsModelFallbackEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableModelFallback) + if err != nil { + return false // Default: disabled + } + return value == "true" +} + +// GetFallbackModel 获取指定平台的兜底模型 +func (s *SettingService) GetFallbackModel(ctx context.Context, platform string) string { + var key string + var defaultModel string + + switch platform { + case PlatformAnthropic: + key = SettingKeyFallbackModelAnthropic + defaultModel = "claude-3-5-sonnet-20241022" + case PlatformOpenAI: + key = SettingKeyFallbackModelOpenAI + defaultModel = "gpt-4o" + case PlatformGemini: + key = SettingKeyFallbackModelGemini + defaultModel = "gemini-2.5-pro" + case PlatformAntigravity: + key = SettingKeyFallbackModelAntigravity + defaultModel = "gemini-2.5-pro" + default: + return "" + } + + value, err := s.settingRepo.GetValue(ctx, key) + if err != nil || value == "" { + return defaultModel + } + return value +} + +// GetOverloadCooldownSettings 获取529过载冷却配置 +func (s *SettingService) GetOverloadCooldownSettings(ctx context.Context) (*OverloadCooldownSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyOverloadCooldownSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultOverloadCooldownSettings(), nil + } + return nil, fmt.Errorf("get overload cooldown settings: %w", err) + } + if value == "" { + return DefaultOverloadCooldownSettings(), nil + } + + var settings OverloadCooldownSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultOverloadCooldownSettings(), nil + } + + // 修正配置值范围 + if settings.CooldownMinutes < 1 { + settings.CooldownMinutes = 1 + } + if settings.CooldownMinutes > 120 { + settings.CooldownMinutes = 120 + } + + return &settings, nil +} + +// SetOverloadCooldownSettings 设置529过载冷却配置 +func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settings *OverloadCooldownSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + // 禁用时修正为合法值即可,不拒绝请求 + if settings.CooldownMinutes < 1 || settings.CooldownMinutes > 120 { + if settings.Enabled { + return fmt.Errorf("cooldown_minutes must be between 1-120") + } + settings.CooldownMinutes = 10 // 禁用状态下归一化为默认值 + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal overload cooldown settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data)) +} + +// GetRateLimit429CooldownSettings 获取429默认回避配置 +func (s *SettingService) GetRateLimit429CooldownSettings(ctx context.Context) (*RateLimit429CooldownSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyRateLimit429CooldownSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultRateLimit429CooldownSettings(), nil + } + return nil, fmt.Errorf("get 429 cooldown settings: %w", err) + } + if value == "" { + return DefaultRateLimit429CooldownSettings(), nil + } + + var settings RateLimit429CooldownSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultRateLimit429CooldownSettings(), nil + } + + if settings.CooldownSeconds < 1 { + settings.CooldownSeconds = 1 + } + if settings.CooldownSeconds > 7200 { + settings.CooldownSeconds = 7200 + } + + return &settings, nil +} + +// SetRateLimit429CooldownSettings 设置429默认回避配置 +func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, settings *RateLimit429CooldownSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + if settings.CooldownSeconds < 1 || settings.CooldownSeconds > 7200 { + if settings.Enabled { + return fmt.Errorf("cooldown_seconds must be between 1-7200") + } + settings.CooldownSeconds = 5 + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal 429 cooldown settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyRateLimit429CooldownSettings, string(data)) +} + +// GetStreamTimeoutSettings 获取流超时处理配置 +func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultStreamTimeoutSettings(), nil + } + return nil, fmt.Errorf("get stream timeout settings: %w", err) + } + if value == "" { + return DefaultStreamTimeoutSettings(), nil + } + + var settings StreamTimeoutSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultStreamTimeoutSettings(), nil + } + + // 验证并修正配置值 + if settings.TempUnschedMinutes < 1 { + settings.TempUnschedMinutes = 1 + } + if settings.TempUnschedMinutes > 60 { + settings.TempUnschedMinutes = 60 + } + if settings.ThresholdCount < 1 { + settings.ThresholdCount = 1 + } + if settings.ThresholdCount > 10 { + settings.ThresholdCount = 10 + } + if settings.ThresholdWindowMinutes < 1 { + settings.ThresholdWindowMinutes = 1 + } + if settings.ThresholdWindowMinutes > 60 { + settings.ThresholdWindowMinutes = 60 + } + + // 验证 action + switch settings.Action { + case StreamTimeoutActionTempUnsched, StreamTimeoutActionError, StreamTimeoutActionNone: + // valid + default: + settings.Action = StreamTimeoutActionTempUnsched + } + + return &settings, nil +} + +// IsUngroupedKeySchedulingAllowed 查询是否允许未分组 Key 调度 +func (s *SettingService) IsUngroupedKeySchedulingAllowed(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyAllowUngroupedKeyScheduling) + if err != nil { + return false // fail-closed: 查询失败时默认不允许 + } + return value == "true" +} + +// GetRectifierSettings 获取请求整流器配置 +func (s *SettingService) GetRectifierSettings(ctx context.Context) (*RectifierSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyRectifierSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultRectifierSettings(), nil + } + return nil, fmt.Errorf("get rectifier settings: %w", err) + } + if value == "" { + return DefaultRectifierSettings(), nil + } + + var settings RectifierSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultRectifierSettings(), nil + } + + return &settings, nil +} + +// SetRectifierSettings 设置请求整流器配置 +func (s *SettingService) SetRectifierSettings(ctx context.Context, settings *RectifierSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal rectifier settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyRectifierSettings, string(data)) +} + +// IsSignatureRectifierEnabled 判断签名整流是否启用(总开关 && 签名子开关) +func (s *SettingService) IsSignatureRectifierEnabled(ctx context.Context) bool { + settings, err := s.GetRectifierSettings(ctx) + if err != nil { + return true // fail-open: 查询失败时默认启用 + } + return settings.Enabled && settings.ThinkingSignatureEnabled +} + +// IsBudgetRectifierEnabled 判断 Budget 整流是否启用(总开关 && Budget 子开关) +func (s *SettingService) IsBudgetRectifierEnabled(ctx context.Context) bool { + settings, err := s.GetRectifierSettings(ctx) + if err != nil { + return true // fail-open: 查询失败时默认启用 + } + return settings.Enabled && settings.ThinkingBudgetEnabled +} + +// GetBetaPolicySettings 获取 Beta 策略配置 +func (s *SettingService) GetBetaPolicySettings(ctx context.Context) (*BetaPolicySettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyBetaPolicySettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultBetaPolicySettings(), nil + } + return nil, fmt.Errorf("get beta policy settings: %w", err) + } + if value == "" { + return DefaultBetaPolicySettings(), nil + } + + var settings BetaPolicySettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultBetaPolicySettings(), nil + } + + return &settings, nil +} + +// SetBetaPolicySettings 设置 Beta 策略配置 +func (s *SettingService) SetBetaPolicySettings(ctx context.Context, settings *BetaPolicySettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + validActions := map[string]bool{ + BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true, + } + validScopes := map[string]bool{ + BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true, + } + + for i, rule := range settings.Rules { + if rule.BetaToken == "" { + return fmt.Errorf("rule[%d]: beta_token cannot be empty", i) + } + if !validActions[rule.Action] { + return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action) + } + if !validScopes[rule.Scope] { + return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope) + } + // Validate model_whitelist patterns + for j, pattern := range rule.ModelWhitelist { + trimmed := strings.TrimSpace(pattern) + if trimmed == "" { + return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j) + } + settings.Rules[i].ModelWhitelist[j] = trimmed + } + // Validate fallback_action + if rule.FallbackAction != "" && !validActions[rule.FallbackAction] { + return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction) + } + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal beta policy settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyBetaPolicySettings, string(data)) +} + +// GetOpenAIFastPolicySettings 获取 OpenAI fast 策略配置 +func (s *SettingService) GetOpenAIFastPolicySettings(ctx context.Context) (*OpenAIFastPolicySettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAIFastPolicySettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultOpenAIFastPolicySettings(), nil + } + return nil, fmt.Errorf("get openai fast policy settings: %w", err) + } + if value == "" { + return DefaultOpenAIFastPolicySettings(), nil + } + + var settings OpenAIFastPolicySettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + // JSON 损坏时静默 fallback 到默认配置会让策略意外失效(管理员配 + // 置的 block/filter 规则被忽略)。记录 Warn 让运维能在出现异常 + // 行为时定位到 settings 表里的脏数据。 + slog.Warn("failed to unmarshal openai fast policy settings, falling back to defaults", + "error", err, + "key", SettingKeyOpenAIFastPolicySettings) + return DefaultOpenAIFastPolicySettings(), nil + } + + return &settings, nil +} + +// SetOpenAIFastPolicySettings 设置 OpenAI fast 策略配置 +func (s *SettingService) SetOpenAIFastPolicySettings(ctx context.Context, settings *OpenAIFastPolicySettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + validActions := map[string]bool{ + BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true, + OpenAIFastPolicyActionForcePriority: true, + } + validScopes := map[string]bool{ + BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true, + } + validTiers := map[string]bool{ + OpenAIFastTierAny: true, OpenAIFastTierPriority: true, OpenAIFastTierFlex: true, + } + + for i, rule := range settings.Rules { + tier := strings.ToLower(strings.TrimSpace(rule.ServiceTier)) + if tier == "" { + tier = OpenAIFastTierAny + } + if !validTiers[tier] { + return fmt.Errorf("rule[%d]: invalid service_tier %q", i, rule.ServiceTier) + } + settings.Rules[i].ServiceTier = tier + if !validActions[rule.Action] { + return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action) + } + if !validScopes[rule.Scope] { + return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope) + } + for j, pattern := range rule.ModelWhitelist { + trimmed := strings.TrimSpace(pattern) + if trimmed == "" { + return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j) + } + settings.Rules[i].ModelWhitelist[j] = trimmed + } + if rule.FallbackAction != "" && !validActions[rule.FallbackAction] { + return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction) + } + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal openai fast policy settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyOpenAIFastPolicySettings, string(data)) +} + +// SetStreamTimeoutSettings 设置流超时处理配置 +func (s *SettingService) SetStreamTimeoutSettings(ctx context.Context, settings *StreamTimeoutSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + // 验证配置值 + if settings.TempUnschedMinutes < 1 || settings.TempUnschedMinutes > 60 { + return fmt.Errorf("temp_unsched_minutes must be between 1-60") + } + if settings.ThresholdCount < 1 || settings.ThresholdCount > 10 { + return fmt.Errorf("threshold_count must be between 1-10") + } + if settings.ThresholdWindowMinutes < 1 || settings.ThresholdWindowMinutes > 60 { + return fmt.Errorf("threshold_window_minutes must be between 1-60") + } + + switch settings.Action { + case StreamTimeoutActionTempUnsched, StreamTimeoutActionError, StreamTimeoutActionNone: + // valid + default: + return fmt.Errorf("invalid action: %s", settings.Action) + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal stream timeout settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyStreamTimeoutSettings, string(data)) +} + +// GetDefaultPlatformQuotas 读取系统全局 platform quota JSON key,返回全部允许平台 x 3 window 的设置。 +// 永远返回包含全部允许 platform key 的 map(值可能为零值/nil 字段,表示"上层未配置 = 不限制")。 +// +// 使用单个 JSON key(default_platform_quotas),一次 DB roundtrip,消除旧 12-KV 格式的 N+1 问题。 +// 容错语义:取值失败或 unmarshal 失败 → 返回补齐全部允许平台 key 的空 map(fail-open,注册不被阻断)。 +func (s *SettingService) GetDefaultPlatformQuotas(ctx context.Context) (map[string]*DefaultPlatformQuotaSetting, error) { + out := make(map[string]*DefaultPlatformQuotaSetting, len(AllowedQuotaPlatforms)) + for _, platform := range AllowedQuotaPlatforms { + out[platform] = &DefaultPlatformQuotaSetting{} + } + raw, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultPlatformQuotas) + if err != nil || raw == "" { + return out, nil // 无配置 = 全部不限制 + } + parsed := map[string]*DefaultPlatformQuotaSetting{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + slog.Warn("[Setting] unmarshal default_platform_quotas failed (fail-open)", "error", err) + return out, nil + } + for _, platform := range AllowedQuotaPlatforms { + if v := parsed[platform]; v != nil { + out[platform] = v + } + } + return out, nil // 补齐全部允许 platform key,保持与旧实现一致的下游契约 +} + +// GetAuthSourcePlatformQuotas 读取指定 auth source 的 platform quota 覆盖(仅返回有配置的平台,override 语义)。 +func (s *SettingService) GetAuthSourcePlatformQuotas(ctx context.Context, source string) map[string]*DefaultPlatformQuotaSetting { + out := map[string]*DefaultPlatformQuotaSetting{} + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAuthSourcePlatformQuotas(source)) + if err != nil || raw == "" { + return out // 无 override + } + if err := json.Unmarshal([]byte(raw), &out); err != nil { + slog.Warn("[Setting] unmarshal auth source platform quotas failed (fail-open)", "source", source, "error", err) + return map[string]*DefaultPlatformQuotaSetting{} + } + return out // 仅含已配置平台,保持 override 语义 +} + +// mergePlatformQuotaDefaults 按字段级 patch:src 中非 nil 字段覆盖 dst。 +// 区分 nil("未配置",保留 dst)vs &0.0("显式禁用",覆盖 dst 为 0) +func mergePlatformQuotaDefaults(dst, src *DefaultPlatformQuotaSetting) { + if src == nil || dst == nil { + return + } + if src.DailyLimitUSD != nil { + dst.DailyLimitUSD = src.DailyLimitUSD + } + if src.WeeklyLimitUSD != nil { + dst.WeeklyLimitUSD = src.WeeklyLimitUSD + } + if src.MonthlyLimitUSD != nil { + dst.MonthlyLimitUSD = src.MonthlyLimitUSD + } +} diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go new file mode 100644 index 0000000000..ea4037d990 --- /dev/null +++ b/backend/internal/service/setting_gateway_runtime.go @@ -0,0 +1,892 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "golang.org/x/sync/singleflight" +) + +// cachedVersionBounds 缓存 Claude Code 版本号上下限(进程内缓存,60s TTL) +type cachedVersionBounds struct { + min string // 空字符串 = 不检查 + max string // 空字符串 = 不检查 + expiresAt int64 // unix nano +} + +// versionBoundsCache 版本号上下限进程内缓存 +var versionBoundsCache atomic.Value // *cachedVersionBounds + +// versionBoundsSF 防止缓存过期时 thundering herd +var versionBoundsSF singleflight.Group + +// versionBoundsCacheTTL 缓存有效期 +const versionBoundsCacheTTL = 60 * time.Second + +// versionBoundsErrorTTL DB 错误时的短缓存,快速重试 +const versionBoundsErrorTTL = 5 * time.Second + +// versionBoundsDBTimeout singleflight 内 DB 查询超时,独立于请求 context +const versionBoundsDBTimeout = 5 * time.Second + +// cachedBackendMode Backend Mode cache (in-process, 60s TTL) +type cachedBackendMode struct { + value bool + expiresAt int64 // unix nano +} + +var backendModeCache atomic.Value // *cachedBackendMode +var backendModeSF singleflight.Group + +const backendModeCacheTTL = 60 * time.Second +const backendModeErrorTTL = 5 * time.Second +const backendModeDBTimeout = 5 * time.Second + +// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL) +type cachedGatewayForwardingSettings struct { + fingerprintUnification bool + metadataPassthrough bool + cchSigning bool + claudeOAuthSystemPromptInjection bool + claudeOAuthSystemPrompt string + claudeOAuthSystemPromptBlocks string + anthropicCacheTTL1hInjection bool + rewriteMessageCacheControl bool + clientDatelineNormalization bool + expiresAt int64 // unix nano +} + +var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings +var gatewayForwardingSF singleflight.Group + +const gatewayForwardingCacheTTL = 60 * time.Second +const gatewayForwardingErrorTTL = 5 * time.Second +const gatewayForwardingDBTimeout = 5 * time.Second + +// cachedAntigravityUserAgentVersion 缓存 Antigravity UA 版本号(进程内缓存,60s TTL) +type cachedAntigravityUserAgentVersion struct { + version string + expiresAt int64 // unix nano +} + +const antigravityUserAgentVersionCacheTTL = 60 * time.Second +const antigravityUserAgentVersionErrorTTL = 5 * time.Second +const antigravityUserAgentVersionDBTimeout = 5 * time.Second + +// DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询) +const DefaultOpenAICodexUserAgent = "codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)" + +// cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL) +type cachedOpenAICodexUserAgent struct { + value string + expiresAt int64 // unix nano +} + +type cachedOpenAIQuotaAutoPauseSettings struct { + settings OpsOpenAIAccountQuotaAutoPauseSettings + expiresAt int64 +} + +const openAICodexUserAgentCacheTTL = 60 * time.Second +const openAICodexUserAgentErrorTTL = 5 * time.Second +const openAICodexUserAgentDBTimeout = 5 * time.Second + +const codexRestrictionPolicyCacheTTL = 60 * time.Second +const codexRestrictionPolicyDBTimeout = 5 * time.Second + +// cachedCodexRestrictionPolicy codex_cli_only 全局加固策略缓存(进程内,60s TTL)。 +// GetCodexRestrictionPolicy 在每个 codex_cli_only 账号的网关请求热路径上被调用,避免每次访问 DB。 +type cachedCodexRestrictionPolicy struct { + value CodexRestrictionPolicy + expiresAt int64 // unix nano +} + +// cachedCyberSessionBlockRuntime cyber 会话屏蔽开关+TTL 进程内缓存(60s TTL)。 +// GetCyberSessionBlockRuntime 在网关请求热路径上被调用,避免每次访问 DB。 +type cachedCyberSessionBlockRuntime struct { + enabled bool + ttl time.Duration + expiresAt int64 // unix nano +} + +const cyberSessionBlockRuntimeCacheTTL = 60 * time.Second +const cyberSessionBlockRuntimeErrorTTL = 5 * time.Second +const cyberSessionBlockRuntimeDBTimeout = 5 * time.Second + +const openAIQuotaAutoPauseSettingsCacheTTL = 60 * time.Second +const openAIQuotaAutoPauseSettingsErrorTTL = 5 * time.Second +const openAIQuotaAutoPauseSettingsDBTimeout = 5 * time.Second + +const openAIQuotaAutoPauseSettingsRefreshKey = "openai_quota_auto_pause_settings" + +// GetCyberSessionBlockRuntime 返回 (开关, TTL),进程内缓存 ~60s, +// 供网关热路径读取时避免 DB 往返。 +// 两个 setting key 在单次 singleflight 里一起读取,减少 DB 往返。 +// 默认值:开关 false,TTL 1h(与粘性会话对齐)。 +func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, time.Duration) { + if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.enabled, cached.ttl + } + } + result, _, _ := s.cyberSessionBlockRuntimeSF.Do("cyber_session_block_runtime", func() (any, error) { + if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached, nil + } + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cyberSessionBlockRuntimeDBTimeout) + defer cancel() + + enabledVal, enabledErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockEnabled) + ttlVal, ttlErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockTTLSeconds) + + if enabledErr != nil && !errors.Is(enabledErr, ErrSettingNotFound) { + slog.Warn("failed to get cyber_session_block_enabled setting", "error", enabledErr) + entry := &cachedCyberSessionBlockRuntime{ + enabled: false, + ttl: time.Hour, + expiresAt: time.Now().Add(cyberSessionBlockRuntimeErrorTTL).UnixNano(), + } + s.cyberSessionBlockRuntimeCache.Store(entry) + return entry, nil + } + + enabled := enabledErr == nil && strings.TrimSpace(enabledVal) == "true" + + ttl := time.Hour + if ttlErr == nil { + if n, perr := strconv.Atoi(strings.TrimSpace(ttlVal)); perr == nil && n > 0 { + ttl = time.Duration(n) * time.Second + } + } + + entry := &cachedCyberSessionBlockRuntime{ + enabled: enabled, + ttl: ttl, + expiresAt: time.Now().Add(cyberSessionBlockRuntimeCacheTTL).UnixNano(), + } + s.cyberSessionBlockRuntimeCache.Store(entry) + return entry, nil + }) + if entry, ok := result.(*cachedCyberSessionBlockRuntime); ok && entry != nil { + return entry.enabled, entry.ttl + } + return false, time.Hour +} + +// GetAntigravityUserAgentVersion 返回 Antigravity 上游请求使用的版本号。 +// 后台设置优先;为空、缺失或非法时回退到 ANTIGRAVITY_USER_AGENT_VERSION / 内置默认值。 +func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) string { + fallback := antigravity.GetDefaultUserAgentVersion() + if s == nil || s.settingRepo == nil { + return fallback + } + if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.version + } + } + + result, _, _ := s.antigravityUAVersionSF.Do("antigravity_user_agent_version", func() (any, error) { + if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.version, nil + } + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), antigravityUserAgentVersionDBTimeout) + defer cancel() + value, err := s.settingRepo.GetValue(dbCtx, SettingKeyAntigravityUserAgentVersion) + if err != nil && !errors.Is(err, ErrSettingNotFound) { + slog.Warn("failed to get antigravity user agent version setting", "error", err) + s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{ + version: fallback, + expiresAt: time.Now().Add(antigravityUserAgentVersionErrorTTL).UnixNano(), + }) + return fallback, nil + } + version := antigravity.NormalizeUserAgentVersion(value) + if version == "" { + version = fallback + } + s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{ + version: version, + expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(), + }) + return version, nil + }) + if version, ok := result.(string); ok && version != "" { + return version + } + return fallback +} + +// GetOpenAICodexUserAgent 返回 OpenAI Codex 上游请求使用的 User-Agent。 +// 后台设置优先;为空时回退到内置默认值。 +func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string { + fallback := DefaultOpenAICodexUserAgent + if s == nil || s.settingRepo == nil { + return fallback + } + if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value + } + } + + result, _, _ := s.openAICodexUASF.Do("openai_codex_user_agent", func() (any, error) { + if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value, nil + } + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAICodexUserAgentDBTimeout) + defer cancel() + value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAICodexUserAgent) + if err != nil && !errors.Is(err, ErrSettingNotFound) { + slog.Warn("failed to get openai codex user agent setting", "error", err) + s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{ + value: fallback, + expiresAt: time.Now().Add(openAICodexUserAgentErrorTTL).UnixNano(), + }) + return fallback, nil + } + ua := strings.TrimSpace(value) + if ua == "" { + ua = fallback + } + s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{ + value: ua, + expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(), + }) + return ua, nil + }) + if ua, ok := result.(string); ok && ua != "" { + return ua + } + return fallback +} + +var legacyClaudeCodeCodexWhitelistEntry = openai.AllowedClientEntry{ + Originator: "Claude Code", + UAContains: []string{"Claude Code/"}, +} + +// MigrateOpenAIAllowClaudeCodeCodexPluginSetting folds the deprecated global Claude Code +// plugin allow switch into codex_cli_only_whitelist. The app-server identity model is the +// same originator + UA marker pair, so runtime checks no longer need a separate flag. +func (s *SettingService) MigrateOpenAIAllowClaudeCodeCodexPluginSetting(ctx context.Context) error { + if s == nil || s.settingRepo == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) + defer cancel() + + legacyValue, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAIAllowClaudeCodeCodexPlugin) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return nil + } + return fmt.Errorf("get deprecated %s setting: %w", SettingKeyOpenAIAllowClaudeCodeCodexPlugin, err) + } + if strings.TrimSpace(legacyValue) != "true" { + return nil + } + + rawWhitelist, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyWhitelist) + if err != nil && !errors.Is(err, ErrSettingNotFound) { + return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) + } + + var entries []openai.AllowedClientEntry + if strings.TrimSpace(rawWhitelist) != "" { + if err := json.Unmarshal([]byte(rawWhitelist), &entries); err != nil { + return fmt.Errorf("parse %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) + } + } + if codexClientEntriesContain(entries, legacyClaudeCodeCodexWhitelistEntry) { + return nil + } + + entries = append(entries, legacyClaudeCodeCodexWhitelistEntry) + encoded, err := json.Marshal(entries) + if err != nil { + return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) + } + if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyWhitelist, string(encoded)); err != nil { + return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) + } + s.codexRestrictionPolicySF.Forget("codex_restriction_policy") + s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) + return nil +} + +// MigrateCodexBodyFingerprintToSignals 把已废弃的 codex_cli_only_allow_body_engine_fingerprint +// 开关并入引擎指纹信号列表。幂等:信号键已存在(非空)则不动;缺失时写默认种子, +// 并把 body 路径行的 Required 设为旧 body 开关的值(旧 true ⇒ 勾上 body 行)。 +func (s *SettingService) MigrateCodexBodyFingerprintToSignals(ctx context.Context) error { + if s == nil || s.settingRepo == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) + defer cancel() + + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals); err == nil && strings.TrimSpace(v) != "" { + return nil // 已配置/已迁移 + } else if err != nil && !errors.Is(err, ErrSettingNotFound) { + return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) + } + + bodyOn := false + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint); err == nil { + bodyOn = strings.TrimSpace(v) == "true" + } else if !errors.Is(err, ErrSettingNotFound) { + return fmt.Errorf("get deprecated %s setting: %w", SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint, err) + } + + seed := make([]openai.EngineFingerprintSignal, len(openai.DefaultEngineFingerprintSignals)) + copy(seed, openai.DefaultEngineFingerprintSignals) + if bodyOn { + for i := range seed { + if seed[i].Type == openai.FingerprintSignalBodyPath { + seed[i].Required = true + } + } + } + encoded, err := json.Marshal(seed) + if err != nil { + return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) + } + if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals, string(encoded)); err != nil { + return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) + } + s.codexRestrictionPolicySF.Forget("codex_restriction_policy") + s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) + return nil +} + +func codexClientEntriesContain(entries []openai.AllowedClientEntry, want openai.AllowedClientEntry) bool { + wantOriginator := strings.TrimSpace(want.Originator) + if wantOriginator == "" { + return false + } + wantMarkers := normalizedCodexClientMarkers(want.UAContains) + if len(wantMarkers) == 0 { + return false + } + for _, entry := range entries { + if !strings.EqualFold(strings.TrimSpace(entry.Originator), wantOriginator) { + continue + } + gotMarkers := normalizedCodexClientMarkers(entry.UAContains) + if len(gotMarkers) != len(wantMarkers) { + continue + } + matched := true + for marker := range wantMarkers { + if _, ok := gotMarkers[marker]; !ok { + matched = false + break + } + } + if matched { + return true + } + } + return false +} + +func normalizedCodexClientMarkers(markers []string) map[string]struct{} { + normalized := make(map[string]struct{}, len(markers)) + for _, marker := range markers { + marker = strings.TrimSpace(marker) + if marker == "" { + continue + } + normalized[strings.ToLower(marker)] = struct{}{} + } + return normalized +} + +// GetCodexRestrictionPolicy 读取 codex_cli_only 全局加固策略(黑/白名单、最低版本、引擎指纹门)。 +// 仅在调用方已确认账号 codex_cli_only 开启时读取;进程内 atomic.Value 缓存(60s TTL)避免热路径访问 DB。 +// 任意键缺失/解析失败 → 安全默认:空名单、空版本、默认种子指纹信号。 +func (s *SettingService) GetCodexRestrictionPolicy(ctx context.Context) CodexRestrictionPolicy { + if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value + } + } + result, _, _ := s.codexRestrictionPolicySF.Do("codex_restriction_policy", func() (any, error) { + if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value, nil + } + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) + defer cancel() + + pol := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} // 安全默认:默认种子指纹信号 + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinCodexVersion); err == nil { + pol.MinCodexVersion = strings.TrimSpace(v) + } + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMaxCodexVersion); err == nil { + pol.MaxCodexVersion = strings.TrimSpace(v) + } + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowAppServerClients); err == nil { + pol.AllowAppServerClients = strings.TrimSpace(v) == "true" // 仅显式 "true" 开启 + } + pol.EngineFingerprintSignals = s.loadEngineFingerprintSignals(dbCtx) + pol.Whitelist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyWhitelist) + pol.Blacklist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyBlacklist) + + s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{ + value: pol, + expiresAt: time.Now().Add(codexRestrictionPolicyCacheTTL).UnixNano(), + }) + return pol, nil + }) + if pol, ok := result.(CodexRestrictionPolicy); ok { + return pol + } + return CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} +} + +// loadCodexClientEntries 读取并解析 []openai.AllowedClientEntry JSON 设置;缺失/空/非法 → nil(安全忽略)。 +func (s *SettingService) loadCodexClientEntries(ctx context.Context, key string) []openai.AllowedClientEntry { + v, err := s.settingRepo.GetValue(ctx, key) + if err != nil || strings.TrimSpace(v) == "" { + return nil + } + var entries []openai.AllowedClientEntry + if json.Unmarshal([]byte(v), &entries) != nil { + return nil + } + return entries +} + +// loadEngineFingerprintSignals 读取引擎指纹信号列表;缺失/空/非法 → 默认种子。 +func (s *SettingService) loadEngineFingerprintSignals(ctx context.Context) []openai.EngineFingerprintSignal { + v, err := s.settingRepo.GetValue(ctx, SettingKeyCodexCLIOnlyEngineFingerprintSignals) + if err != nil || strings.TrimSpace(v) == "" { + return openai.DefaultEngineFingerprintSignals + } + sigs, ok := openai.ParseEngineFingerprintSignals(v) + if !ok { + return openai.DefaultEngineFingerprintSignals + } + return sigs +} + +// ValidateCodexClientEntriesJSON 校验 codex_cli_only 名单 JSON 配置(黑名单语义): +// 空=合法(禁用);非空须为 []AllowedClientEntry 的 JSON 数组。黑名单是 OR 宽 deny, +// 允许 originator-only 条目,故不校验 ua_contains。白名单请用 ValidateCodexWhitelistEntriesJSON。 +func ValidateCodexClientEntriesJSON(raw string) error { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + var entries []openai.AllowedClientEntry + if err := json.Unmarshal([]byte(trimmed), &entries); err != nil { + return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}") + } + return nil +} + +// ValidateCodexWhitelistEntriesJSON 在 ValidateCodexClientEntriesJSON 的数组结构校验之上,额外要求 +// 每条白名单条目「有可能命中」(openai.AllowedClientEntry.IsWhitelistable)。白名单是双因子 AND: +// originator-only、空或含空白 ua_contains 的条目会在运行时静默失效——这里让管理员在写入时即收到反馈, +// 而非存入永不命中的死规则。黑名单(OR 宽 deny)仍用 ValidateCodexClientEntriesJSON。 +func ValidateCodexWhitelistEntriesJSON(raw string) error { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + var entries []openai.AllowedClientEntry + if err := json.Unmarshal([]byte(trimmed), &entries); err != nil { + return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}") + } + for i, e := range entries { + if !e.IsWhitelistable() { + return fmt.Errorf("entry %d: whitelist requires a non-empty originator and at least one non-empty ua_contains (double-factor AND; otherwise the rule never matches)", i) + } + } + return nil +} + +// ValidateEngineFingerprintSignalsJSON 服务层包装,复用 openai 校验逻辑。 +func ValidateEngineFingerprintSignalsJSON(raw string) error { + return openai.ValidateEngineFingerprintSignalsJSON(raw) +} + +// IsBackendModeEnabled checks if backend mode is enabled +// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path +func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool { + if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value + } + } + result, _, _ := backendModeSF.Do("backend_mode", func() (any, error) { + if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value, nil + } + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), backendModeDBTimeout) + defer cancel() + value, err := s.settingRepo.GetValue(dbCtx, SettingKeyBackendModeEnabled) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + // Setting not yet created (fresh install) - default to disabled with full TTL + backendModeCache.Store(&cachedBackendMode{ + value: false, + expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), + }) + return false, nil + } + slog.Warn("failed to get backend_mode_enabled setting", "error", err) + backendModeCache.Store(&cachedBackendMode{ + value: false, + expiresAt: time.Now().Add(backendModeErrorTTL).UnixNano(), + }) + return false, nil + } + enabled := value == "true" + backendModeCache.Store(&cachedBackendMode{ + value: enabled, + expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), + }) + return enabled, nil + }) + if val, ok := result.(bool); ok { + return val + } + return false +} + +type gatewayForwardingSettingsResult struct { + fp, mp, cch, claudeOAuthSystemPromptInjection, cacheTTL1h, rewriteMessageCacheControl bool + clientDatelineNormalization bool + claudeOAuthSystemPrompt, claudeOAuthSystemPromptBlocks string +} + +func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) gatewayForwardingSettingsResult { + if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return gatewayForwardingSettingsResult{ + fp: cached.fingerprintUnification, + mp: cached.metadataPassthrough, + cch: cached.cchSigning, + claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection, + claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt, + claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks, + cacheTTL1h: cached.anthropicCacheTTL1hInjection, + rewriteMessageCacheControl: cached.rewriteMessageCacheControl, + clientDatelineNormalization: cached.clientDatelineNormalization, + } + } + } + val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) { + if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return gatewayForwardingSettingsResult{ + fp: cached.fingerprintUnification, + mp: cached.metadataPassthrough, + cch: cached.cchSigning, + claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection, + claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt, + claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks, + cacheTTL1h: cached.anthropicCacheTTL1hInjection, + rewriteMessageCacheControl: cached.rewriteMessageCacheControl, + clientDatelineNormalization: cached.clientDatelineNormalization, + }, nil + } + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout) + defer cancel() + values, err := s.settingRepo.GetMultiple(dbCtx, []string{ + SettingKeyEnableFingerprintUnification, + SettingKeyEnableMetadataPassthrough, + SettingKeyEnableCCHSigning, + SettingKeyEnableClaudeOAuthSystemPromptInjection, + SettingKeyClaudeOAuthSystemPrompt, + SettingKeyClaudeOAuthSystemPromptBlocks, + SettingKeyEnableAnthropicCacheTTL1hInjection, + SettingKeyRewriteMessageCacheControl, + SettingKeyEnableClientDatelineNormalization, + }) + if err != nil { + slog.Warn("failed to get gateway forwarding settings", "error", err) + gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ + fingerprintUnification: true, + metadataPassthrough: false, + cchSigning: false, + claudeOAuthSystemPromptInjection: true, + anthropicCacheTTL1hInjection: false, + rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), + clientDatelineNormalization: true, + expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(), + }) + return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), clientDatelineNormalization: true}, nil + } + fp := true + if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" { + fp = v == "true" + } + mp := values[SettingKeyEnableMetadataPassthrough] == "true" + cch := values[SettingKeyEnableCCHSigning] == "true" + systemPromptInjection := true + if v, ok := values[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" { + systemPromptInjection = v == "true" + } + systemPrompt := values[SettingKeyClaudeOAuthSystemPrompt] + systemPromptBlocks := values[SettingKeyClaudeOAuthSystemPromptBlocks] + cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" + rewriteMessageCacheControl := s.defaultRewriteMessageCacheControl() + if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" { + rewriteMessageCacheControl = v == "true" + } + clientDatelineNormalization := true + if v, ok := values[SettingKeyEnableClientDatelineNormalization]; ok && v != "" { + clientDatelineNormalization = v == "true" + } + gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ + fingerprintUnification: fp, + metadataPassthrough: mp, + cchSigning: cch, + claudeOAuthSystemPromptInjection: systemPromptInjection, + claudeOAuthSystemPrompt: systemPrompt, + claudeOAuthSystemPromptBlocks: systemPromptBlocks, + anthropicCacheTTL1hInjection: cacheTTL1h, + rewriteMessageCacheControl: rewriteMessageCacheControl, + clientDatelineNormalization: clientDatelineNormalization, + expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), + }) + return gatewayForwardingSettingsResult{ + fp: fp, + mp: mp, + cch: cch, + claudeOAuthSystemPromptInjection: systemPromptInjection, + claudeOAuthSystemPrompt: systemPrompt, + claudeOAuthSystemPromptBlocks: systemPromptBlocks, + cacheTTL1h: cacheTTL1h, + rewriteMessageCacheControl: rewriteMessageCacheControl, + clientDatelineNormalization: clientDatelineNormalization, + }, nil + }) + if r, ok := val.(gatewayForwardingSettingsResult); ok { + return r + } + return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, clientDatelineNormalization: true} +} + +// GetGatewayForwardingSettings returns cached gateway forwarding settings. +// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path. +// Returns (fingerprintUnification, metadataPassthrough, cchSigning). +func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) { + result := s.getGatewayForwardingSettingsCached(ctx) + return result.fp, result.mp, result.cch +} + +// IsAnthropicCacheTTL1hInjectionEnabled 检查是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl。 +func (s *SettingService) IsAnthropicCacheTTL1hInjectionEnabled(ctx context.Context) bool { + return s.getGatewayForwardingSettingsCached(ctx).cacheTTL1h +} + +// IsRewriteMessageCacheControlEnabled 检查是否启用 messages cache_control 改写。 +func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context) bool { + return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl +} + +// IsClientDatelineNormalizationEnabled 检查是否启用 Anthropic OAuth/SetupToken 请求体 +// 的客户端 dateline 归一化。默认开启。 +func (s *SettingService) IsClientDatelineNormalizationEnabled(ctx context.Context) bool { + return s.getGatewayForwardingSettingsCached(ctx).clientDatelineNormalization +} + +// GetClaudeOAuthSystemPromptInjectionSettings returns the Claude OAuth mimic +// system block switch, legacy custom expansion prompt, and configurable blocks JSON. +// Empty values mean use the built-in Claude Code default blocks. +func (s *SettingService) GetClaudeOAuthSystemPromptInjectionSettings(ctx context.Context) (enabled bool, prompt string, blocks string) { + result := s.getGatewayForwardingSettingsCached(ctx) + return result.claudeOAuthSystemPromptInjection, result.claudeOAuthSystemPrompt, result.claudeOAuthSystemPromptBlocks +} + +// GetClaudeCodeVersionBounds 获取 Claude Code 版本号上下限要求 +// 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销 +// singleflight 防止缓存过期时 thundering herd +// 返回空字符串表示不做对应方向的版本检查 +func (s *SettingService) GetClaudeCodeVersionBounds(ctx context.Context) (min, max string) { + if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok { + if time.Now().UnixNano() < cached.expiresAt { + return cached.min, cached.max + } + } + // singleflight: 同一时刻只有一个 goroutine 查询 DB,其余复用结果 + type bounds struct{ min, max string } + result, err, _ := versionBoundsSF.Do("version_bounds", func() (any, error) { + // 二次检查,避免排队的 goroutine 重复查询 + if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok { + if time.Now().UnixNano() < cached.expiresAt { + return bounds{cached.min, cached.max}, nil + } + } + // 使用独立 context:断开请求取消链,避免客户端断连导致空值被长期缓存 + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), versionBoundsDBTimeout) + defer cancel() + values, err := s.settingRepo.GetMultiple(dbCtx, []string{ + SettingKeyMinClaudeCodeVersion, + SettingKeyMaxClaudeCodeVersion, + }) + if err != nil { + // fail-open: DB 错误时不阻塞请求,但记录日志并使用短 TTL 快速重试 + slog.Warn("failed to get claude code version bounds setting, skipping version check", "error", err) + versionBoundsCache.Store(&cachedVersionBounds{ + min: "", + max: "", + expiresAt: time.Now().Add(versionBoundsErrorTTL).UnixNano(), + }) + return bounds{"", ""}, nil + } + b := bounds{ + min: values[SettingKeyMinClaudeCodeVersion], + max: values[SettingKeyMaxClaudeCodeVersion], + } + versionBoundsCache.Store(&cachedVersionBounds{ + min: b.min, + max: b.max, + expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(), + }) + return b, nil + }) + if err != nil { + return "", "" + } + b, ok := result.(bounds) + if !ok { + return "", "" + } + return b.min, b.max +} + +// GetOpenAIQuotaAutoPauseSettings returns the current global default quota auto-pause +// settings. It is invoked on the OpenAI scheduling hot path (once per request) and is +// therefore designed to never block on the DB: +// +// - Fresh cached value → returned immediately. +// - Stale or empty cache → the last known value is returned, and a background +// goroutine refreshes the cache via singleflight (stale-while-revalidate). +// - First call with no cache yet → zero defaults are returned and the same async +// refresh is kicked off; the next call gets the freshly populated value. +// +// Callers that need the freshly persisted value synchronously (tests, post-update +// confirmation, optional startup warm-up) should call WarmOpenAIQuotaAutoPauseSettings. +func (s *SettingService) GetOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings { + if s == nil { + return OpsOpenAIAccountQuotaAutoPauseSettings{} + } + cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings) + now := time.Now().UnixNano() + if cached != nil && now < cached.expiresAt { + return cached.settings + } + // Stale or unset: trigger background refresh without blocking this request. + // singleflight.DoChan dedupes concurrent refreshes; we deliberately ignore the + // returned channel — the result is observable via the atomic cache. + s.openAIQuotaAutoPauseSettingsSF.DoChan(openAIQuotaAutoPauseSettingsRefreshKey, func() (any, error) { + s.refreshOpenAIQuotaAutoPauseSettings(context.Background()) + return nil, nil + }) + if cached != nil { + return cached.settings // serve stale value while revalidating + } + return OpsOpenAIAccountQuotaAutoPauseSettings{} +} + +// WarmOpenAIQuotaAutoPauseSettings synchronously loads the quota auto-pause settings +// into the in-memory cache. Useful for application startup (so the first request hits +// a warm cache) and for tests that need deterministic reads immediately after +// constructing the service. +func (s *SettingService) WarmOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings { + if s == nil { + return OpsOpenAIAccountQuotaAutoPauseSettings{} + } + s.refreshOpenAIQuotaAutoPauseSettings(ctx) + cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings) + if cached == nil { + return OpsOpenAIAccountQuotaAutoPauseSettings{} + } + return cached.settings +} + +// refreshOpenAIQuotaAutoPauseSettings reads the latest settings from the DB and stores +// them into the in-memory cache. On error it stores the prior value (or zero defaults +// if nothing is cached yet) with the shorter error TTL so the next refresh comes +// sooner. Always uses its own timeout-bounded context to keep refresh latency +// predictable regardless of the caller. +func (s *SettingService) refreshOpenAIQuotaAutoPauseSettings(ctx context.Context) { + if s == nil || s.settingRepo == nil { + return + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIQuotaAutoPauseSettingsDBTimeout) + defer cancel() + + settings := OpsOpenAIAccountQuotaAutoPauseSettings{} + ttl := openAIQuotaAutoPauseSettingsCacheTTL + raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpsAdvancedSettings) + if err == nil { + cfg := defaultOpsAdvancedSettings() + if strings.TrimSpace(raw) != "" { + if jsonErr := json.Unmarshal([]byte(raw), cfg); jsonErr == nil { + normalizeOpsAdvancedSettings(cfg) + } + } + settings = cfg.OpenAIAccountQuotaAutoPause + } else if !errors.Is(err, ErrSettingNotFound) { + // Real error: keep serving prior value but refresh sooner. + if prior, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); prior != nil { + settings = prior.settings + } + ttl = openAIQuotaAutoPauseSettingsErrorTTL + } + + s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{ + settings: settings, + expiresAt: time.Now().Add(ttl).UnixNano(), + }) +} + +// SetOpenAIQuotaAutoPauseSettings writes the given settings directly into the in-memory +// cache. Called from settings-write code paths so that the next read reflects the new +// value immediately, without waiting for the background refresh. +func (s *SettingService) SetOpenAIQuotaAutoPauseSettings(settings OpsOpenAIAccountQuotaAutoPauseSettings) { + if s == nil { + return + } + s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{ + settings: settings, + expiresAt: time.Now().Add(openAIQuotaAutoPauseSettingsCacheTTL).UnixNano(), + }) +} diff --git a/backend/internal/service/setting_oauth.go b/backend/internal/service/setting_oauth.go new file mode 100644 index 0000000000..cb717c7743 --- /dev/null +++ b/backend/internal/service/setting_oauth.go @@ -0,0 +1,1015 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/imroc/req/v3" +) + +// CoerceDingTalkCorpPolicyForWrite 是 coerceDeprecatedDingTalkCorpPolicy 的导出版本, +// 用于 admin handler 在写入路径上对客户端直传的入参做防御性 coerce(前端 UI 虽已无 whitelist 选项, +// 但 API 可被直接调用)。 +func CoerceDingTalkCorpPolicyForWrite(policy string) string { + return coerceDeprecatedDingTalkCorpPolicy(policy) +} + +// coerceDeprecatedDingTalkCorpPolicy 把已废弃的 corp_restriction_policy 值替换成安全的等价值。 +// 升级前残留在 DB 中的 "whitelist" 会导致 callback 链路在 default case 静默 fail-closed +// (所有钉钉登录被拒)。这里统一退化为 "none" 让服务保持可用,并 warn 日志提醒 admin 重新保存设置。 +func coerceDeprecatedDingTalkCorpPolicy(policy string) string { + if policy == "whitelist" { + slog.Warn("dingtalk: corp_restriction_policy=whitelist is deprecated and unsupported, coercing to none", + "hint", "re-save DingTalk settings in admin UI to clear this warning") + return "none" + } + return policy +} + +func normalizeWeChatConnectModeSetting(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "mp": + return "mp" + case "mobile": + return "mobile" + default: + return "open" + } +} + +func defaultWeChatConnectScopeForMode(mode string) string { + switch normalizeWeChatConnectModeSetting(mode) { + case "mp": + return "snsapi_userinfo" + case "mobile": + return "" + } + return defaultWeChatConnectScopes +} + +func normalizeWeChatConnectScopeSetting(raw, mode string) string { + switch normalizeWeChatConnectModeSetting(mode) { + case "mp": + switch strings.TrimSpace(raw) { + case "snsapi_base": + return "snsapi_base" + case "snsapi_userinfo": + return "snsapi_userinfo" + default: + return defaultWeChatConnectScopeForMode(mode) + } + case "mobile": + return "" + default: + return defaultWeChatConnectScopes + } +} + +func parseWeChatConnectCapabilitySettings(settings map[string]string, enabled bool, mode string) (bool, bool, bool) { + mode = normalizeWeChatConnectModeSetting(mode) + rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled] + rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled] + rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled] + openConfigured := hasOpen && strings.TrimSpace(rawOpen) != "" + mpConfigured := hasMP && strings.TrimSpace(rawMP) != "" + mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != "" + + if openConfigured || mpConfigured || mobileConfigured { + openEnabled := strings.TrimSpace(rawOpen) == "true" + mpEnabled := strings.TrimSpace(rawMP) == "true" + mobileEnabled := strings.TrimSpace(rawMobile) == "true" + return openEnabled, mpEnabled, mobileEnabled + } + + if !enabled { + return false, false, false + } + if mode == "mp" { + return false, true, false + } + if mode == "mobile" { + return false, false, true + } + return true, false, false +} + +func normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled bool, mode string) string { + mode = normalizeWeChatConnectModeSetting(mode) + switch mode { + case "open": + if openEnabled { + return "open" + } + case "mp": + if mpEnabled { + return "mp" + } + case "mobile": + if mobileEnabled { + return "mobile" + } + } + switch { + case openEnabled: + return "open" + case mpEnabled: + return "mp" + case mobileEnabled: + return "mobile" + default: + return mode + } +} + +func mergeWeChatConnectCapabilitySettings(settings map[string]string, base config.WeChatConnectConfig, enabled bool, mode string) (bool, bool, bool) { + mode = normalizeWeChatConnectModeSetting(firstNonEmpty(mode, base.Mode)) + rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled] + rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled] + rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled] + openConfigured := hasOpen && strings.TrimSpace(rawOpen) != "" + mpConfigured := hasMP && strings.TrimSpace(rawMP) != "" + mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != "" + + if openConfigured || mpConfigured || mobileConfigured { + openEnabled := strings.TrimSpace(rawOpen) == "true" + mpEnabled := strings.TrimSpace(rawMP) == "true" + mobileEnabled := strings.TrimSpace(rawMobile) == "true" + _, enabledConfigured := settings[SettingKeyWeChatConnectEnabled] + if !enabledConfigured && + enabled && + !openEnabled && + !mpEnabled && + !mobileEnabled && + (base.OpenEnabled || base.MPEnabled || base.MobileEnabled) { + return base.OpenEnabled, base.MPEnabled, base.MobileEnabled + } + return openEnabled, mpEnabled, mobileEnabled + } + if !enabled { + return false, false, false + } + if base.OpenEnabled || base.MPEnabled || base.MobileEnabled { + return base.OpenEnabled, base.MPEnabled, base.MobileEnabled + } + return parseWeChatConnectCapabilitySettings(settings, enabled, mode) +} + +func (s *SettingService) effectiveWeChatConnectOAuthConfig(settings map[string]string) WeChatConnectOAuthConfig { + base := config.WeChatConnectConfig{} + if s != nil && s.cfg != nil { + base = s.cfg.WeChat + } + + enabled := base.Enabled + if raw, ok := settings[SettingKeyWeChatConnectEnabled]; ok { + enabled = strings.TrimSpace(raw) == "true" + } + + legacyAppID := strings.TrimSpace(firstNonEmpty( + settings[SettingKeyWeChatConnectAppID], + base.AppID, + base.OpenAppID, + base.MPAppID, + base.MobileAppID, + )) + legacyAppSecret := strings.TrimSpace(firstNonEmpty( + settings[SettingKeyWeChatConnectAppSecret], + base.AppSecret, + base.OpenAppSecret, + base.MPAppSecret, + base.MobileAppSecret, + )) + openAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppID], base.OpenAppID, legacyAppID)) + openAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppSecret], base.OpenAppSecret, legacyAppSecret)) + mpAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppID], base.MPAppID, legacyAppID)) + mpAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppSecret], base.MPAppSecret, legacyAppSecret)) + mobileAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppID], base.MobileAppID, legacyAppID)) + mobileAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppSecret], base.MobileAppSecret, legacyAppSecret)) + + modeRaw := firstNonEmpty(settings[SettingKeyWeChatConnectMode], base.Mode) + openEnabled, mpEnabled, mobileEnabled := mergeWeChatConnectCapabilitySettings(settings, base, enabled, modeRaw) + mode := normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled, modeRaw) + + return WeChatConnectOAuthConfig{ + Enabled: enabled, + LegacyAppID: legacyAppID, + LegacyAppSecret: legacyAppSecret, + OpenAppID: openAppID, + OpenAppSecret: openAppSecret, + MPAppID: mpAppID, + MPAppSecret: mpAppSecret, + MobileAppID: mobileAppID, + MobileAppSecret: mobileAppSecret, + OpenEnabled: openEnabled, + MPEnabled: mpEnabled, + MobileEnabled: mobileEnabled, + Mode: mode, + Scopes: normalizeWeChatConnectScopeSetting(firstNonEmpty(settings[SettingKeyWeChatConnectScopes], base.Scopes), mode), + RedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectRedirectURL], base.RedirectURL)), + FrontendRedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectFrontendRedirectURL], base.FrontendRedirectURL, defaultWeChatConnectFrontend)), + } +} + +func DefaultWeChatConnectScopesForMode(mode string) string { + return defaultWeChatConnectScopeForMode(mode) +} + +func (s *SettingService) parseWeChatConnectOAuthConfig(settings map[string]string) (WeChatConnectOAuthConfig, error) { + cfg := s.effectiveWeChatConnectOAuthConfig(settings) + + if !cfg.Enabled || (!cfg.OpenEnabled && !cfg.MPEnabled) { + return WeChatConnectOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled") + } + if cfg.OpenEnabled { + if cfg.AppIDForMode("open") == "" { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app id not configured") + } + if cfg.AppSecretForMode("open") == "" { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app secret not configured") + } + } + if cfg.MPEnabled { + if cfg.AppIDForMode("mp") == "" { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app id not configured") + } + if cfg.AppSecretForMode("mp") == "" { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app secret not configured") + } + } + if cfg.MobileEnabled { + if cfg.AppIDForMode("mobile") == "" { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app id not configured") + } + if cfg.AppSecretForMode("mobile") == "" { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app secret not configured") + } + } + if v := strings.TrimSpace(cfg.RedirectURL); v != "" { + if err := config.ValidateAbsoluteHTTPURL(v); err != nil { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url invalid") + } + } + if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth frontend redirect url invalid") + } + return cfg, nil +} + +func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string]string) (bool, bool, bool, bool) { + cfg := s.effectiveWeChatConnectOAuthConfig(settings) + if !cfg.Enabled { + return false, false, false, false + } + + openReady := cfg.OpenEnabled && cfg.AppIDForMode("open") != "" && cfg.AppSecretForMode("open") != "" + mpReady := cfg.MPEnabled && cfg.AppIDForMode("mp") != "" && cfg.AppSecretForMode("mp") != "" + mobileReady := cfg.MobileEnabled && cfg.AppIDForMode("mobile") != "" && cfg.AppSecretForMode("mobile") != "" + + return openReady || mpReady, openReady, mpReady, mobileReady +} + +func (s *SettingService) emailOAuthBaseConfig(provider string) config.EmailOAuthProviderConfig { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "github": + cfg := config.EmailOAuthProviderConfig{ + AuthorizeURL: defaultGitHubOAuthAuthorize, + TokenURL: defaultGitHubOAuthToken, + UserInfoURL: defaultGitHubOAuthUserInfo, + EmailsURL: defaultGitHubOAuthEmails, + Scopes: defaultGitHubOAuthScopes, + FrontendRedirectURL: defaultGitHubOAuthFrontend, + } + if s != nil && s.cfg != nil { + cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GitHubOAuth) + } + return cfg + case "google": + cfg := config.EmailOAuthProviderConfig{ + AuthorizeURL: defaultGoogleOAuthAuthorize, + TokenURL: defaultGoogleOAuthToken, + UserInfoURL: defaultGoogleOAuthUserInfo, + Scopes: defaultGoogleOAuthScopes, + FrontendRedirectURL: defaultGoogleOAuthFrontend, + } + if s != nil && s.cfg != nil { + cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GoogleOAuth) + } + return cfg + default: + return config.EmailOAuthProviderConfig{} + } +} + +func mergeEmailOAuthBaseConfig(base, override config.EmailOAuthProviderConfig) config.EmailOAuthProviderConfig { + base.Enabled = override.Enabled + if strings.TrimSpace(override.ClientID) != "" { + base.ClientID = strings.TrimSpace(override.ClientID) + } + if strings.TrimSpace(override.ClientSecret) != "" { + base.ClientSecret = strings.TrimSpace(override.ClientSecret) + } + if strings.TrimSpace(override.AuthorizeURL) != "" { + base.AuthorizeURL = strings.TrimSpace(override.AuthorizeURL) + } + if strings.TrimSpace(override.TokenURL) != "" { + base.TokenURL = strings.TrimSpace(override.TokenURL) + } + if strings.TrimSpace(override.UserInfoURL) != "" { + base.UserInfoURL = strings.TrimSpace(override.UserInfoURL) + } + if strings.TrimSpace(override.EmailsURL) != "" { + base.EmailsURL = strings.TrimSpace(override.EmailsURL) + } + if strings.TrimSpace(override.Scopes) != "" { + base.Scopes = strings.TrimSpace(override.Scopes) + } + if strings.TrimSpace(override.RedirectURL) != "" { + base.RedirectURL = strings.TrimSpace(override.RedirectURL) + } + if strings.TrimSpace(override.FrontendRedirectURL) != "" { + base.FrontendRedirectURL = strings.TrimSpace(override.FrontendRedirectURL) + } + return base +} + +func (s *SettingService) emailOAuthPublicEnabled(settings map[string]string, provider string) bool { + cfg := s.effectiveEmailOAuthConfig(settings, provider) + return cfg.Enabled && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != "" +} + +func (s *SettingService) effectiveEmailOAuthConfig(settings map[string]string, provider string) config.EmailOAuthProviderConfig { + cfg := s.emailOAuthBaseConfig(provider) + switch strings.ToLower(strings.TrimSpace(provider)) { + case "github": + if raw, ok := settings[SettingKeyGitHubOAuthEnabled]; ok { + cfg.Enabled = raw == "true" + } + cfg.ClientID = firstNonEmpty(settings[SettingKeyGitHubOAuthClientID], cfg.ClientID) + cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGitHubOAuthClientSecret], cfg.ClientSecret) + cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthRedirectURL], cfg.RedirectURL) + cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGitHubOAuthFrontend) + case "google": + if raw, ok := settings[SettingKeyGoogleOAuthEnabled]; ok { + cfg.Enabled = raw == "true" + } + cfg.ClientID = firstNonEmpty(settings[SettingKeyGoogleOAuthClientID], cfg.ClientID) + cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGoogleOAuthClientSecret], cfg.ClientSecret) + cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthRedirectURL], cfg.RedirectURL) + cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGoogleOAuthFrontend) + } + return cfg +} + +func oidcUsePKCECompatibilityDefault(base config.OIDCConnectConfig) bool { + if base.UsePKCEExplicit { + return base.UsePKCE + } + return true +} + +func oidcValidateIDTokenCompatibilityDefault(base config.OIDCConnectConfig) bool { + if base.ValidateIDTokenExplicit { + return base.ValidateIDToken + } + return true +} + +func oidcCompatibilityWriteDefault(base config.OIDCConnectConfig, configured bool, raw string, explicit bool, explicitValue bool) bool { + if configured { + return strings.TrimSpace(raw) == "true" + } + if explicit { + return explicitValue + } + return false +} + +func (s *SettingService) OIDCSecurityWriteDefaults(ctx context.Context) (bool, bool, error) { + rawSettings, err := s.settingRepo.GetMultiple(ctx, []string{ + SettingKeyOIDCConnectUsePKCE, + SettingKeyOIDCConnectValidateIDToken, + }) + if err != nil { + return false, false, fmt.Errorf("get oidc security write defaults: %w", err) + } + + base := config.OIDCConnectConfig{} + if s != nil && s.cfg != nil { + base = s.cfg.OIDC + } + + rawUsePKCE, hasUsePKCE := rawSettings[SettingKeyOIDCConnectUsePKCE] + rawValidateIDToken, hasValidateIDToken := rawSettings[SettingKeyOIDCConnectValidateIDToken] + + return oidcCompatibilityWriteDefault(base, hasUsePKCE, rawUsePKCE, base.UsePKCEExplicit, base.UsePKCE), + oidcCompatibilityWriteDefault(base, hasValidateIDToken, rawValidateIDToken, base.ValidateIDTokenExplicit, base.ValidateIDToken), + nil +} + +func (s *SettingService) GetEmailOAuthProviderConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider != "github" && provider != "google" { + return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_PROVIDER_NOT_FOUND", "oauth provider not found") + } + keys := []string{ + SettingKeyGitHubOAuthEnabled, + SettingKeyGitHubOAuthClientID, + SettingKeyGitHubOAuthClientSecret, + SettingKeyGitHubOAuthRedirectURL, + SettingKeyGitHubOAuthFrontendRedirectURL, + SettingKeyGoogleOAuthEnabled, + SettingKeyGoogleOAuthClientID, + SettingKeyGoogleOAuthClientSecret, + SettingKeyGoogleOAuthRedirectURL, + SettingKeyGoogleOAuthFrontendRedirectURL, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return config.EmailOAuthProviderConfig{}, fmt.Errorf("get email oauth settings: %w", err) + } + cfg := s.effectiveEmailOAuthConfig(settings, provider) + if !cfg.Enabled { + return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") + } + if strings.TrimSpace(cfg.ClientID) == "" { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") + } + if strings.TrimSpace(cfg.ClientSecret) == "" { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") + } + for label, rawURL := range map[string]string{ + "authorize": cfg.AuthorizeURL, + "token": cfg.TokenURL, + "userinfo": cfg.UserInfoURL, + "redirect": cfg.RedirectURL, + } { + if strings.TrimSpace(rawURL) == "" { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url not configured") + } + if err := config.ValidateAbsoluteHTTPURL(rawURL); err != nil { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url invalid") + } + } + if strings.TrimSpace(cfg.EmailsURL) != "" { + if err := config.ValidateAbsoluteHTTPURL(cfg.EmailsURL); err != nil { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth emails url invalid") + } + } + if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") + } + return cfg, nil +} + +// GetLinuxDoConnectOAuthConfig 返回用于登录的"最终生效" LinuxDo Connect 配置。 +// +// 优先级: +// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 +// - 否则回退到 config.yaml/env 的值 +func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) { + if s == nil || s.cfg == nil { + return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + + effective := s.cfg.LinuxDo + + keys := []string{ + SettingKeyLinuxDoConnectEnabled, + SettingKeyLinuxDoConnectClientID, + SettingKeyLinuxDoConnectClientSecret, + SettingKeyLinuxDoConnectRedirectURL, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return config.LinuxDoConnectConfig{}, fmt.Errorf("get linuxdo connect settings: %w", err) + } + + if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { + effective.Enabled = raw == "true" + } + if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" { + effective.ClientID = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyLinuxDoConnectClientSecret]; ok && strings.TrimSpace(v) != "" { + effective.ClientSecret = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + effective.RedirectURL = strings.TrimSpace(v) + } + if !effective.Enabled { + return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") + } + + // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。 + if strings.TrimSpace(effective.ClientID) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") + } + if strings.TrimSpace(effective.AuthorizeURL) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured") + } + if strings.TrimSpace(effective.TokenURL) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured") + } + if strings.TrimSpace(effective.UserInfoURL) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url not configured") + } + if strings.TrimSpace(effective.RedirectURL) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured") + } + if strings.TrimSpace(effective.FrontendRedirectURL) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") + } + + if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") + } + if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") + } + + method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod)) + switch method { + case "", "client_secret_post", "client_secret_basic": + if strings.TrimSpace(effective.ClientSecret) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") + } + case "none": + default: + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") + } + + return effective, nil +} + +// GetDingTalkConnectOAuthConfig 返回用于登录的"最终生效" DingTalk Connect 配置。 +// +// 优先级: +// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 +// - 否则回退到 config.yaml/env 的值 +func (s *SettingService) GetDingTalkConnectOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) { + if s == nil || s.cfg == nil { + return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + + effective := s.cfg.DingTalk + + keys := []string{ + SettingKeyDingTalkConnectEnabled, + SettingKeyDingTalkConnectClientID, + SettingKeyDingTalkConnectClientSecret, + SettingKeyDingTalkConnectRedirectURL, + SettingKeyDingTalkConnectCorpRestrictionPolicy, + SettingKeyDingTalkConnectInternalCorpID, + SettingKeyDingTalkConnectBypassRegistration, + SettingKeyDingTalkConnectSyncCorpEmail, + SettingKeyDingTalkConnectSyncDisplayName, + SettingKeyDingTalkConnectSyncDept, + SettingKeyDingTalkConnectSyncCorpEmailAttrKey, + SettingKeyDingTalkConnectSyncDisplayNameAttrKey, + SettingKeyDingTalkConnectSyncDeptAttrKey, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return config.DingTalkConnectConfig{}, fmt.Errorf("get dingtalk connect settings: %w", err) + } + + if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { + effective.Enabled = raw == "true" + } + if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" { + effective.ClientID = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyDingTalkConnectClientSecret]; ok && strings.TrimSpace(v) != "" { + effective.ClientSecret = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + effective.RedirectURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" { + effective.CorpRestrictionPolicy = strings.TrimSpace(v) + } + effective.CorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(effective.CorpRestrictionPolicy) + if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" { + effective.InternalCorpID = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" { + effective.BypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true") + } + // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false, + // 以保证 OAuth callback 看到的 effective config 永远是一致状态。 + if effective.CorpRestrictionPolicy != "internal_only" { + effective.BypassRegistration = false + } + + if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" { + effective.SyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true") + } + if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" { + effective.SyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true") + } + if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" { + effective.SyncDept = strings.EqualFold(strings.TrimSpace(v), "true") + } + // 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。 + if effective.CorpRestrictionPolicy != "internal_only" { + effective.SyncCorpEmail = false + effective.SyncDisplayName = false + effective.SyncDept = false + } + + // 身份同步目标 attr key(DB 空 → fallback 默认值) + if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]); v != "" { + effective.SyncCorpEmailAttrKey = v + } + if effective.SyncCorpEmailAttrKey == "" { + effective.SyncCorpEmailAttrKey = "dingtalk_email" + } + if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]); v != "" { + effective.SyncDisplayNameAttrKey = v + } + if effective.SyncDisplayNameAttrKey == "" { + effective.SyncDisplayNameAttrKey = "dingtalk_name" + } + if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]); v != "" { + effective.SyncDeptAttrKey = v + } + if effective.SyncDeptAttrKey == "" { + effective.SyncDeptAttrKey = "dingtalk_department" + } + + if !effective.Enabled { + return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled") + } + + // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。 + if strings.TrimSpace(effective.ClientID) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client id not configured") + } + if strings.TrimSpace(effective.AuthorizeURL) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url not configured") + } + if strings.TrimSpace(effective.TokenURL) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url not configured") + } + if strings.TrimSpace(effective.UserInfoURL) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url not configured") + } + if strings.TrimSpace(effective.RedirectURL) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url not configured") + } + if strings.TrimSpace(effective.FrontendRedirectURL) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url not configured") + } + + if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url invalid") + } + if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url invalid") + } + if strings.TrimSpace(effective.ClientSecret) == "" { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client secret not configured") + } + + // 镜像 admin handler 行为:internal_only policy 隐式要求 AppType=internal + if effective.CorpRestrictionPolicy == "internal_only" { + effective.AppType = "internal" + } + + if err := config.ValidateDingTalkConfig(effective); err != nil { + return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", err.Error()) + } + + return effective, nil +} + +// GetWeChatConnectOAuthConfig 返回用于登录的最终生效 WeChat Connect 配置。 +// +// WeChat Connect 已回归 DB 系统设置模型,不再回退到 config/env。 +func (s *SettingService) GetWeChatConnectOAuthConfig(ctx context.Context) (WeChatConnectOAuthConfig, error) { + keys := []string{ + SettingKeyWeChatConnectEnabled, + SettingKeyWeChatConnectAppID, + SettingKeyWeChatConnectAppSecret, + SettingKeyWeChatConnectOpenAppID, + SettingKeyWeChatConnectOpenAppSecret, + SettingKeyWeChatConnectMPAppID, + SettingKeyWeChatConnectMPAppSecret, + SettingKeyWeChatConnectMobileAppID, + SettingKeyWeChatConnectMobileAppSecret, + SettingKeyWeChatConnectOpenEnabled, + SettingKeyWeChatConnectMPEnabled, + SettingKeyWeChatConnectMobileEnabled, + SettingKeyWeChatConnectMode, + SettingKeyWeChatConnectScopes, + SettingKeyWeChatConnectRedirectURL, + SettingKeyWeChatConnectFrontendRedirectURL, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return WeChatConnectOAuthConfig{}, fmt.Errorf("get wechat connect settings: %w", err) + } + return s.parseWeChatConnectOAuthConfig(settings) +} + +// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。 +// +// 优先级: +// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 +// - 否则回退到 config.yaml/env 的值 +func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) { + if s == nil || s.cfg == nil { + return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + + effective := s.cfg.OIDC + + keys := []string{ + SettingKeyOIDCConnectEnabled, + SettingKeyOIDCConnectProviderName, + SettingKeyOIDCConnectClientID, + SettingKeyOIDCConnectClientSecret, + SettingKeyOIDCConnectIssuerURL, + SettingKeyOIDCConnectDiscoveryURL, + SettingKeyOIDCConnectAuthorizeURL, + SettingKeyOIDCConnectTokenURL, + SettingKeyOIDCConnectUserInfoURL, + SettingKeyOIDCConnectJWKSURL, + SettingKeyOIDCConnectScopes, + SettingKeyOIDCConnectRedirectURL, + SettingKeyOIDCConnectFrontendRedirectURL, + SettingKeyOIDCConnectTokenAuthMethod, + SettingKeyOIDCConnectUsePKCE, + SettingKeyOIDCConnectValidateIDToken, + SettingKeyOIDCConnectAllowedSigningAlgs, + SettingKeyOIDCConnectClockSkewSeconds, + SettingKeyOIDCConnectRequireEmailVerified, + SettingKeyOIDCConnectUserInfoEmailPath, + SettingKeyOIDCConnectUserInfoIDPath, + SettingKeyOIDCConnectUserInfoUsernamePath, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return config.OIDCConnectConfig{}, fmt.Errorf("get oidc connect settings: %w", err) + } + + if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { + effective.Enabled = raw == "true" + } + if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" { + effective.ProviderName = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" { + effective.ClientID = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectClientSecret]; ok && strings.TrimSpace(v) != "" { + effective.ClientSecret = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" { + effective.IssuerURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" { + effective.DiscoveryURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" { + effective.AuthorizeURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" { + effective.TokenURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" { + effective.UserInfoURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" { + effective.JWKSURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" { + effective.Scopes = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + effective.RedirectURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" { + effective.FrontendRedirectURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" { + effective.TokenAuthMethod = strings.ToLower(strings.TrimSpace(v)) + } + if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok { + effective.UsePKCE = raw == "true" + } else { + effective.UsePKCE = oidcUsePKCECompatibilityDefault(effective) + } + if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok { + effective.ValidateIDToken = raw == "true" + } else { + effective.ValidateIDToken = oidcValidateIDTokenCompatibilityDefault(effective) + } + if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" { + effective.AllowedSigningAlgs = strings.TrimSpace(v) + } + if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" { + if parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil { + effective.ClockSkewSeconds = parsed + } + } + if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok { + effective.RequireEmailVerified = raw == "true" + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok { + effective.UserInfoEmailPath = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok { + effective.UserInfoIDPath = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok { + effective.UserInfoUsernamePath = strings.TrimSpace(v) + } + + if !effective.Enabled { + return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") + } + if strings.TrimSpace(effective.ProviderName) == "" { + effective.ProviderName = "OIDC" + } + if strings.TrimSpace(effective.ClientID) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") + } + if strings.TrimSpace(effective.IssuerURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url not configured") + } + if strings.TrimSpace(effective.RedirectURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured") + } + if strings.TrimSpace(effective.FrontendRedirectURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") + } + if !scopesContainOpenID(effective.Scopes) { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth scopes must contain openid") + } + if effective.ClockSkewSeconds < 0 || effective.ClockSkewSeconds > 600 { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth clock skew must be between 0 and 600") + } + + if err := config.ValidateAbsoluteHTTPURL(effective.IssuerURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url invalid") + } + + discoveryURL := strings.TrimSpace(effective.DiscoveryURL) + if discoveryURL == "" { + discoveryURL = oidcDefaultDiscoveryURL(effective.IssuerURL) + effective.DiscoveryURL = discoveryURL + } + if discoveryURL != "" { + if err := config.ValidateAbsoluteHTTPURL(discoveryURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery url invalid") + } + } + + needsDiscovery := strings.TrimSpace(effective.AuthorizeURL) == "" || + strings.TrimSpace(effective.TokenURL) == "" || + (effective.ValidateIDToken && strings.TrimSpace(effective.JWKSURL) == "") + if needsDiscovery && discoveryURL != "" { + metadata, resolveErr := oidcResolveProviderMetadata(ctx, discoveryURL) + if resolveErr != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery resolve failed").WithCause(resolveErr) + } + if strings.TrimSpace(effective.AuthorizeURL) == "" { + effective.AuthorizeURL = strings.TrimSpace(metadata.AuthorizationEndpoint) + } + if strings.TrimSpace(effective.TokenURL) == "" { + effective.TokenURL = strings.TrimSpace(metadata.TokenEndpoint) + } + if strings.TrimSpace(effective.UserInfoURL) == "" { + effective.UserInfoURL = strings.TrimSpace(metadata.UserInfoEndpoint) + } + if strings.TrimSpace(effective.JWKSURL) == "" { + effective.JWKSURL = strings.TrimSpace(metadata.JWKSURI) + } + } + + if strings.TrimSpace(effective.AuthorizeURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured") + } + if strings.TrimSpace(effective.TokenURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured") + } + if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") + } + if v := strings.TrimSpace(effective.UserInfoURL); v != "" { + if err := config.ValidateAbsoluteHTTPURL(v); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") + } + } + if effective.ValidateIDToken { + if strings.TrimSpace(effective.JWKSURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url not configured") + } + if strings.TrimSpace(effective.AllowedSigningAlgs) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth signing algs not configured") + } + } + if v := strings.TrimSpace(effective.JWKSURL); v != "" { + if err := config.ValidateAbsoluteHTTPURL(v); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url invalid") + } + } + if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") + } + if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") + } + + method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod)) + switch method { + case "", "client_secret_post", "client_secret_basic": + if strings.TrimSpace(effective.ClientSecret) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") + } + case "none": + default: + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") + } + + return effective, nil +} + +func scopesContainOpenID(scopes string) bool { + for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) { + if scope == "openid" { + return true + } + } + return false +} + +type oidcProviderMetadata struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + JWKSURI string `json:"jwks_uri"` +} + +func oidcDefaultDiscoveryURL(issuerURL string) string { + issuerURL = strings.TrimSpace(issuerURL) + if issuerURL == "" { + return "" + } + return strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration" +} + +func oidcResolveProviderMetadata(ctx context.Context, discoveryURL string) (*oidcProviderMetadata, error) { + discoveryURL = strings.TrimSpace(discoveryURL) + if discoveryURL == "" { + return nil, fmt.Errorf("discovery url is empty") + } + + resp, err := req.C(). + SetTimeout(15*time.Second). + R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + Get(discoveryURL) + if err != nil { + return nil, fmt.Errorf("request discovery document: %w", err) + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("discovery request failed: status=%d", resp.StatusCode) + } + + metadata := &oidcProviderMetadata{} + if err := json.Unmarshal(resp.Bytes(), metadata); err != nil { + return nil, fmt.Errorf("parse discovery document: %w", err) + } + return metadata, nil +} diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go new file mode 100644 index 0000000000..bf71b559a0 --- /dev/null +++ b/backend/internal/service/setting_parse.go @@ -0,0 +1,1157 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "math" + "sort" + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" +) + +// InitializeDefaultSettings 初始化默认设置 +func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { + // 检查是否已有设置 + _, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled) + if err == nil { + // 已有设置,不需要初始化 + return nil + } + if !errors.Is(err, ErrSettingNotFound) { + return fmt.Errorf("check existing settings: %w", err) + } + + oidcUsePKCEDefault := true + oidcValidateIDTokenDefault := true + if s != nil && s.cfg != nil { + if s.cfg.OIDC.UsePKCEExplicit { + oidcUsePKCEDefault = s.cfg.OIDC.UsePKCE + } + if s.cfg.OIDC.ValidateIDTokenExplicit { + oidcValidateIDTokenDefault = s.cfg.OIDC.ValidateIDToken + } + } + loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(defaultLoginAgreementDocuments()) + if err != nil { + return err + } + + // 初始化默认设置 + defaults := map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyEmailVerifyEnabled: "false", + SettingKeyRegistrationEmailSuffixWhitelist: "[]", + SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 + SettingKeyLoginAgreementEnabled: "false", + SettingKeyLoginAgreementMode: defaultLoginAgreementMode, + SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate, + SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON, + SettingKeyAPIKeyACLTrustForwardedIP: "false", + SettingKeySiteName: "Sub2API", + SettingKeySiteLogo: "", + SettingKeyPurchaseSubscriptionEnabled: "false", + SettingKeyPurchaseSubscriptionURL: "", + SettingKeyTableDefaultPageSize: "20", + SettingKeyTablePageSizeOptions: "[10,20,50,100]", + SettingKeyCustomMenuItems: "[]", + SettingKeyCustomEndpoints: "[]", + SettingKeyWeChatConnectEnabled: "false", + SettingKeyWeChatConnectAppID: "", + SettingKeyWeChatConnectAppSecret: "", + SettingKeyWeChatConnectOpenAppID: "", + SettingKeyWeChatConnectOpenAppSecret: "", + SettingKeyWeChatConnectMPAppID: "", + SettingKeyWeChatConnectMPAppSecret: "", + SettingKeyWeChatConnectMobileAppID: "", + SettingKeyWeChatConnectMobileAppSecret: "", + SettingKeyWeChatConnectOpenEnabled: "false", + SettingKeyWeChatConnectMPEnabled: "false", + SettingKeyWeChatConnectMobileEnabled: "false", + SettingKeyWeChatConnectMode: "open", + SettingKeyWeChatConnectScopes: "snsapi_login", + SettingKeyWeChatConnectRedirectURL: "", + SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend, + SettingKeyGitHubOAuthEnabled: "false", + SettingKeyGitHubOAuthClientID: "", + SettingKeyGitHubOAuthClientSecret: "", + SettingKeyGitHubOAuthRedirectURL: "", + SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend, + SettingKeyGoogleOAuthEnabled: "false", + SettingKeyGoogleOAuthClientID: "", + SettingKeyGoogleOAuthClientSecret: "", + SettingKeyGoogleOAuthRedirectURL: "", + SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend, + SettingKeyOIDCConnectEnabled: "false", + SettingKeyOIDCConnectProviderName: "OIDC", + SettingKeyOIDCConnectClientID: "", + SettingKeyOIDCConnectClientSecret: "", + SettingKeyOIDCConnectIssuerURL: "", + SettingKeyOIDCConnectDiscoveryURL: "", + SettingKeyOIDCConnectAuthorizeURL: "", + SettingKeyOIDCConnectTokenURL: "", + SettingKeyOIDCConnectUserInfoURL: "", + SettingKeyOIDCConnectJWKSURL: "", + SettingKeyOIDCConnectScopes: "openid email profile", + SettingKeyOIDCConnectRedirectURL: "", + SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback", + SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post", + SettingKeyOIDCConnectUsePKCE: strconv.FormatBool(oidcUsePKCEDefault), + SettingKeyOIDCConnectValidateIDToken: strconv.FormatBool(oidcValidateIDTokenDefault), + SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256", + SettingKeyOIDCConnectClockSkewSeconds: "120", + SettingKeyOIDCConnectRequireEmailVerified: "false", + SettingKeyOIDCConnectUserInfoEmailPath: "", + SettingKeyOIDCConnectUserInfoIDPath: "", + SettingKeyOIDCConnectUserInfoUsernamePath: "", + SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), + SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), + SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64), + SettingKeyAffiliateRebateFreezeHours: strconv.Itoa(AffiliateRebateFreezeHoursDefault), + SettingKeyAffiliateRebateDurationDays: strconv.Itoa(AffiliateRebateDurationDaysDefault), + SettingKeyAffiliateRebatePerInviteeCap: strconv.FormatFloat(AffiliateRebatePerInviteeCapDefault, 'f', 2, 64), + SettingKeyDefaultUserRPMLimit: "0", + SettingKeyDefaultSubscriptions: "[]", + SettingKeyAuthSourceDefaultEmailBalance: "0", + SettingKeyAuthSourceDefaultEmailConcurrency: "5", + SettingKeyAuthSourceDefaultEmailSubscriptions: "[]", + SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false", + SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultLinuxDoBalance: "0", + SettingKeyAuthSourceDefaultLinuxDoConcurrency: "5", + SettingKeyAuthSourceDefaultLinuxDoSubscriptions: "[]", + SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "false", + SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultOIDCBalance: "0", + SettingKeyAuthSourceDefaultOIDCConcurrency: "5", + SettingKeyAuthSourceDefaultOIDCSubscriptions: "[]", + SettingKeyAuthSourceDefaultOIDCGrantOnSignup: "false", + SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultWeChatBalance: "0", + SettingKeyAuthSourceDefaultWeChatConcurrency: "5", + SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]", + SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false", + SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultGitHubBalance: "0", + SettingKeyAuthSourceDefaultGitHubConcurrency: "5", + SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]", + SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false", + SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultGoogleBalance: "0", + SettingKeyAuthSourceDefaultGoogleConcurrency: "5", + SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]", + SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false", + SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultDingTalkBalance: "0", + SettingKeyAuthSourceDefaultDingTalkConcurrency: "5", + SettingKeyAuthSourceDefaultDingTalkSubscriptions: "[]", + SettingKeyAuthSourceDefaultDingTalkGrantOnSignup: "false", + SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind: "false", + SettingKeyForceEmailOnThirdPartySignup: "false", + SettingKeySMTPPort: "587", + SettingKeySMTPUseTLS: "false", + // Model fallback defaults + SettingKeyEnableModelFallback: "false", + SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022", + SettingKeyFallbackModelOpenAI: "gpt-4o", + SettingKeyFallbackModelGemini: "gemini-2.5-pro", + SettingKeyFallbackModelAntigravity: "gemini-2.5-pro", + // Identity patch defaults + SettingKeyEnableIdentityPatch: "true", + SettingKeyIdentityPatchPrompt: "", + + // Ops monitoring defaults (vNext) + SettingKeyOpsMonitoringEnabled: "true", + SettingKeyOpsRealtimeMonitoringEnabled: "true", + SettingKeyOpsQueryModeDefault: "auto", + SettingKeyOpsMetricsIntervalSeconds: "60", + + // Channel monitor defaults (enabled, 60s) + SettingKeyChannelMonitorEnabled: "true", + SettingKeyChannelMonitorDefaultIntervalSeconds: "60", + + // Available channels feature (default disabled; opt-in) + SettingKeyAvailableChannelsEnabled: "false", + + // Affiliate (邀请返利) feature (default disabled; opt-in) + SettingKeyAffiliateEnabled: "false", + + // 风控中心功能(默认关闭,显式启用) + SettingKeyRiskControlEnabled: "false", + + // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) + SettingKeyCyberSessionBlockEnabled: "false", + SettingKeyCyberSessionBlockTTLSeconds: "3600", + + // Claude Code version check (default: empty = disabled) + SettingKeyMinClaudeCodeVersion: "", + SettingKeyMaxClaudeCodeVersion: "", + + // codex_cli_only 加固(默认:版本不检查、名单空、默认种子指纹信号) + SettingKeyMinCodexVersion: "", + SettingKeyMaxCodexVersion: "", + SettingKeyCodexCLIOnlyBlacklist: "", + SettingKeyCodexCLIOnlyWhitelist: "", + SettingKeyCodexCLIOnlyAllowAppServerClients: "false", + SettingKeyCodexCLIOnlyEngineFingerprintSignals: openai.DefaultEngineFingerprintSignalsJSON(), + + // 分组隔离(默认不允许未分组 Key 调度) + SettingKeyAllowUngroupedKeyScheduling: "false", + SettingKeyEnableAnthropicCacheTTL1hInjection: "false", + SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()), + SettingKeyEnableClientDatelineNormalization: "true", + SettingKeyAntigravityUserAgentVersion: "", + SettingKeyOpenAICodexUserAgent: "", + SettingPaymentVisibleMethodAlipaySource: "", + SettingPaymentVisibleMethodWxpaySource: "", + SettingPaymentVisibleMethodAlipayEnabled: "false", + SettingPaymentVisibleMethodWxpayEnabled: "false", + openAIAdvancedSchedulerSettingKey: "false", + SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled: "false", + SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled: "false", + SettingKeyOpenAIAdvancedSchedulerLBTopK: "", + SettingKeyOpenAIAdvancedSchedulerWeightPriority: "", + SettingKeyOpenAIAdvancedSchedulerWeightLoad: "", + SettingKeyOpenAIAdvancedSchedulerWeightQueue: "", + SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: "", + SettingKeyOpenAIAdvancedSchedulerWeightTTFT: "", + SettingKeyOpenAIAdvancedSchedulerWeightReset: "", + SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: "", + SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: "", + SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: "", + + SettingKeyAllowUserViewErrorRequests: "false", + } + + return s.settingRepo.SetMultiple(ctx, defaults) +} + +// parseSettings 解析设置到结构体 +func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings { + emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" + loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments]) + loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt]) + if loginAgreementUpdatedAt == "" { + loginAgreementUpdatedAt = defaultLoginAgreementDate + } + apiKeyACLTrustForwardedIP := false + if value, ok := settings[SettingKeyAPIKeyACLTrustForwardedIP]; ok { + apiKeyACLTrustForwardedIP = value == "true" + } else if s != nil && s.cfg != nil { + apiKeyACLTrustForwardedIP = s.cfg.Security.TrustForwardedIPForAPIKeyACL + } + result := &SystemSettings{ + RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", + EmailVerifyEnabled: emailVerifyEnabled, + RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]), + PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 + PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", + FrontendURL: settings[SettingKeyFrontendURL], + InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", + TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true", + LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocuments, + SMTPHost: settings[SettingKeySMTPHost], + SMTPUsername: settings[SettingKeySMTPUsername], + SMTPFrom: settings[SettingKeySMTPFrom], + SMTPFromName: settings[SettingKeySMTPFromName], + SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true", + SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "", + TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", + TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], + TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "", + APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP, + SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), + SiteLogo: settings[SettingKeySiteLogo], + SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), + APIBaseURL: settings[SettingKeyAPIBaseURL], + ContactInfo: settings[SettingKeyContactInfo], + DocURL: settings[SettingKeyDocURL], + HomeContent: settings[SettingKeyHomeContent], + HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", + PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", + PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), + CustomMenuItems: settings[SettingKeyCustomMenuItems], + CustomEndpoints: settings[SettingKeyCustomEndpoints], + BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", + } + result.TableDefaultPageSize, result.TablePageSizeOptions = parseTablePreferences( + settings[SettingKeyTableDefaultPageSize], + settings[SettingKeyTablePageSizeOptions], + ) + + // 解析整数类型 + if port, err := strconv.Atoi(settings[SettingKeySMTPPort]); err == nil { + result.SMTPPort = port + } else { + result.SMTPPort = 587 + } + + if concurrency, err := strconv.Atoi(settings[SettingKeyDefaultConcurrency]); err == nil { + result.DefaultConcurrency = concurrency + } else { + result.DefaultConcurrency = s.cfg.Default.UserConcurrency + } + + if rpm, err := strconv.Atoi(settings[SettingKeyDefaultUserRPMLimit]); err == nil && rpm >= 0 { + result.DefaultUserRPMLimit = rpm + } + + // 解析浮点数类型 + if balance, err := strconv.ParseFloat(settings[SettingKeyDefaultBalance], 64); err == nil { + result.DefaultBalance = balance + } else { + result.DefaultBalance = s.cfg.Default.UserBalance + } + if rebateRate, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebateRate], 64); err == nil { + result.AffiliateRebateRate = clampAffiliateRebateRate(rebateRate) + } else { + result.AffiliateRebateRate = AffiliateRebateRateDefault + } + if freezeHours, err := strconv.Atoi(settings[SettingKeyAffiliateRebateFreezeHours]); err == nil && freezeHours >= 0 { + if freezeHours > AffiliateRebateFreezeHoursMax { + freezeHours = AffiliateRebateFreezeHoursMax + } + result.AffiliateRebateFreezeHours = freezeHours + } + if durationDays, err := strconv.Atoi(settings[SettingKeyAffiliateRebateDurationDays]); err == nil && durationDays >= 0 { + if durationDays > AffiliateRebateDurationDaysMax { + durationDays = AffiliateRebateDurationDaysMax + } + result.AffiliateRebateDurationDays = durationDays + } + if perInviteeCap, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebatePerInviteeCap], 64); err == nil && perInviteeCap >= 0 { + result.AffiliateRebatePerInviteeCap = perInviteeCap + } + result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions]) + + // 敏感信息直接返回,方便测试连接时使用 + result.SMTPPassword = settings[SettingKeySMTPPassword] + result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey] + + // LinuxDo Connect 设置: + // - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭) + // - 支持在后台“系统设置”中覆盖并持久化(存储于 DB) + linuxDoBase := config.LinuxDoConnectConfig{} + if s.cfg != nil { + linuxDoBase = s.cfg.LinuxDo + } + + if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { + result.LinuxDoConnectEnabled = raw == "true" + } else { + result.LinuxDoConnectEnabled = linuxDoBase.Enabled + } + + if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" { + result.LinuxDoConnectClientID = strings.TrimSpace(v) + } else { + result.LinuxDoConnectClientID = linuxDoBase.ClientID + } + + if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.LinuxDoConnectRedirectURL = strings.TrimSpace(v) + } else { + result.LinuxDoConnectRedirectURL = linuxDoBase.RedirectURL + } + + result.LinuxDoConnectClientSecret = strings.TrimSpace(settings[SettingKeyLinuxDoConnectClientSecret]) + if result.LinuxDoConnectClientSecret == "" { + result.LinuxDoConnectClientSecret = strings.TrimSpace(linuxDoBase.ClientSecret) + } + result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != "" + + // DingTalk Connect 设置: + // - 兼容 config.yaml/env + // - 支持后台系统设置覆盖并持久化(存储于 DB) + dingTalkBase := config.DingTalkConnectConfig{} + if s.cfg != nil { + dingTalkBase = s.cfg.DingTalk + } + + if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { + result.DingTalkConnectEnabled = raw == "true" + } else { + result.DingTalkConnectEnabled = dingTalkBase.Enabled + } + + if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectClientID = strings.TrimSpace(v) + } else { + result.DingTalkConnectClientID = dingTalkBase.ClientID + } + + if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectRedirectURL = strings.TrimSpace(v) + } else { + result.DingTalkConnectRedirectURL = dingTalkBase.RedirectURL + } + + result.DingTalkConnectClientSecret = strings.TrimSpace(settings[SettingKeyDingTalkConnectClientSecret]) + if result.DingTalkConnectClientSecret == "" { + result.DingTalkConnectClientSecret = strings.TrimSpace(dingTalkBase.ClientSecret) + } + result.DingTalkConnectClientSecretConfigured = result.DingTalkConnectClientSecret != "" + + if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectCorpRestrictionPolicy = strings.TrimSpace(v) + } else { + result.DingTalkConnectCorpRestrictionPolicy = dingTalkBase.CorpRestrictionPolicy + } + result.DingTalkConnectCorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(result.DingTalkConnectCorpRestrictionPolicy) + + if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectInternalCorpID = strings.TrimSpace(v) + } else { + result.DingTalkConnectInternalCorpID = dingTalkBase.InternalCorpID + } + + if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectBypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.DingTalkConnectBypassRegistration = dingTalkBase.BypassRegistration + } + // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false, + // 以保证加载出的 effective config 永远是一致状态。 + if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" { + result.DingTalkConnectBypassRegistration = false + } + + if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectSyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.DingTalkConnectSyncCorpEmail = dingTalkBase.SyncCorpEmail + } + if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectSyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.DingTalkConnectSyncDisplayName = dingTalkBase.SyncDisplayName + } + if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectSyncDept = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.DingTalkConnectSyncDept = dingTalkBase.SyncDept + } + // 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。 + if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" { + result.DingTalkConnectSyncCorpEmail = false + result.DingTalkConnectSyncDisplayName = false + result.DingTalkConnectSyncDept = false + } + + // 身份同步目标 attr key(DB 空 → fallback 默认值) + result.DingTalkConnectSyncCorpEmailAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]) + if result.DingTalkConnectSyncCorpEmailAttrKey == "" { + if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrKey); v != "" { + result.DingTalkConnectSyncCorpEmailAttrKey = v + } else { + result.DingTalkConnectSyncCorpEmailAttrKey = "dingtalk_email" + } + } + result.DingTalkConnectSyncDisplayNameAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]) + if result.DingTalkConnectSyncDisplayNameAttrKey == "" { + if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrKey); v != "" { + result.DingTalkConnectSyncDisplayNameAttrKey = v + } else { + result.DingTalkConnectSyncDisplayNameAttrKey = "dingtalk_name" + } + } + result.DingTalkConnectSyncDeptAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]) + if result.DingTalkConnectSyncDeptAttrKey == "" { + if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrKey); v != "" { + result.DingTalkConnectSyncDeptAttrKey = v + } else { + result.DingTalkConnectSyncDeptAttrKey = "dingtalk_department" + } + } + + // 身份同步目标 attr 显示名称(DB 空 → fallback 默认中文) + result.DingTalkConnectSyncCorpEmailAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrName]) + if result.DingTalkConnectSyncCorpEmailAttrName == "" { + if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrName); v != "" { + result.DingTalkConnectSyncCorpEmailAttrName = v + } else { + result.DingTalkConnectSyncCorpEmailAttrName = "钉钉企业邮箱" + } + } + result.DingTalkConnectSyncDisplayNameAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrName]) + if result.DingTalkConnectSyncDisplayNameAttrName == "" { + if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrName); v != "" { + result.DingTalkConnectSyncDisplayNameAttrName = v + } else { + result.DingTalkConnectSyncDisplayNameAttrName = "钉钉姓名" + } + } + result.DingTalkConnectSyncDeptAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrName]) + if result.DingTalkConnectSyncDeptAttrName == "" { + if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrName); v != "" { + result.DingTalkConnectSyncDeptAttrName = v + } else { + result.DingTalkConnectSyncDeptAttrName = "钉钉部门" + } + } + + // Generic OIDC 设置: + // - 兼容 config.yaml/env + // - 支持后台系统设置覆盖并持久化(存储于 DB) + oidcBase := config.OIDCConnectConfig{} + if s.cfg != nil { + oidcBase = s.cfg.OIDC + } + + if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { + result.OIDCConnectEnabled = raw == "true" + } else { + result.OIDCConnectEnabled = oidcBase.Enabled + } + + if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectProviderName = strings.TrimSpace(v) + } else { + result.OIDCConnectProviderName = strings.TrimSpace(oidcBase.ProviderName) + } + if result.OIDCConnectProviderName == "" { + result.OIDCConnectProviderName = "OIDC" + } + + if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectClientID = strings.TrimSpace(v) + } else { + result.OIDCConnectClientID = strings.TrimSpace(oidcBase.ClientID) + } + if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectIssuerURL = strings.TrimSpace(v) + } else { + result.OIDCConnectIssuerURL = strings.TrimSpace(oidcBase.IssuerURL) + } + if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectDiscoveryURL = strings.TrimSpace(v) + } else { + result.OIDCConnectDiscoveryURL = strings.TrimSpace(oidcBase.DiscoveryURL) + } + if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectAuthorizeURL = strings.TrimSpace(v) + } else { + result.OIDCConnectAuthorizeURL = strings.TrimSpace(oidcBase.AuthorizeURL) + } + if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectTokenURL = strings.TrimSpace(v) + } else { + result.OIDCConnectTokenURL = strings.TrimSpace(oidcBase.TokenURL) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectUserInfoURL = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoURL = strings.TrimSpace(oidcBase.UserInfoURL) + } + if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectJWKSURL = strings.TrimSpace(v) + } else { + result.OIDCConnectJWKSURL = strings.TrimSpace(oidcBase.JWKSURL) + } + if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectScopes = strings.TrimSpace(v) + } else { + result.OIDCConnectScopes = strings.TrimSpace(oidcBase.Scopes) + } + if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectRedirectURL = strings.TrimSpace(v) + } else { + result.OIDCConnectRedirectURL = strings.TrimSpace(oidcBase.RedirectURL) + } + if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(v) + } else { + result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(oidcBase.FrontendRedirectURL) + } + if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(v)) + } else { + result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(oidcBase.TokenAuthMethod)) + } + if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok { + result.OIDCConnectUsePKCE = raw == "true" + } else { + result.OIDCConnectUsePKCE = oidcUsePKCECompatibilityDefault(oidcBase) + } + if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok { + result.OIDCConnectValidateIDToken = raw == "true" + } else { + result.OIDCConnectValidateIDToken = oidcValidateIDTokenCompatibilityDefault(oidcBase) + } + if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(v) + } else { + result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(oidcBase.AllowedSigningAlgs) + } + clockSkewSet := false + if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" { + if parsed, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { + result.OIDCConnectClockSkewSeconds = parsed + clockSkewSet = true + } + } + if !clockSkewSet { + result.OIDCConnectClockSkewSeconds = oidcBase.ClockSkewSeconds + } + if !clockSkewSet && result.OIDCConnectClockSkewSeconds == 0 { + result.OIDCConnectClockSkewSeconds = 120 + } + if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok { + result.OIDCConnectRequireEmailVerified = raw == "true" + } else { + result.OIDCConnectRequireEmailVerified = oidcBase.RequireEmailVerified + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok { + result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(oidcBase.UserInfoEmailPath) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok { + result.OIDCConnectUserInfoIDPath = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoIDPath = strings.TrimSpace(oidcBase.UserInfoIDPath) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok { + result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(oidcBase.UserInfoUsernamePath) + } + result.OIDCConnectClientSecret = strings.TrimSpace(settings[SettingKeyOIDCConnectClientSecret]) + if result.OIDCConnectClientSecret == "" { + result.OIDCConnectClientSecret = strings.TrimSpace(oidcBase.ClientSecret) + } + result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != "" + + gitHubEffective := s.effectiveEmailOAuthConfig(settings, "github") + result.GitHubOAuthEnabled = gitHubEffective.Enabled + result.GitHubOAuthClientID = strings.TrimSpace(gitHubEffective.ClientID) + result.GitHubOAuthClientSecret = strings.TrimSpace(gitHubEffective.ClientSecret) + result.GitHubOAuthClientSecretConfigured = result.GitHubOAuthClientSecret != "" + result.GitHubOAuthRedirectURL = strings.TrimSpace(gitHubEffective.RedirectURL) + result.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(gitHubEffective.FrontendRedirectURL) + + googleEffective := s.effectiveEmailOAuthConfig(settings, "google") + result.GoogleOAuthEnabled = googleEffective.Enabled + result.GoogleOAuthClientID = strings.TrimSpace(googleEffective.ClientID) + result.GoogleOAuthClientSecret = strings.TrimSpace(googleEffective.ClientSecret) + result.GoogleOAuthClientSecretConfigured = result.GoogleOAuthClientSecret != "" + result.GoogleOAuthRedirectURL = strings.TrimSpace(googleEffective.RedirectURL) + result.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(googleEffective.FrontendRedirectURL) + + // WeChat Connect 设置: + // - 优先读取 DB 系统设置 + // - 缺失时回退到 config/env,保持升级兼容 + weChatEffective := s.effectiveWeChatConnectOAuthConfig(settings) + result.WeChatConnectEnabled = weChatEffective.Enabled + result.WeChatConnectAppID = weChatEffective.LegacyAppID + result.WeChatConnectAppSecret = weChatEffective.LegacyAppSecret + result.WeChatConnectAppSecretConfigured = weChatEffective.LegacyAppSecret != "" + result.WeChatConnectOpenAppID = weChatEffective.OpenAppID + result.WeChatConnectOpenAppSecret = weChatEffective.OpenAppSecret + result.WeChatConnectOpenAppSecretConfigured = weChatEffective.OpenAppSecret != "" + result.WeChatConnectMPAppID = weChatEffective.MPAppID + result.WeChatConnectMPAppSecret = weChatEffective.MPAppSecret + result.WeChatConnectMPAppSecretConfigured = weChatEffective.MPAppSecret != "" + result.WeChatConnectMobileAppID = weChatEffective.MobileAppID + result.WeChatConnectMobileAppSecret = weChatEffective.MobileAppSecret + result.WeChatConnectMobileAppSecretConfigured = weChatEffective.MobileAppSecret != "" + result.WeChatConnectOpenEnabled = weChatEffective.OpenEnabled + result.WeChatConnectMPEnabled = weChatEffective.MPEnabled + result.WeChatConnectMobileEnabled = weChatEffective.MobileEnabled + result.WeChatConnectMode = weChatEffective.Mode + result.WeChatConnectScopes = weChatEffective.Scopes + result.WeChatConnectRedirectURL = weChatEffective.RedirectURL + result.WeChatConnectFrontendRedirectURL = weChatEffective.FrontendRedirectURL + + // Model fallback settings + result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true" + result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022") + result.FallbackModelOpenAI = s.getStringOrDefault(settings, SettingKeyFallbackModelOpenAI, "gpt-4o") + result.FallbackModelGemini = s.getStringOrDefault(settings, SettingKeyFallbackModelGemini, "gemini-2.5-pro") + result.FallbackModelAntigravity = s.getStringOrDefault(settings, SettingKeyFallbackModelAntigravity, "gemini-2.5-pro") + + // Identity patch settings (default: enabled, to preserve existing behavior) + if v, ok := settings[SettingKeyEnableIdentityPatch]; ok && v != "" { + result.EnableIdentityPatch = v == "true" + } else { + result.EnableIdentityPatch = true + } + result.IdentityPatchPrompt = settings[SettingKeyIdentityPatchPrompt] + + // Ops monitoring settings (default: enabled, fail-open) + result.OpsMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsMonitoringEnabled]) + result.OpsRealtimeMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsRealtimeMonitoringEnabled]) + result.OpsQueryModeDefault = string(ParseOpsQueryMode(settings[SettingKeyOpsQueryModeDefault])) + result.OpsMetricsIntervalSeconds = 60 + if raw := strings.TrimSpace(settings[SettingKeyOpsMetricsIntervalSeconds]); raw != "" { + if v, err := strconv.Atoi(raw); err == nil { + if v < 60 { + v = 60 + } + if v > 3600 { + v = 3600 + } + result.OpsMetricsIntervalSeconds = v + } + } + + // Channel monitor feature (default: enabled, 60s) + result.ChannelMonitorEnabled = !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]) + result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval( + settings[SettingKeyChannelMonitorDefaultIntervalSeconds], + ) + + // Available channels feature (default: disabled; strict true) + result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true" + + // Affiliate (邀请返利) feature (default: disabled; strict true) + result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true" + + // 风控中心功能(默认关闭,严格 true 才启用) + result.RiskControlEnabled = settings[SettingKeyRiskControlEnabled] == "true" + + // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) + result.CyberSessionBlockEnabled = settings[SettingKeyCyberSessionBlockEnabled] == "true" + if v, err := strconv.Atoi(strings.TrimSpace(settings[SettingKeyCyberSessionBlockTTLSeconds])); err == nil && v > 0 { + result.CyberSessionBlockTTLSeconds = v + } else { + result.CyberSessionBlockTTLSeconds = 3600 + } + + // Claude Code version check + result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion] + result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion] + + // 分组隔离 + result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true" + + // Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false, + // cch_signing=false, claude_oauth_system_prompt_injection=true) + if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" { + result.EnableFingerprintUnification = v == "true" + } else { + result.EnableFingerprintUnification = true // default: enabled (current behavior) + } + result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true" + result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true" + if v, ok := settings[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" { + result.EnableClaudeOAuthSystemPromptInjection = v == "true" + } else { + result.EnableClaudeOAuthSystemPromptInjection = true + } + result.ClaudeOAuthSystemPrompt = settings[SettingKeyClaudeOAuthSystemPrompt] + result.ClaudeOAuthSystemPromptBlocks = settings[SettingKeyClaudeOAuthSystemPromptBlocks] + result.EnableAnthropicCacheTTL1hInjection = settings[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" + if v, ok := settings[SettingKeyRewriteMessageCacheControl]; ok && v != "" { + result.RewriteMessageCacheControl = v == "true" + } else { + result.RewriteMessageCacheControl = s.defaultRewriteMessageCacheControl() + } + if v, ok := settings[SettingKeyEnableClientDatelineNormalization]; ok && v != "" { + result.EnableClientDatelineNormalization = v == "true" + } else { + result.EnableClientDatelineNormalization = true + } + result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion]) + result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent]) + // codex_cli_only 加固 + result.MinCodexVersion = settings[SettingKeyMinCodexVersion] + result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion] + result.CodexCLIOnlyBlacklist = settings[SettingKeyCodexCLIOnlyBlacklist] + result.CodexCLIOnlyWhitelist = settings[SettingKeyCodexCLIOnlyWhitelist] + result.CodexCLIOnlyAllowAppServerClients = settings[SettingKeyCodexCLIOnlyAllowAppServerClients] == "true" + if raw := strings.TrimSpace(settings[SettingKeyCodexCLIOnlyEngineFingerprintSignals]); raw != "" { + result.CodexCLIOnlyEngineFingerprintSignals = raw + } else { + result.CodexCLIOnlyEngineFingerprintSignals = openai.DefaultEngineFingerprintSignalsJSON() // 缺失/空 → 展示默认种子 + } + + // Web search emulation: quick enabled check from the JSON config + if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" { + var wsCfg WebSearchEmulationConfig + if err := json.Unmarshal([]byte(raw), &wsCfg); err == nil { + result.WebSearchEmulationEnabled = wsCfg.Enabled && len(wsCfg.Providers) > 0 + } + } + result.PaymentVisibleMethodAlipaySource = NormalizeVisibleMethodSource("alipay", settings[SettingPaymentVisibleMethodAlipaySource]) + result.PaymentVisibleMethodWxpaySource = NormalizeVisibleMethodSource("wxpay", settings[SettingPaymentVisibleMethodWxpaySource]) + result.PaymentVisibleMethodAlipayEnabled = settings[SettingPaymentVisibleMethodAlipayEnabled] == "true" + result.PaymentVisibleMethodWxpayEnabled = settings[SettingPaymentVisibleMethodWxpayEnabled] == "true" + result.OpenAIAdvancedSchedulerEnabled = settings[openAIAdvancedSchedulerSettingKey] == "true" + result.OpenAIAdvancedSchedulerStickyWeightedEnabled = settings[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] == "true" + result.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled = settings[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] == "true" + result.OpenAIAdvancedSchedulerLBTopK = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerLBTopK]) + result.OpenAIAdvancedSchedulerWeightPriority = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightPriority]) + result.OpenAIAdvancedSchedulerWeightLoad = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightLoad]) + result.OpenAIAdvancedSchedulerWeightQueue = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightQueue]) + result.OpenAIAdvancedSchedulerWeightErrorRate = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate]) + result.OpenAIAdvancedSchedulerWeightTTFT = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightTTFT]) + result.OpenAIAdvancedSchedulerWeightReset = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightReset]) + result.OpenAIAdvancedSchedulerWeightQuotaHeadroom = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom]) + result.OpenAIAdvancedSchedulerWeightPreviousResponse = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse]) + result.OpenAIAdvancedSchedulerWeightSessionSticky = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky]) + result.OpenAIAdvancedSchedulerEffectiveLBTopK = s.openAIAdvancedSchedulerEffectiveLBTopK() + effectiveWeights := s.openAIAdvancedSchedulerEffectiveWeights() + result.OpenAIAdvancedSchedulerEffectiveWeightPriority = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Priority) + result.OpenAIAdvancedSchedulerEffectiveWeightLoad = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Load) + result.OpenAIAdvancedSchedulerEffectiveWeightQueue = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Queue) + result.OpenAIAdvancedSchedulerEffectiveWeightErrorRate = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.ErrorRate) + result.OpenAIAdvancedSchedulerEffectiveWeightTTFT = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.TTFT) + result.OpenAIAdvancedSchedulerEffectiveWeightReset = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Reset) + result.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.QuotaHeadroom) + result.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.PreviousResponse) + result.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.SessionSticky) + + // 余额、订阅到期与账号限额通知 + result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true" + if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 { + result.BalanceLowNotifyThreshold = v + } + result.BalanceLowNotifyRechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL] + result.SubscriptionExpiryNotifyEnabled = !isFalseSettingValue(settings[SettingKeySubscriptionExpiryNotifyEnabled]) + + // 账号限额通知 + result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true" + if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" { + result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw) + } + if result.AccountQuotaNotifyEmails == nil { + result.AccountQuotaNotifyEmails = []NotifyEmailEntry{} + } + + // 系统层默认 platform quota(修复 Bug B:parseSettings 不填充导致回显恒为 nil) + if raw := settings[SettingKeyDefaultPlatformQuotas]; raw != "" { + parsed := map[string]*DefaultPlatformQuotaSetting{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + slog.Warn("[Setting] parseSettings: unmarshal default_platform_quotas failed", "error", err) + } else { + result.DefaultPlatformQuotas = parsed + } + } + + result.AllowUserViewErrorRequests = settings[SettingKeyAllowUserViewErrorRequests] == "true" // default false + + return result +} + +func clampAffiliateRebateRate(value float64) float64 { + if math.IsNaN(value) || math.IsInf(value, 0) { + return AffiliateRebateRateDefault + } + if value < AffiliateRebateRateMin { + return AffiliateRebateRateMin + } + if value > AffiliateRebateRateMax { + return AffiliateRebateRateMax + } + return value +} + +func isFalseSettingValue(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "false", "0", "off", "disabled": + return true + default: + return false + } +} + +func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (string, error) { + _ = enabled + source = strings.TrimSpace(source) + if source == "" { + return "", nil + } + + normalized := NormalizeVisibleMethodSource(method, source) + if normalized == "" { + return "", infraerrors.BadRequest( + "INVALID_PAYMENT_VISIBLE_METHOD_SOURCE", + fmt.Sprintf("%s source must be one of the supported payment providers", method), + ) + } + return normalized, nil +} + +func (s *SettingService) openAIAdvancedSchedulerEffectiveLBTopK() string { + if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.LBTopK > 0 { + return strconv.Itoa(s.cfg.Gateway.OpenAIWS.LBTopK) + } + return "7" +} + +func (s *SettingService) openAIAdvancedSchedulerEffectiveWeights() config.GatewayOpenAIWSSchedulerScoreWeights { + defaults := config.GatewayOpenAIWSSchedulerScoreWeights{ + Priority: 1.0, + Load: 1.0, + Queue: 0.7, + ErrorRate: 0.8, + TTFT: 0.5, + Reset: 0.0, + QuotaHeadroom: 0.0, + PreviousResponse: 5.0, + SessionSticky: 3.0, + } + if s == nil || s.cfg == nil { + return defaults + } + + weights := s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights + baseSum := weights.Priority + weights.Load + weights.Queue + weights.ErrorRate + weights.TTFT + weights.QuotaHeadroom + if baseSum <= 0 { + return defaults + } + return weights +} + +func formatOpenAIAdvancedSchedulerFloat(value float64) string { + return strconv.FormatFloat(value, 'f', -1, 64) +} + +func (s *SettingService) normalizeOpenAIAdvancedSchedulerOverrides(settings *SystemSettings) error { + lbTopK, err := normalizeOptionalPositiveIntString(settings.OpenAIAdvancedSchedulerLBTopK) + if err != nil { + return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_LB_TOP_K", "openai advanced scheduler TopK must be a positive integer or empty") + } + settings.OpenAIAdvancedSchedulerLBTopK = lbTopK + + weights := []*string{ + &settings.OpenAIAdvancedSchedulerWeightPriority, + &settings.OpenAIAdvancedSchedulerWeightLoad, + &settings.OpenAIAdvancedSchedulerWeightQueue, + &settings.OpenAIAdvancedSchedulerWeightErrorRate, + &settings.OpenAIAdvancedSchedulerWeightTTFT, + &settings.OpenAIAdvancedSchedulerWeightReset, + &settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, + &settings.OpenAIAdvancedSchedulerWeightPreviousResponse, + &settings.OpenAIAdvancedSchedulerWeightSessionSticky, + } + for _, target := range weights { + normalized, err := normalizeOptionalNonNegativeFloatString(*target) + if err != nil { + return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_WEIGHT", "openai advanced scheduler weights must be non-negative numbers or empty") + } + *target = normalized + } + + // 与 config.Validate 的 "scheduler_score_weights must not all be zero" 保持一致: + // 覆盖值(空则回退到生效的配置值)叠加后的基础权重和不允许为 0, + // 否则调度会静默退化为 TopK 内均匀随机。 + effective := s.openAIAdvancedSchedulerEffectiveWeights() + baseSum := resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightPriority, effective.Priority) + + resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightLoad, effective.Load) + + resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQueue, effective.Queue) + + resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightErrorRate, effective.ErrorRate) + + resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightTTFT, effective.TTFT) + + resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, effective.QuotaHeadroom) + if baseSum <= 0 { + return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_WEIGHT", "openai advanced scheduler base weights must not all be zero") + } + return nil +} + +// resolveOpenAIAdvancedSchedulerWeight 返回覆盖值(已归一化的非空字符串),空则回退默认值。 +func resolveOpenAIAdvancedSchedulerWeight(normalized string, fallback float64) float64 { + if normalized == "" { + return fallback + } + value, err := strconv.ParseFloat(normalized, 64) + if err != nil { + return fallback + } + return value +} + +func normalizeOptionalPositiveIntString(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return "", fmt.Errorf("invalid positive integer") + } + return strconv.Itoa(value), nil +} + +func normalizeOptionalNonNegativeFloatString(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + value, err := strconv.ParseFloat(raw, 64) + if err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return "", fmt.Errorf("invalid non-negative float") + } + return strconv.FormatFloat(value, 'f', -1, 64), nil +} + +func parseDefaultSubscriptions(raw string) []DefaultSubscriptionSetting { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + var items []DefaultSubscriptionSetting + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return nil + } + + normalized := make([]DefaultSubscriptionSetting, 0, len(items)) + for _, item := range items { + if item.GroupID <= 0 || item.ValidityDays <= 0 { + continue + } + if item.ValidityDays > MaxValidityDays { + item.ValidityDays = MaxValidityDays + } + normalized = append(normalized, item) + } + + return normalized +} + +func parseProviderDefaultGrantSettings(settings map[string]string, keys authSourceDefaultKeySet) ProviderDefaultGrantSettings { + result := ProviderDefaultGrantSettings{ + Balance: defaultAuthSourceBalance, + Concurrency: defaultAuthSourceConcurrency, + Subscriptions: []DefaultSubscriptionSetting{}, + GrantOnSignup: false, + GrantOnFirstBind: false, + } + + if v, err := strconv.ParseFloat(strings.TrimSpace(settings[keys.balance]), 64); err == nil { + result.Balance = v + } + if v, err := strconv.Atoi(strings.TrimSpace(settings[keys.concurrency])); err == nil { + result.Concurrency = v + } + if items := parseDefaultSubscriptions(settings[keys.subscriptions]); items != nil { + result.Subscriptions = items + } + if raw, ok := settings[keys.grantOnSignup]; ok { + result.GrantOnSignup = raw == "true" + } + if raw, ok := settings[keys.grantOnFirstBind]; ok { + result.GrantOnFirstBind = raw == "true" + } + + if raw := settings[keys.platformQuotas]; raw != "" { + parsed := map[string]*DefaultPlatformQuotaSetting{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + slog.Warn("[Setting] parseProviderDefaultGrantSettings: unmarshal auth source platform quotas failed", "source", keys.source, "error", err) + } else { + result.PlatformQuotas = parsed + } + } + + return result +} + +func writeProviderDefaultGrantUpdates(updates map[string]string, keys authSourceDefaultKeySet, settings ProviderDefaultGrantSettings) { + updates[keys.balance] = strconv.FormatFloat(settings.Balance, 'f', 8, 64) + updates[keys.concurrency] = strconv.Itoa(settings.Concurrency) + + subscriptions := settings.Subscriptions + if subscriptions == nil { + subscriptions = []DefaultSubscriptionSetting{} + } + raw, err := json.Marshal(subscriptions) + if err != nil { + raw = []byte("[]") + } + updates[keys.subscriptions] = string(raw) + updates[keys.grantOnSignup] = strconv.FormatBool(settings.GrantOnSignup) + updates[keys.grantOnFirstBind] = strconv.FormatBool(settings.GrantOnFirstBind) + + // auth source platform quota:整体替换语义。 + // nil = 请求未携带该字段,跳过写入以保留既有配置(与系统层 buildSystemSettingsUpdates 的 + // DefaultPlatformQuotas nil 守卫一致);非 nil(含空 map)才整体替换。二者语义不可混同。 + if keys.platformQuotas != "" && settings.PlatformQuotas != nil { + blob, err := json.Marshal(settings.PlatformQuotas) + if err != nil { + blob = []byte("{}") + } + updates[keys.platformQuotas] = string(blob) + } +} + +func mergeProviderDefaultGrantSettings(globalDefaults ProviderDefaultGrantSettings, providerDefaults ProviderDefaultGrantSettings) ProviderDefaultGrantSettings { + result := ProviderDefaultGrantSettings{ + Balance: globalDefaults.Balance, + Concurrency: globalDefaults.Concurrency, + Subscriptions: append([]DefaultSubscriptionSetting(nil), globalDefaults.Subscriptions...), + GrantOnSignup: providerDefaults.GrantOnSignup, + GrantOnFirstBind: providerDefaults.GrantOnFirstBind, + } + + // 注意:不能把 parse 默认值 (defaultAuthSourceBalance / defaultAuthSourceConcurrency) + // 当作"未配置"哨兵——admin 完全有权显式设成相同的值,那时仍应覆盖 globalDefaults。 + // 旧实现的 `!= defaultAuthSourceConcurrency` 会把 admin 设的 5 与 fallback 5 混淆, + // 导致渠道发放退回到全局默认(如 1),表现为"管理员设 5、新用户实际拿 1"。 + if providerDefaults.Balance >= 0 { + result.Balance = providerDefaults.Balance + } + if providerDefaults.Concurrency > 0 { + result.Concurrency = providerDefaults.Concurrency + } + if len(providerDefaults.Subscriptions) > 0 { + result.Subscriptions = append([]DefaultSubscriptionSetting(nil), providerDefaults.Subscriptions...) + } + + return result +} + +func parseTablePreferences(defaultPageSizeRaw, optionsRaw string) (int, []int) { + defaultPageSize := 20 + if v, err := strconv.Atoi(strings.TrimSpace(defaultPageSizeRaw)); err == nil { + defaultPageSize = v + } + + var options []int + if strings.TrimSpace(optionsRaw) != "" { + _ = json.Unmarshal([]byte(optionsRaw), &options) + } + + return normalizeTablePreferences(defaultPageSize, options) +} + +func normalizeTablePreferences(defaultPageSize int, options []int) (int, []int) { + const minPageSize = 5 + const maxPageSize = 1000 + const fallbackPageSize = 20 + + seen := make(map[int]struct{}, len(options)) + normalizedOptions := make([]int, 0, len(options)) + for _, option := range options { + if option < minPageSize || option > maxPageSize { + continue + } + if _, ok := seen[option]; ok { + continue + } + seen[option] = struct{}{} + normalizedOptions = append(normalizedOptions, option) + } + sort.Ints(normalizedOptions) + + if defaultPageSize < minPageSize || defaultPageSize > maxPageSize { + defaultPageSize = fallbackPageSize + } + + if len(normalizedOptions) == 0 { + normalizedOptions = []int{10, 20, 50} + } + + return defaultPageSize, normalizedOptions +} diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go new file mode 100644 index 0000000000..5bcc9e4c02 --- /dev/null +++ b/backend/internal/service/setting_public.go @@ -0,0 +1,688 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "net/url" + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" +) + +func normalizeLoginAgreementMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "checkbox": + return "checkbox" + default: + return defaultLoginAgreementMode + } +} + +func defaultLoginAgreementDocuments() []LoginAgreementDocument { + return []LoginAgreementDocument{ + { + ID: "terms", + Title: "服务条款", + ContentMD: "", + }, + { + ID: "usage-policy", + Title: "使用政策", + ContentMD: "", + }, + { + ID: "supported-regions", + Title: "支持的国家和地区", + ContentMD: "", + }, + { + ID: "service-specific-terms", + Title: "服务特定条款", + ContentMD: "", + }, + } +} + +func normalizeLoginAgreementDocumentID(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + var b strings.Builder + lastSeparator := false + for _, r := range raw { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + _, _ = b.WriteRune(r) + lastSeparator = false + continue + } + if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' { + if !lastSeparator && b.Len() > 0 { + if r == '_' { + _, _ = b.WriteRune('_') + } else { + _, _ = b.WriteRune('-') + } + lastSeparator = true + } + } + } + return strings.Trim(b.String(), "-_") +} + +func normalizeLoginAgreementDocuments(docs []LoginAgreementDocument) []LoginAgreementDocument { + normalized := make([]LoginAgreementDocument, 0, len(docs)) + seen := make(map[string]int, len(docs)) + for i, doc := range docs { + title := strings.TrimSpace(doc.Title) + content := strings.TrimSpace(doc.ContentMD) + if title == "" && content == "" { + continue + } + id := normalizeLoginAgreementDocumentID(doc.ID) + if id == "" { + sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s", i, title, content))) + id = hex.EncodeToString(sum[:])[:12] + } + baseID := id + for suffix := 2; seen[id] > 0; suffix++ { + id = fmt.Sprintf("%s-%d", baseID, suffix) + } + seen[id]++ + normalized = append(normalized, LoginAgreementDocument{ + ID: id, + Title: title, + ContentMD: content, + }) + } + return normalized +} + +func parseLoginAgreementDocuments(raw string) []LoginAgreementDocument { + raw = strings.TrimSpace(raw) + if raw == "" { + return defaultLoginAgreementDocuments() + } + var docs []LoginAgreementDocument + if err := json.Unmarshal([]byte(raw), &docs); err != nil { + return defaultLoginAgreementDocuments() + } + docs = normalizeLoginAgreementDocuments(docs) + if len(docs) == 0 { + return defaultLoginAgreementDocuments() + } + return docs +} + +func marshalLoginAgreementDocuments(docs []LoginAgreementDocument) (string, error) { + normalized := normalizeLoginAgreementDocuments(docs) + if len(normalized) == 0 { + normalized = defaultLoginAgreementDocuments() + } + b, err := json.Marshal(normalized) + if err != nil { + return "", fmt.Errorf("marshal login agreement documents: %w", err) + } + return string(b), nil +} + +func buildLoginAgreementRevision(updatedAt string, docs []LoginAgreementDocument) string { + normalized := normalizeLoginAgreementDocuments(docs) + payload, err := json.Marshal(struct { + UpdatedAt string `json:"updated_at"` + Documents []LoginAgreementDocument `json:"documents"` + }{ + UpdatedAt: strings.TrimSpace(updatedAt), + Documents: normalized, + }) + if err != nil { + payload = []byte(strings.TrimSpace(updatedAt)) + } + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:])[:16] +} + +// GetFrontendURL 获取前端基础URL(数据库优先,fallback 到配置文件) +func (s *SettingService) GetFrontendURL(ctx context.Context) string { + val, err := s.settingRepo.GetValue(ctx, SettingKeyFrontendURL) + if err == nil && strings.TrimSpace(val) != "" { + return strings.TrimSpace(val) + } + return s.cfg.Server.FrontendURL +} + +// GetPublicSettings 获取公开设置(无需登录) +func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) { + keys := []string{ + SettingKeyRegistrationEnabled, + SettingKeyEmailVerifyEnabled, + SettingKeyForceEmailOnThirdPartySignup, + SettingKeyRegistrationEmailSuffixWhitelist, + SettingKeyPromoCodeEnabled, + SettingKeyPasswordResetEnabled, + SettingKeyInvitationCodeEnabled, + SettingKeyTotpEnabled, + SettingKeyLoginAgreementEnabled, + SettingKeyLoginAgreementMode, + SettingKeyLoginAgreementUpdatedAt, + SettingKeyLoginAgreementDocuments, + SettingKeyTurnstileEnabled, + SettingKeyTurnstileSiteKey, + SettingKeyAPIKeyACLTrustForwardedIP, + SettingKeySiteName, + SettingKeySiteLogo, + SettingKeySiteSubtitle, + SettingKeyAPIBaseURL, + SettingKeyContactInfo, + SettingKeyDocURL, + SettingKeyHomeContent, + SettingKeyHideCcsImportButton, + SettingKeyPurchaseSubscriptionEnabled, + SettingKeyPurchaseSubscriptionURL, + SettingKeyTableDefaultPageSize, + SettingKeyTablePageSizeOptions, + SettingKeyCustomMenuItems, + SettingKeyCustomEndpoints, + SettingKeyLinuxDoConnectEnabled, + SettingKeyDingTalkConnectEnabled, + SettingKeyWeChatConnectEnabled, + SettingKeyWeChatConnectAppID, + SettingKeyWeChatConnectAppSecret, + SettingKeyWeChatConnectOpenAppID, + SettingKeyWeChatConnectOpenAppSecret, + SettingKeyWeChatConnectMPAppID, + SettingKeyWeChatConnectMPAppSecret, + SettingKeyWeChatConnectMobileAppID, + SettingKeyWeChatConnectMobileAppSecret, + SettingKeyWeChatConnectOpenEnabled, + SettingKeyWeChatConnectMPEnabled, + SettingKeyWeChatConnectMobileEnabled, + SettingKeyWeChatConnectMode, + SettingKeyWeChatConnectScopes, + SettingKeyWeChatConnectRedirectURL, + SettingKeyWeChatConnectFrontendRedirectURL, + SettingKeyBackendModeEnabled, + SettingPaymentEnabled, + SettingKeyOIDCConnectEnabled, + SettingKeyOIDCConnectProviderName, + SettingKeyGitHubOAuthEnabled, + SettingKeyGitHubOAuthClientID, + SettingKeyGitHubOAuthClientSecret, + SettingKeyGoogleOAuthEnabled, + SettingKeyGoogleOAuthClientID, + SettingKeyGoogleOAuthClientSecret, + SettingKeyBalanceLowNotifyEnabled, + SettingKeyBalanceLowNotifyThreshold, + SettingKeyBalanceLowNotifyRechargeURL, + SettingKeyAccountQuotaNotifyEnabled, + SettingKeyChannelMonitorEnabled, + SettingKeyChannelMonitorDefaultIntervalSeconds, + SettingKeyAvailableChannelsEnabled, + SettingKeyAffiliateEnabled, + SettingKeyRiskControlEnabled, + SettingKeyAllowUserViewErrorRequests, + } + + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return nil, fmt.Errorf("get public settings: %w", err) + } + + linuxDoEnabled := false + if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { + linuxDoEnabled = raw == "true" + } else { + linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled + } + dingTalkEnabled := false + if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { + dingTalkEnabled = raw == "true" + } else { + dingTalkEnabled = s.cfg != nil && s.cfg.DingTalk.Enabled + } + oidcEnabled := false + if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { + oidcEnabled = raw == "true" + } else { + oidcEnabled = s.cfg != nil && s.cfg.OIDC.Enabled + } + oidcProviderName := strings.TrimSpace(settings[SettingKeyOIDCConnectProviderName]) + if oidcProviderName == "" && s.cfg != nil { + oidcProviderName = strings.TrimSpace(s.cfg.OIDC.ProviderName) + } + if oidcProviderName == "" { + oidcProviderName = "OIDC" + } + gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github") + googleEnabled := s.emailOAuthPublicEnabled(settings, "google") + weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings) + + // Password reset requires email verification to be enabled + emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" + passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true" + registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist( + settings[SettingKeyRegistrationEmailSuffixWhitelist], + ) + tableDefaultPageSize, tablePageSizeOptions := parseTablePreferences( + settings[SettingKeyTableDefaultPageSize], + settings[SettingKeyTablePageSizeOptions], + ) + loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments]) + loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt]) + if loginAgreementUpdatedAt == "" { + loginAgreementUpdatedAt = defaultLoginAgreementDate + } + + var balanceLowNotifyThreshold float64 + if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 { + balanceLowNotifyThreshold = v + } + + return &PublicSettings{ + RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", + EmailVerifyEnabled: emailVerifyEnabled, + ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", + RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist, + PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 + PasswordResetEnabled: passwordResetEnabled, + InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", + TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, + LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments), + LoginAgreementDocuments: loginAgreementDocuments, + TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", + TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], + SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), + SiteLogo: settings[SettingKeySiteLogo], + SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), + APIBaseURL: settings[SettingKeyAPIBaseURL], + ContactInfo: settings[SettingKeyContactInfo], + DocURL: settings[SettingKeyDocURL], + HomeContent: settings[SettingKeyHomeContent], + HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", + PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", + PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), + TableDefaultPageSize: tableDefaultPageSize, + TablePageSizeOptions: tablePageSizeOptions, + CustomMenuItems: settings[SettingKeyCustomMenuItems], + CustomEndpoints: settings[SettingKeyCustomEndpoints], + LinuxDoOAuthEnabled: linuxDoEnabled, + DingTalkOAuthEnabled: dingTalkEnabled, + WeChatOAuthEnabled: weChatEnabled, + WeChatOAuthOpenEnabled: weChatOpenEnabled, + WeChatOAuthMPEnabled: weChatMPEnabled, + WeChatOAuthMobileEnabled: weChatMobileEnabled, + BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", + PaymentEnabled: settings[SettingPaymentEnabled] == "true", + OIDCOAuthEnabled: oidcEnabled, + OIDCOAuthProviderName: oidcProviderName, + GitHubOAuthEnabled: gitHubEnabled, + GoogleOAuthEnabled: googleEnabled, + BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true", + AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true", + BalanceLowNotifyThreshold: balanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL], + + ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]), + ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), + + AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", + + AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true", + + RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true", + + AllowUserViewErrorRequests: settings[SettingKeyAllowUserViewErrorRequests] == "true", + }, nil +} + +// channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval +// (mirrors the monitor-level constraint but lives here so setting_service stays decoupled). +const ( + channelMonitorIntervalMin = 15 + channelMonitorIntervalMax = 3600 + channelMonitorIntervalFallback = 60 +) + +// parseChannelMonitorInterval parses the stored string and clamps to [15, 3600]. +// Empty / invalid input falls back to channelMonitorIntervalFallback. +func parseChannelMonitorInterval(raw string) int { + v, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return channelMonitorIntervalFallback + } + return clampChannelMonitorInterval(v) +} + +// clampChannelMonitorInterval clamps v to the allowed range. 0 means "not provided". +func clampChannelMonitorInterval(v int) int { + if v <= 0 { + return 0 + } + if v < channelMonitorIntervalMin { + return channelMonitorIntervalMin + } + if v > channelMonitorIntervalMax { + return channelMonitorIntervalMax + } + return v +} + +// ChannelMonitorRuntime is the lightweight view of the channel monitor feature +// consumed by the runner and user-facing handlers. +type ChannelMonitorRuntime struct { + Enabled bool + DefaultIntervalSeconds int +} + +// GetChannelMonitorRuntime reads the channel monitor feature flags directly from +// the settings store. Fail-open: on error returns Enabled=true with the default interval. +func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime { + vals, err := s.settingRepo.GetMultiple(ctx, []string{ + SettingKeyChannelMonitorEnabled, + SettingKeyChannelMonitorDefaultIntervalSeconds, + }) + if err != nil { + return ChannelMonitorRuntime{Enabled: true, DefaultIntervalSeconds: channelMonitorIntervalFallback} + } + return ChannelMonitorRuntime{ + Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]), + DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]), + } +} + +// AvailableChannelsRuntime is the lightweight view of the available-channels feature +// switch consumed by the user-facing handler. +type AvailableChannelsRuntime struct { + Enabled bool +} + +// GetAvailableChannelsRuntime reads the available-channels feature switch directly +// from the settings store. Fail-closed: on error returns Enabled=false, matching +// the opt-in default (unknown ↔ disabled). +func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) AvailableChannelsRuntime { + vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAvailableChannelsEnabled}) + if err != nil { + return AvailableChannelsRuntime{Enabled: false} + } + return AvailableChannelsRuntime{ + Enabled: vals[SettingKeyAvailableChannelsEnabled] == "true", + } +} + +// IsUserErrorViewAllowed reads the user-facing error-requests visibility switch +// directly from the settings store. Fail-closed: on error returns false (opt-in default). +func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool { + vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAllowUserViewErrorRequests}) + if err != nil { + slog.Warn("failed to get allow_user_view_error_requests setting, defaulting to false", "error", err) + return false + } + return vals[SettingKeyAllowUserViewErrorRequests] == "true" +} + +// PublicSettingsInjectionPayload is the JSON shape embedded into HTML as +// `window.__APP_CONFIG__` so the frontend can hydrate feature flags & site +// config before the first XHR finishes. +// +// INVARIANT: every `json` tag here MUST also exist on handler/dto.PublicSettings. +// If you forget a feature-flag field here, the frontend's +// `cachedPublicSettings.xxx_enabled` will be `undefined` on refresh until the +// async `/api/v1/settings/public` call returns — which causes opt-in menus +// (strict `=== true`) to flicker off/on. See +// frontend/src/utils/featureFlags.ts for the matching registry. +// +// A unit test diffs this struct's JSON keys against dto.PublicSettings to catch +// drift automatically (see setting_service_injection_test.go). +type PublicSettingsInjectionPayload struct { + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementRevision string `json:"login_agreement_revision"` + LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems json.RawMessage `json:"custom_menu_items"` + CustomEndpoints json.RawMessage `json:"custom_endpoints"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` + WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` + WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` + WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` + WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` + GitHubOAuthEnabled bool `json:"github_oauth_enabled"` + GoogleOAuthEnabled bool `json:"google_oauth_enabled"` + BackendModeEnabled bool `json:"backend_mode_enabled"` + PaymentEnabled bool `json:"payment_enabled"` + Version string `json:"version"` + // 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用 + ServerTimezone string `json:"server_timezone"` + ServerUTCOffset string `json:"server_utc_offset"` + BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` + AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` + BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` + + // Feature flags — MUST match the opt-in/opt-out registry in + // frontend/src/utils/featureFlags.ts. Missing a field here is the bug + // that hid the "可用渠道" menu on page refresh. + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + AvailableChannelsEnabled bool `json:"available_channels_enabled"` + AffiliateEnabled bool `json:"affiliate_enabled"` + RiskControlEnabled bool `json:"risk_control_enabled"` + AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` +} + +// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. +// This implements the web.PublicSettingsProvider interface. +func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) { + settings, err := s.GetPublicSettings(ctx) + if err != nil { + return nil, err + } + + return &PublicSettingsInjectionPayload{ + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, + PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, + InvitationCodeEnabled: settings.InvitationCodeEnabled, + TotpEnabled: settings.TotpEnabled, + LoginAgreementEnabled: settings.LoginAgreementEnabled, + LoginAgreementMode: settings.LoginAgreementMode, + LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, + LoginAgreementRevision: settings.LoginAgreementRevision, + LoginAgreementDocuments: settings.LoginAgreementDocuments, + TurnstileEnabled: settings.TurnstileEnabled, + TurnstileSiteKey: settings.TurnstileSiteKey, + SiteName: settings.SiteName, + SiteLogo: settings.SiteLogo, + SiteSubtitle: settings.SiteSubtitle, + APIBaseURL: settings.APIBaseURL, + ContactInfo: settings.ContactInfo, + DocURL: settings.DocURL, + HomeContent: settings.HomeContent, + HideCcsImportButton: settings.HideCcsImportButton, + PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, + PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, + TableDefaultPageSize: settings.TableDefaultPageSize, + TablePageSizeOptions: settings.TablePageSizeOptions, + CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), + CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), + LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, + WeChatOAuthEnabled: settings.WeChatOAuthEnabled, + WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, + WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, + WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, + OIDCOAuthEnabled: settings.OIDCOAuthEnabled, + OIDCOAuthProviderName: settings.OIDCOAuthProviderName, + GitHubOAuthEnabled: settings.GitHubOAuthEnabled, + GoogleOAuthEnabled: settings.GoogleOAuthEnabled, + BackendModeEnabled: settings.BackendModeEnabled, + PaymentEnabled: settings.PaymentEnabled, + Version: s.version, + ServerTimezone: timezone.Name(), + ServerUTCOffset: timezone.UTCOffset(), + BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, + AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, + BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, + + ChannelMonitorEnabled: settings.ChannelMonitorEnabled, + ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, + AvailableChannelsEnabled: settings.AvailableChannelsEnabled, + AffiliateEnabled: settings.AffiliateEnabled, + RiskControlEnabled: settings.RiskControlEnabled, + AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests, + }, nil +} + +// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON +// array string, returning only items with visibility != "admin". +func filterUserVisibleMenuItems(raw string) json.RawMessage { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" { + return json.RawMessage("[]") + } + var items []struct { + Visibility string `json:"visibility"` + } + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return json.RawMessage("[]") + } + + // Parse full items to preserve all fields + var fullItems []json.RawMessage + if err := json.Unmarshal([]byte(raw), &fullItems); err != nil { + return json.RawMessage("[]") + } + + var filtered []json.RawMessage + for i, item := range items { + if item.Visibility != "admin" { + filtered = append(filtered, fullItems[i]) + } + } + if len(filtered) == 0 { + return json.RawMessage("[]") + } + result, err := json.Marshal(filtered) + if err != nil { + return json.RawMessage("[]") + } + return result +} + +// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]". +func safeRawJSONArray(raw string) json.RawMessage { + raw = strings.TrimSpace(raw) + if raw == "" { + return json.RawMessage("[]") + } + if json.Valid([]byte(raw)) { + return json.RawMessage(raw) + } + return json.RawMessage("[]") +} + +// GetFrameSrcOrigins returns deduplicated http(s) origins from home_content URL, +// purchase_subscription_url, and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection. +func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) { + settings, err := s.GetPublicSettings(ctx) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}) + var origins []string + + addOrigin := func(rawURL string) { + if origin := extractOriginFromURL(rawURL); origin != "" { + if _, ok := seen[origin]; !ok { + seen[origin] = struct{}{} + origins = append(origins, origin) + } + } + } + + // home content URL (when home_content is set to a URL for iframe embedding) + addOrigin(settings.HomeContent) + + // purchase subscription URL + if settings.PurchaseSubscriptionEnabled { + addOrigin(settings.PurchaseSubscriptionURL) + } + + // all custom menu items (including admin-only, since CSP must allow all iframes) + for _, item := range parseCustomMenuItemURLs(settings.CustomMenuItems) { + addOrigin(item) + } + + return origins, nil +} + +// extractOriginFromURL returns the scheme+host origin from rawURL. +// Only http and https schemes are accepted. +func extractOriginFromURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return "" + } + if u.Scheme != "http" && u.Scheme != "https" { + return "" + } + return u.Scheme + "://" + u.Host +} + +// parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items. +func parseCustomMenuItemURLs(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" { + return nil + } + var items []struct { + URL string `json:"url"` + } + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return nil + } + urls := make([]string, 0, len(items)) + for _, item := range items { + if item.URL != "" { + urls = append(urls, item.URL) + } + } + return urls +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 2a6d821d3a..52acc62cf9 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -2,49 +2,15 @@ package service import ( "context" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" "errors" "fmt" - "log/slog" - "math" - "net/url" - "sort" - "strconv" - "strings" "sync/atomic" - "time" "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" - "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" - "github.com/imroc/req/v3" "golang.org/x/sync/singleflight" ) -// CoerceDingTalkCorpPolicyForWrite 是 coerceDeprecatedDingTalkCorpPolicy 的导出版本, -// 用于 admin handler 在写入路径上对客户端直传的入参做防御性 coerce(前端 UI 虽已无 whitelist 选项, -// 但 API 可被直接调用)。 -func CoerceDingTalkCorpPolicyForWrite(policy string) string { - return coerceDeprecatedDingTalkCorpPolicy(policy) -} - -// coerceDeprecatedDingTalkCorpPolicy 把已废弃的 corp_restriction_policy 值替换成安全的等价值。 -// 升级前残留在 DB 中的 "whitelist" 会导致 callback 链路在 default case 静默 fail-closed -// (所有钉钉登录被拒)。这里统一退化为 "none" 让服务保持可用,并 warn 日志提醒 admin 重新保存设置。 -func coerceDeprecatedDingTalkCorpPolicy(policy string) string { - if policy == "whitelist" { - slog.Warn("dingtalk: corp_restriction_policy=whitelist is deprecated and unsupported, coercing to none", - "hint", "re-save DingTalk settings in admin UI to clear this warning") - return "none" - } - return policy -} - var ( ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found") @@ -68,118 +34,6 @@ type SettingRepository interface { Delete(ctx context.Context, key string) error } -// cachedVersionBounds 缓存 Claude Code 版本号上下限(进程内缓存,60s TTL) -type cachedVersionBounds struct { - min string // 空字符串 = 不检查 - max string // 空字符串 = 不检查 - expiresAt int64 // unix nano -} - -// versionBoundsCache 版本号上下限进程内缓存 -var versionBoundsCache atomic.Value // *cachedVersionBounds - -// versionBoundsSF 防止缓存过期时 thundering herd -var versionBoundsSF singleflight.Group - -// versionBoundsCacheTTL 缓存有效期 -const versionBoundsCacheTTL = 60 * time.Second - -// versionBoundsErrorTTL DB 错误时的短缓存,快速重试 -const versionBoundsErrorTTL = 5 * time.Second - -// versionBoundsDBTimeout singleflight 内 DB 查询超时,独立于请求 context -const versionBoundsDBTimeout = 5 * time.Second - -// cachedBackendMode Backend Mode cache (in-process, 60s TTL) -type cachedBackendMode struct { - value bool - expiresAt int64 // unix nano -} - -var backendModeCache atomic.Value // *cachedBackendMode -var backendModeSF singleflight.Group - -const backendModeCacheTTL = 60 * time.Second -const backendModeErrorTTL = 5 * time.Second -const backendModeDBTimeout = 5 * time.Second - -// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL) -type cachedGatewayForwardingSettings struct { - fingerprintUnification bool - metadataPassthrough bool - cchSigning bool - claudeOAuthSystemPromptInjection bool - claudeOAuthSystemPrompt string - claudeOAuthSystemPromptBlocks string - anthropicCacheTTL1hInjection bool - rewriteMessageCacheControl bool - clientDatelineNormalization bool - expiresAt int64 // unix nano -} - -var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings -var gatewayForwardingSF singleflight.Group - -const gatewayForwardingCacheTTL = 60 * time.Second -const gatewayForwardingErrorTTL = 5 * time.Second -const gatewayForwardingDBTimeout = 5 * time.Second - -// cachedAntigravityUserAgentVersion 缓存 Antigravity UA 版本号(进程内缓存,60s TTL) -type cachedAntigravityUserAgentVersion struct { - version string - expiresAt int64 // unix nano -} - -const antigravityUserAgentVersionCacheTTL = 60 * time.Second -const antigravityUserAgentVersionErrorTTL = 5 * time.Second -const antigravityUserAgentVersionDBTimeout = 5 * time.Second - -// DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询) -const DefaultOpenAICodexUserAgent = "codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)" - -// cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL) -type cachedOpenAICodexUserAgent struct { - value string - expiresAt int64 // unix nano -} - -type cachedOpenAIQuotaAutoPauseSettings struct { - settings OpsOpenAIAccountQuotaAutoPauseSettings - expiresAt int64 -} - -const openAICodexUserAgentCacheTTL = 60 * time.Second -const openAICodexUserAgentErrorTTL = 5 * time.Second -const openAICodexUserAgentDBTimeout = 5 * time.Second - -const codexRestrictionPolicyCacheTTL = 60 * time.Second -const codexRestrictionPolicyDBTimeout = 5 * time.Second - -// cachedCodexRestrictionPolicy codex_cli_only 全局加固策略缓存(进程内,60s TTL)。 -// GetCodexRestrictionPolicy 在每个 codex_cli_only 账号的网关请求热路径上被调用,避免每次访问 DB。 -type cachedCodexRestrictionPolicy struct { - value CodexRestrictionPolicy - expiresAt int64 // unix nano -} - -// cachedCyberSessionBlockRuntime cyber 会话屏蔽开关+TTL 进程内缓存(60s TTL)。 -// GetCyberSessionBlockRuntime 在网关请求热路径上被调用,避免每次访问 DB。 -type cachedCyberSessionBlockRuntime struct { - enabled bool - ttl time.Duration - expiresAt int64 // unix nano -} - -const cyberSessionBlockRuntimeCacheTTL = 60 * time.Second -const cyberSessionBlockRuntimeErrorTTL = 5 * time.Second -const cyberSessionBlockRuntimeDBTimeout = 5 * time.Second - -const openAIQuotaAutoPauseSettingsCacheTTL = 60 * time.Second -const openAIQuotaAutoPauseSettingsErrorTTL = 5 * time.Second -const openAIQuotaAutoPauseSettingsDBTimeout = 5 * time.Second - -const openAIQuotaAutoPauseSettingsRefreshKey = "openai_quota_auto_pause_settings" - // DefaultSubscriptionGroupReader validates group references used by default subscriptions. type DefaultSubscriptionGroupReader interface { GetByID(ctx context.Context, id int64) (*Group, error) @@ -344,320 +198,6 @@ const ( defaultLoginAgreementDate = "2026-03-31" ) -func normalizeLoginAgreementMode(raw string) string { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "checkbox": - return "checkbox" - default: - return defaultLoginAgreementMode - } -} - -func defaultLoginAgreementDocuments() []LoginAgreementDocument { - return []LoginAgreementDocument{ - { - ID: "terms", - Title: "服务条款", - ContentMD: "", - }, - { - ID: "usage-policy", - Title: "使用政策", - ContentMD: "", - }, - { - ID: "supported-regions", - Title: "支持的国家和地区", - ContentMD: "", - }, - { - ID: "service-specific-terms", - Title: "服务特定条款", - ContentMD: "", - }, - } -} - -func normalizeLoginAgreementDocumentID(raw string) string { - raw = strings.ToLower(strings.TrimSpace(raw)) - var b strings.Builder - lastSeparator := false - for _, r := range raw { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - _, _ = b.WriteRune(r) - lastSeparator = false - continue - } - if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' { - if !lastSeparator && b.Len() > 0 { - if r == '_' { - _, _ = b.WriteRune('_') - } else { - _, _ = b.WriteRune('-') - } - lastSeparator = true - } - } - } - return strings.Trim(b.String(), "-_") -} - -func normalizeLoginAgreementDocuments(docs []LoginAgreementDocument) []LoginAgreementDocument { - normalized := make([]LoginAgreementDocument, 0, len(docs)) - seen := make(map[string]int, len(docs)) - for i, doc := range docs { - title := strings.TrimSpace(doc.Title) - content := strings.TrimSpace(doc.ContentMD) - if title == "" && content == "" { - continue - } - id := normalizeLoginAgreementDocumentID(doc.ID) - if id == "" { - sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s", i, title, content))) - id = hex.EncodeToString(sum[:])[:12] - } - baseID := id - for suffix := 2; seen[id] > 0; suffix++ { - id = fmt.Sprintf("%s-%d", baseID, suffix) - } - seen[id]++ - normalized = append(normalized, LoginAgreementDocument{ - ID: id, - Title: title, - ContentMD: content, - }) - } - return normalized -} - -func parseLoginAgreementDocuments(raw string) []LoginAgreementDocument { - raw = strings.TrimSpace(raw) - if raw == "" { - return defaultLoginAgreementDocuments() - } - var docs []LoginAgreementDocument - if err := json.Unmarshal([]byte(raw), &docs); err != nil { - return defaultLoginAgreementDocuments() - } - docs = normalizeLoginAgreementDocuments(docs) - if len(docs) == 0 { - return defaultLoginAgreementDocuments() - } - return docs -} - -func marshalLoginAgreementDocuments(docs []LoginAgreementDocument) (string, error) { - normalized := normalizeLoginAgreementDocuments(docs) - if len(normalized) == 0 { - normalized = defaultLoginAgreementDocuments() - } - b, err := json.Marshal(normalized) - if err != nil { - return "", fmt.Errorf("marshal login agreement documents: %w", err) - } - return string(b), nil -} - -func buildLoginAgreementRevision(updatedAt string, docs []LoginAgreementDocument) string { - normalized := normalizeLoginAgreementDocuments(docs) - payload, err := json.Marshal(struct { - UpdatedAt string `json:"updated_at"` - Documents []LoginAgreementDocument `json:"documents"` - }{ - UpdatedAt: strings.TrimSpace(updatedAt), - Documents: normalized, - }) - if err != nil { - payload = []byte(strings.TrimSpace(updatedAt)) - } - sum := sha256.Sum256(payload) - return hex.EncodeToString(sum[:])[:16] -} - -func normalizeWeChatConnectModeSetting(raw string) string { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "mp": - return "mp" - case "mobile": - return "mobile" - default: - return "open" - } -} - -func defaultWeChatConnectScopeForMode(mode string) string { - switch normalizeWeChatConnectModeSetting(mode) { - case "mp": - return "snsapi_userinfo" - case "mobile": - return "" - } - return defaultWeChatConnectScopes -} - -func normalizeWeChatConnectScopeSetting(raw, mode string) string { - switch normalizeWeChatConnectModeSetting(mode) { - case "mp": - switch strings.TrimSpace(raw) { - case "snsapi_base": - return "snsapi_base" - case "snsapi_userinfo": - return "snsapi_userinfo" - default: - return defaultWeChatConnectScopeForMode(mode) - } - case "mobile": - return "" - default: - return defaultWeChatConnectScopes - } -} - -func parseWeChatConnectCapabilitySettings(settings map[string]string, enabled bool, mode string) (bool, bool, bool) { - mode = normalizeWeChatConnectModeSetting(mode) - rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled] - rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled] - rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled] - openConfigured := hasOpen && strings.TrimSpace(rawOpen) != "" - mpConfigured := hasMP && strings.TrimSpace(rawMP) != "" - mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != "" - - if openConfigured || mpConfigured || mobileConfigured { - openEnabled := strings.TrimSpace(rawOpen) == "true" - mpEnabled := strings.TrimSpace(rawMP) == "true" - mobileEnabled := strings.TrimSpace(rawMobile) == "true" - return openEnabled, mpEnabled, mobileEnabled - } - - if !enabled { - return false, false, false - } - if mode == "mp" { - return false, true, false - } - if mode == "mobile" { - return false, false, true - } - return true, false, false -} - -func normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled bool, mode string) string { - mode = normalizeWeChatConnectModeSetting(mode) - switch mode { - case "open": - if openEnabled { - return "open" - } - case "mp": - if mpEnabled { - return "mp" - } - case "mobile": - if mobileEnabled { - return "mobile" - } - } - switch { - case openEnabled: - return "open" - case mpEnabled: - return "mp" - case mobileEnabled: - return "mobile" - default: - return mode - } -} - -func mergeWeChatConnectCapabilitySettings(settings map[string]string, base config.WeChatConnectConfig, enabled bool, mode string) (bool, bool, bool) { - mode = normalizeWeChatConnectModeSetting(firstNonEmpty(mode, base.Mode)) - rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled] - rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled] - rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled] - openConfigured := hasOpen && strings.TrimSpace(rawOpen) != "" - mpConfigured := hasMP && strings.TrimSpace(rawMP) != "" - mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != "" - - if openConfigured || mpConfigured || mobileConfigured { - openEnabled := strings.TrimSpace(rawOpen) == "true" - mpEnabled := strings.TrimSpace(rawMP) == "true" - mobileEnabled := strings.TrimSpace(rawMobile) == "true" - _, enabledConfigured := settings[SettingKeyWeChatConnectEnabled] - if !enabledConfigured && - enabled && - !openEnabled && - !mpEnabled && - !mobileEnabled && - (base.OpenEnabled || base.MPEnabled || base.MobileEnabled) { - return base.OpenEnabled, base.MPEnabled, base.MobileEnabled - } - return openEnabled, mpEnabled, mobileEnabled - } - if !enabled { - return false, false, false - } - if base.OpenEnabled || base.MPEnabled || base.MobileEnabled { - return base.OpenEnabled, base.MPEnabled, base.MobileEnabled - } - return parseWeChatConnectCapabilitySettings(settings, enabled, mode) -} - -func (s *SettingService) effectiveWeChatConnectOAuthConfig(settings map[string]string) WeChatConnectOAuthConfig { - base := config.WeChatConnectConfig{} - if s != nil && s.cfg != nil { - base = s.cfg.WeChat - } - - enabled := base.Enabled - if raw, ok := settings[SettingKeyWeChatConnectEnabled]; ok { - enabled = strings.TrimSpace(raw) == "true" - } - - legacyAppID := strings.TrimSpace(firstNonEmpty( - settings[SettingKeyWeChatConnectAppID], - base.AppID, - base.OpenAppID, - base.MPAppID, - base.MobileAppID, - )) - legacyAppSecret := strings.TrimSpace(firstNonEmpty( - settings[SettingKeyWeChatConnectAppSecret], - base.AppSecret, - base.OpenAppSecret, - base.MPAppSecret, - base.MobileAppSecret, - )) - openAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppID], base.OpenAppID, legacyAppID)) - openAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppSecret], base.OpenAppSecret, legacyAppSecret)) - mpAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppID], base.MPAppID, legacyAppID)) - mpAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppSecret], base.MPAppSecret, legacyAppSecret)) - mobileAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppID], base.MobileAppID, legacyAppID)) - mobileAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppSecret], base.MobileAppSecret, legacyAppSecret)) - - modeRaw := firstNonEmpty(settings[SettingKeyWeChatConnectMode], base.Mode) - openEnabled, mpEnabled, mobileEnabled := mergeWeChatConnectCapabilitySettings(settings, base, enabled, modeRaw) - mode := normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled, modeRaw) - - return WeChatConnectOAuthConfig{ - Enabled: enabled, - LegacyAppID: legacyAppID, - LegacyAppSecret: legacyAppSecret, - OpenAppID: openAppID, - OpenAppSecret: openAppSecret, - MPAppID: mpAppID, - MPAppSecret: mpAppSecret, - MobileAppID: mobileAppID, - MobileAppSecret: mobileAppSecret, - OpenEnabled: openEnabled, - MPEnabled: mpEnabled, - MobileEnabled: mobileEnabled, - Mode: mode, - Scopes: normalizeWeChatConnectScopeSetting(firstNonEmpty(settings[SettingKeyWeChatConnectScopes], base.Scopes), mode), - RedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectRedirectURL], base.RedirectURL)), - FrontendRedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectFrontendRedirectURL], base.FrontendRedirectURL, defaultWeChatConnectFrontend)), - } -} - // NewSettingService 创建系统设置服务实例 func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *SettingService { return &SettingService{ @@ -703,699 +243,6 @@ func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, e return s.parseSettings(settings), nil } -// GetFrontendURL 获取前端基础URL(数据库优先,fallback 到配置文件) -func (s *SettingService) GetFrontendURL(ctx context.Context) string { - val, err := s.settingRepo.GetValue(ctx, SettingKeyFrontendURL) - if err == nil && strings.TrimSpace(val) != "" { - return strings.TrimSpace(val) - } - return s.cfg.Server.FrontendURL -} - -// GetCyberSessionBlockRuntime 返回 (开关, TTL),进程内缓存 ~60s, -// 供网关热路径读取时避免 DB 往返。 -// 两个 setting key 在单次 singleflight 里一起读取,减少 DB 往返。 -// 默认值:开关 false,TTL 1h(与粘性会话对齐)。 -func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, time.Duration) { - if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.enabled, cached.ttl - } - } - result, _, _ := s.cyberSessionBlockRuntimeSF.Do("cyber_session_block_runtime", func() (any, error) { - if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached, nil - } - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cyberSessionBlockRuntimeDBTimeout) - defer cancel() - - enabledVal, enabledErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockEnabled) - ttlVal, ttlErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockTTLSeconds) - - if enabledErr != nil && !errors.Is(enabledErr, ErrSettingNotFound) { - slog.Warn("failed to get cyber_session_block_enabled setting", "error", enabledErr) - entry := &cachedCyberSessionBlockRuntime{ - enabled: false, - ttl: time.Hour, - expiresAt: time.Now().Add(cyberSessionBlockRuntimeErrorTTL).UnixNano(), - } - s.cyberSessionBlockRuntimeCache.Store(entry) - return entry, nil - } - - enabled := enabledErr == nil && strings.TrimSpace(enabledVal) == "true" - - ttl := time.Hour - if ttlErr == nil { - if n, perr := strconv.Atoi(strings.TrimSpace(ttlVal)); perr == nil && n > 0 { - ttl = time.Duration(n) * time.Second - } - } - - entry := &cachedCyberSessionBlockRuntime{ - enabled: enabled, - ttl: ttl, - expiresAt: time.Now().Add(cyberSessionBlockRuntimeCacheTTL).UnixNano(), - } - s.cyberSessionBlockRuntimeCache.Store(entry) - return entry, nil - }) - if entry, ok := result.(*cachedCyberSessionBlockRuntime); ok && entry != nil { - return entry.enabled, entry.ttl - } - return false, time.Hour -} - -// GetPublicSettings 获取公开设置(无需登录) -func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) { - keys := []string{ - SettingKeyRegistrationEnabled, - SettingKeyEmailVerifyEnabled, - SettingKeyForceEmailOnThirdPartySignup, - SettingKeyRegistrationEmailSuffixWhitelist, - SettingKeyPromoCodeEnabled, - SettingKeyPasswordResetEnabled, - SettingKeyInvitationCodeEnabled, - SettingKeyTotpEnabled, - SettingKeyLoginAgreementEnabled, - SettingKeyLoginAgreementMode, - SettingKeyLoginAgreementUpdatedAt, - SettingKeyLoginAgreementDocuments, - SettingKeyTurnstileEnabled, - SettingKeyTurnstileSiteKey, - SettingKeyAPIKeyACLTrustForwardedIP, - SettingKeySiteName, - SettingKeySiteLogo, - SettingKeySiteSubtitle, - SettingKeyAPIBaseURL, - SettingKeyContactInfo, - SettingKeyDocURL, - SettingKeyHomeContent, - SettingKeyHideCcsImportButton, - SettingKeyPurchaseSubscriptionEnabled, - SettingKeyPurchaseSubscriptionURL, - SettingKeyTableDefaultPageSize, - SettingKeyTablePageSizeOptions, - SettingKeyCustomMenuItems, - SettingKeyCustomEndpoints, - SettingKeyLinuxDoConnectEnabled, - SettingKeyDingTalkConnectEnabled, - SettingKeyWeChatConnectEnabled, - SettingKeyWeChatConnectAppID, - SettingKeyWeChatConnectAppSecret, - SettingKeyWeChatConnectOpenAppID, - SettingKeyWeChatConnectOpenAppSecret, - SettingKeyWeChatConnectMPAppID, - SettingKeyWeChatConnectMPAppSecret, - SettingKeyWeChatConnectMobileAppID, - SettingKeyWeChatConnectMobileAppSecret, - SettingKeyWeChatConnectOpenEnabled, - SettingKeyWeChatConnectMPEnabled, - SettingKeyWeChatConnectMobileEnabled, - SettingKeyWeChatConnectMode, - SettingKeyWeChatConnectScopes, - SettingKeyWeChatConnectRedirectURL, - SettingKeyWeChatConnectFrontendRedirectURL, - SettingKeyBackendModeEnabled, - SettingPaymentEnabled, - SettingKeyOIDCConnectEnabled, - SettingKeyOIDCConnectProviderName, - SettingKeyGitHubOAuthEnabled, - SettingKeyGitHubOAuthClientID, - SettingKeyGitHubOAuthClientSecret, - SettingKeyGoogleOAuthEnabled, - SettingKeyGoogleOAuthClientID, - SettingKeyGoogleOAuthClientSecret, - SettingKeyBalanceLowNotifyEnabled, - SettingKeyBalanceLowNotifyThreshold, - SettingKeyBalanceLowNotifyRechargeURL, - SettingKeyAccountQuotaNotifyEnabled, - SettingKeyChannelMonitorEnabled, - SettingKeyChannelMonitorDefaultIntervalSeconds, - SettingKeyAvailableChannelsEnabled, - SettingKeyAffiliateEnabled, - SettingKeyRiskControlEnabled, - SettingKeyAllowUserViewErrorRequests, - } - - settings, err := s.settingRepo.GetMultiple(ctx, keys) - if err != nil { - return nil, fmt.Errorf("get public settings: %w", err) - } - - linuxDoEnabled := false - if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { - linuxDoEnabled = raw == "true" - } else { - linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled - } - dingTalkEnabled := false - if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { - dingTalkEnabled = raw == "true" - } else { - dingTalkEnabled = s.cfg != nil && s.cfg.DingTalk.Enabled - } - oidcEnabled := false - if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { - oidcEnabled = raw == "true" - } else { - oidcEnabled = s.cfg != nil && s.cfg.OIDC.Enabled - } - oidcProviderName := strings.TrimSpace(settings[SettingKeyOIDCConnectProviderName]) - if oidcProviderName == "" && s.cfg != nil { - oidcProviderName = strings.TrimSpace(s.cfg.OIDC.ProviderName) - } - if oidcProviderName == "" { - oidcProviderName = "OIDC" - } - gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github") - googleEnabled := s.emailOAuthPublicEnabled(settings, "google") - weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings) - - // Password reset requires email verification to be enabled - emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" - passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true" - registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist( - settings[SettingKeyRegistrationEmailSuffixWhitelist], - ) - tableDefaultPageSize, tablePageSizeOptions := parseTablePreferences( - settings[SettingKeyTableDefaultPageSize], - settings[SettingKeyTablePageSizeOptions], - ) - loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments]) - loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt]) - if loginAgreementUpdatedAt == "" { - loginAgreementUpdatedAt = defaultLoginAgreementDate - } - - var balanceLowNotifyThreshold float64 - if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 { - balanceLowNotifyThreshold = v - } - - return &PublicSettings{ - RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", - EmailVerifyEnabled: emailVerifyEnabled, - ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", - RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist, - PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 - PasswordResetEnabled: passwordResetEnabled, - InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", - TotpEnabled: settings[SettingKeyTotpEnabled] == "true", - LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, - LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), - LoginAgreementUpdatedAt: loginAgreementUpdatedAt, - LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments), - LoginAgreementDocuments: loginAgreementDocuments, - TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", - TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], - SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), - SiteLogo: settings[SettingKeySiteLogo], - SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), - APIBaseURL: settings[SettingKeyAPIBaseURL], - ContactInfo: settings[SettingKeyContactInfo], - DocURL: settings[SettingKeyDocURL], - HomeContent: settings[SettingKeyHomeContent], - HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", - PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", - PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), - TableDefaultPageSize: tableDefaultPageSize, - TablePageSizeOptions: tablePageSizeOptions, - CustomMenuItems: settings[SettingKeyCustomMenuItems], - CustomEndpoints: settings[SettingKeyCustomEndpoints], - LinuxDoOAuthEnabled: linuxDoEnabled, - DingTalkOAuthEnabled: dingTalkEnabled, - WeChatOAuthEnabled: weChatEnabled, - WeChatOAuthOpenEnabled: weChatOpenEnabled, - WeChatOAuthMPEnabled: weChatMPEnabled, - WeChatOAuthMobileEnabled: weChatMobileEnabled, - BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", - PaymentEnabled: settings[SettingPaymentEnabled] == "true", - OIDCOAuthEnabled: oidcEnabled, - OIDCOAuthProviderName: oidcProviderName, - GitHubOAuthEnabled: gitHubEnabled, - GoogleOAuthEnabled: googleEnabled, - BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true", - AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true", - BalanceLowNotifyThreshold: balanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL], - - ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]), - ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), - - AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", - - AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true", - - RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true", - - AllowUserViewErrorRequests: settings[SettingKeyAllowUserViewErrorRequests] == "true", - }, nil -} - -// channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval -// (mirrors the monitor-level constraint but lives here so setting_service stays decoupled). -const ( - channelMonitorIntervalMin = 15 - channelMonitorIntervalMax = 3600 - channelMonitorIntervalFallback = 60 -) - -// parseChannelMonitorInterval parses the stored string and clamps to [15, 3600]. -// Empty / invalid input falls back to channelMonitorIntervalFallback. -func parseChannelMonitorInterval(raw string) int { - v, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil { - return channelMonitorIntervalFallback - } - return clampChannelMonitorInterval(v) -} - -// clampChannelMonitorInterval clamps v to the allowed range. 0 means "not provided". -func clampChannelMonitorInterval(v int) int { - if v <= 0 { - return 0 - } - if v < channelMonitorIntervalMin { - return channelMonitorIntervalMin - } - if v > channelMonitorIntervalMax { - return channelMonitorIntervalMax - } - return v -} - -// ChannelMonitorRuntime is the lightweight view of the channel monitor feature -// consumed by the runner and user-facing handlers. -type ChannelMonitorRuntime struct { - Enabled bool - DefaultIntervalSeconds int -} - -// GetChannelMonitorRuntime reads the channel monitor feature flags directly from -// the settings store. Fail-open: on error returns Enabled=true with the default interval. -func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime { - vals, err := s.settingRepo.GetMultiple(ctx, []string{ - SettingKeyChannelMonitorEnabled, - SettingKeyChannelMonitorDefaultIntervalSeconds, - }) - if err != nil { - return ChannelMonitorRuntime{Enabled: true, DefaultIntervalSeconds: channelMonitorIntervalFallback} - } - return ChannelMonitorRuntime{ - Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]), - DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]), - } -} - -// AvailableChannelsRuntime is the lightweight view of the available-channels feature -// switch consumed by the user-facing handler. -type AvailableChannelsRuntime struct { - Enabled bool -} - -// GetAvailableChannelsRuntime reads the available-channels feature switch directly -// from the settings store. Fail-closed: on error returns Enabled=false, matching -// the opt-in default (unknown ↔ disabled). -func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) AvailableChannelsRuntime { - vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAvailableChannelsEnabled}) - if err != nil { - return AvailableChannelsRuntime{Enabled: false} - } - return AvailableChannelsRuntime{ - Enabled: vals[SettingKeyAvailableChannelsEnabled] == "true", - } -} - -// IsUserErrorViewAllowed reads the user-facing error-requests visibility switch -// directly from the settings store. Fail-closed: on error returns false (opt-in default). -func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool { - vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAllowUserViewErrorRequests}) - if err != nil { - slog.Warn("failed to get allow_user_view_error_requests setting, defaulting to false", "error", err) - return false - } - return vals[SettingKeyAllowUserViewErrorRequests] == "true" -} - -// GetAntigravityUserAgentVersion 返回 Antigravity 上游请求使用的版本号。 -// 后台设置优先;为空、缺失或非法时回退到 ANTIGRAVITY_USER_AGENT_VERSION / 内置默认值。 -func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) string { - fallback := antigravity.GetDefaultUserAgentVersion() - if s == nil || s.settingRepo == nil { - return fallback - } - if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.version - } - } - - result, _, _ := s.antigravityUAVersionSF.Do("antigravity_user_agent_version", func() (any, error) { - if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.version, nil - } - } - if ctx == nil { - ctx = context.Background() - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), antigravityUserAgentVersionDBTimeout) - defer cancel() - value, err := s.settingRepo.GetValue(dbCtx, SettingKeyAntigravityUserAgentVersion) - if err != nil && !errors.Is(err, ErrSettingNotFound) { - slog.Warn("failed to get antigravity user agent version setting", "error", err) - s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{ - version: fallback, - expiresAt: time.Now().Add(antigravityUserAgentVersionErrorTTL).UnixNano(), - }) - return fallback, nil - } - version := antigravity.NormalizeUserAgentVersion(value) - if version == "" { - version = fallback - } - s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{ - version: version, - expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(), - }) - return version, nil - }) - if version, ok := result.(string); ok && version != "" { - return version - } - return fallback -} - -// GetOpenAICodexUserAgent 返回 OpenAI Codex 上游请求使用的 User-Agent。 -// 后台设置优先;为空时回退到内置默认值。 -func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string { - fallback := DefaultOpenAICodexUserAgent - if s == nil || s.settingRepo == nil { - return fallback - } - if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.value - } - } - - result, _, _ := s.openAICodexUASF.Do("openai_codex_user_agent", func() (any, error) { - if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.value, nil - } - } - if ctx == nil { - ctx = context.Background() - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAICodexUserAgentDBTimeout) - defer cancel() - value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAICodexUserAgent) - if err != nil && !errors.Is(err, ErrSettingNotFound) { - slog.Warn("failed to get openai codex user agent setting", "error", err) - s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{ - value: fallback, - expiresAt: time.Now().Add(openAICodexUserAgentErrorTTL).UnixNano(), - }) - return fallback, nil - } - ua := strings.TrimSpace(value) - if ua == "" { - ua = fallback - } - s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{ - value: ua, - expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(), - }) - return ua, nil - }) - if ua, ok := result.(string); ok && ua != "" { - return ua - } - return fallback -} - -var legacyClaudeCodeCodexWhitelistEntry = openai.AllowedClientEntry{ - Originator: "Claude Code", - UAContains: []string{"Claude Code/"}, -} - -// MigrateOpenAIAllowClaudeCodeCodexPluginSetting folds the deprecated global Claude Code -// plugin allow switch into codex_cli_only_whitelist. The app-server identity model is the -// same originator + UA marker pair, so runtime checks no longer need a separate flag. -func (s *SettingService) MigrateOpenAIAllowClaudeCodeCodexPluginSetting(ctx context.Context) error { - if s == nil || s.settingRepo == nil { - return nil - } - if ctx == nil { - ctx = context.Background() - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) - defer cancel() - - legacyValue, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAIAllowClaudeCodeCodexPlugin) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return nil - } - return fmt.Errorf("get deprecated %s setting: %w", SettingKeyOpenAIAllowClaudeCodeCodexPlugin, err) - } - if strings.TrimSpace(legacyValue) != "true" { - return nil - } - - rawWhitelist, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyWhitelist) - if err != nil && !errors.Is(err, ErrSettingNotFound) { - return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) - } - - var entries []openai.AllowedClientEntry - if strings.TrimSpace(rawWhitelist) != "" { - if err := json.Unmarshal([]byte(rawWhitelist), &entries); err != nil { - return fmt.Errorf("parse %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) - } - } - if codexClientEntriesContain(entries, legacyClaudeCodeCodexWhitelistEntry) { - return nil - } - - entries = append(entries, legacyClaudeCodeCodexWhitelistEntry) - encoded, err := json.Marshal(entries) - if err != nil { - return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) - } - if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyWhitelist, string(encoded)); err != nil { - return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) - } - s.codexRestrictionPolicySF.Forget("codex_restriction_policy") - s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) - return nil -} - -// MigrateCodexBodyFingerprintToSignals 把已废弃的 codex_cli_only_allow_body_engine_fingerprint -// 开关并入引擎指纹信号列表。幂等:信号键已存在(非空)则不动;缺失时写默认种子, -// 并把 body 路径行的 Required 设为旧 body 开关的值(旧 true ⇒ 勾上 body 行)。 -func (s *SettingService) MigrateCodexBodyFingerprintToSignals(ctx context.Context) error { - if s == nil || s.settingRepo == nil { - return nil - } - if ctx == nil { - ctx = context.Background() - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) - defer cancel() - - if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals); err == nil && strings.TrimSpace(v) != "" { - return nil // 已配置/已迁移 - } else if err != nil && !errors.Is(err, ErrSettingNotFound) { - return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) - } - - bodyOn := false - if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint); err == nil { - bodyOn = strings.TrimSpace(v) == "true" - } else if !errors.Is(err, ErrSettingNotFound) { - return fmt.Errorf("get deprecated %s setting: %w", SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint, err) - } - - seed := make([]openai.EngineFingerprintSignal, len(openai.DefaultEngineFingerprintSignals)) - copy(seed, openai.DefaultEngineFingerprintSignals) - if bodyOn { - for i := range seed { - if seed[i].Type == openai.FingerprintSignalBodyPath { - seed[i].Required = true - } - } - } - encoded, err := json.Marshal(seed) - if err != nil { - return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) - } - if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals, string(encoded)); err != nil { - return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) - } - s.codexRestrictionPolicySF.Forget("codex_restriction_policy") - s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) - return nil -} - -func codexClientEntriesContain(entries []openai.AllowedClientEntry, want openai.AllowedClientEntry) bool { - wantOriginator := strings.TrimSpace(want.Originator) - if wantOriginator == "" { - return false - } - wantMarkers := normalizedCodexClientMarkers(want.UAContains) - if len(wantMarkers) == 0 { - return false - } - for _, entry := range entries { - if !strings.EqualFold(strings.TrimSpace(entry.Originator), wantOriginator) { - continue - } - gotMarkers := normalizedCodexClientMarkers(entry.UAContains) - if len(gotMarkers) != len(wantMarkers) { - continue - } - matched := true - for marker := range wantMarkers { - if _, ok := gotMarkers[marker]; !ok { - matched = false - break - } - } - if matched { - return true - } - } - return false -} - -func normalizedCodexClientMarkers(markers []string) map[string]struct{} { - normalized := make(map[string]struct{}, len(markers)) - for _, marker := range markers { - marker = strings.TrimSpace(marker) - if marker == "" { - continue - } - normalized[strings.ToLower(marker)] = struct{}{} - } - return normalized -} - -// GetCodexRestrictionPolicy 读取 codex_cli_only 全局加固策略(黑/白名单、最低版本、引擎指纹门)。 -// 仅在调用方已确认账号 codex_cli_only 开启时读取;进程内 atomic.Value 缓存(60s TTL)避免热路径访问 DB。 -// 任意键缺失/解析失败 → 安全默认:空名单、空版本、默认种子指纹信号。 -func (s *SettingService) GetCodexRestrictionPolicy(ctx context.Context) CodexRestrictionPolicy { - if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.value - } - } - result, _, _ := s.codexRestrictionPolicySF.Do("codex_restriction_policy", func() (any, error) { - if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.value, nil - } - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) - defer cancel() - - pol := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} // 安全默认:默认种子指纹信号 - if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinCodexVersion); err == nil { - pol.MinCodexVersion = strings.TrimSpace(v) - } - if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMaxCodexVersion); err == nil { - pol.MaxCodexVersion = strings.TrimSpace(v) - } - if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowAppServerClients); err == nil { - pol.AllowAppServerClients = strings.TrimSpace(v) == "true" // 仅显式 "true" 开启 - } - pol.EngineFingerprintSignals = s.loadEngineFingerprintSignals(dbCtx) - pol.Whitelist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyWhitelist) - pol.Blacklist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyBlacklist) - - s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{ - value: pol, - expiresAt: time.Now().Add(codexRestrictionPolicyCacheTTL).UnixNano(), - }) - return pol, nil - }) - if pol, ok := result.(CodexRestrictionPolicy); ok { - return pol - } - return CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} -} - -// loadCodexClientEntries 读取并解析 []openai.AllowedClientEntry JSON 设置;缺失/空/非法 → nil(安全忽略)。 -func (s *SettingService) loadCodexClientEntries(ctx context.Context, key string) []openai.AllowedClientEntry { - v, err := s.settingRepo.GetValue(ctx, key) - if err != nil || strings.TrimSpace(v) == "" { - return nil - } - var entries []openai.AllowedClientEntry - if json.Unmarshal([]byte(v), &entries) != nil { - return nil - } - return entries -} - -// loadEngineFingerprintSignals 读取引擎指纹信号列表;缺失/空/非法 → 默认种子。 -func (s *SettingService) loadEngineFingerprintSignals(ctx context.Context) []openai.EngineFingerprintSignal { - v, err := s.settingRepo.GetValue(ctx, SettingKeyCodexCLIOnlyEngineFingerprintSignals) - if err != nil || strings.TrimSpace(v) == "" { - return openai.DefaultEngineFingerprintSignals - } - sigs, ok := openai.ParseEngineFingerprintSignals(v) - if !ok { - return openai.DefaultEngineFingerprintSignals - } - return sigs -} - -// ValidateCodexClientEntriesJSON 校验 codex_cli_only 名单 JSON 配置(黑名单语义): -// 空=合法(禁用);非空须为 []AllowedClientEntry 的 JSON 数组。黑名单是 OR 宽 deny, -// 允许 originator-only 条目,故不校验 ua_contains。白名单请用 ValidateCodexWhitelistEntriesJSON。 -func ValidateCodexClientEntriesJSON(raw string) error { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil - } - var entries []openai.AllowedClientEntry - if err := json.Unmarshal([]byte(trimmed), &entries); err != nil { - return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}") - } - return nil -} - -// ValidateCodexWhitelistEntriesJSON 在 ValidateCodexClientEntriesJSON 的数组结构校验之上,额外要求 -// 每条白名单条目「有可能命中」(openai.AllowedClientEntry.IsWhitelistable)。白名单是双因子 AND: -// originator-only、空或含空白 ua_contains 的条目会在运行时静默失效——这里让管理员在写入时即收到反馈, -// 而非存入永不命中的死规则。黑名单(OR 宽 deny)仍用 ValidateCodexClientEntriesJSON。 -func ValidateCodexWhitelistEntriesJSON(raw string) error { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil - } - var entries []openai.AllowedClientEntry - if err := json.Unmarshal([]byte(trimmed), &entries); err != nil { - return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}") - } - for i, e := range entries { - if !e.IsWhitelistable() { - return fmt.Errorf("entry %d: whitelist requires a non-empty originator and at least one non-empty ua_contains (double-factor AND; otherwise the rule never matches)", i) - } - } - return nil -} - -// ValidateEngineFingerprintSignalsJSON 服务层包装,复用 openai 校验逻辑。 -func ValidateEngineFingerprintSignalsJSON(raw string) error { - return openai.ValidateEngineFingerprintSignalsJSON(raw) -} - // SetOnUpdateCallback sets a callback function to be called when settings are updated // This is used for cache invalidation (e.g., HTML cache in frontend server) func (s *SettingService) SetOnUpdateCallback(callback func()) { @@ -1407,2777 +254,6 @@ func (s *SettingService) SetVersion(version string) { s.version = version } -// PublicSettingsInjectionPayload is the JSON shape embedded into HTML as -// `window.__APP_CONFIG__` so the frontend can hydrate feature flags & site -// config before the first XHR finishes. -// -// INVARIANT: every `json` tag here MUST also exist on handler/dto.PublicSettings. -// If you forget a feature-flag field here, the frontend's -// `cachedPublicSettings.xxx_enabled` will be `undefined` on refresh until the -// async `/api/v1/settings/public` call returns — which causes opt-in menus -// (strict `=== true`) to flicker off/on. See -// frontend/src/utils/featureFlags.ts for the matching registry. -// -// A unit test diffs this struct's JSON keys against dto.PublicSettings to catch -// drift automatically (see setting_service_injection_test.go). -type PublicSettingsInjectionPayload struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` - LoginAgreementEnabled bool `json:"login_agreement_enabled"` - LoginAgreementMode string `json:"login_agreement_mode"` - LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` - LoginAgreementRevision string `json:"login_agreement_revision"` - LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems json.RawMessage `json:"custom_menu_items"` - CustomEndpoints json.RawMessage `json:"custom_endpoints"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` - WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` - WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` - WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` - WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` - OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` - OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` - GitHubOAuthEnabled bool `json:"github_oauth_enabled"` - GoogleOAuthEnabled bool `json:"google_oauth_enabled"` - BackendModeEnabled bool `json:"backend_mode_enabled"` - PaymentEnabled bool `json:"payment_enabled"` - Version string `json:"version"` - // 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用 - ServerTimezone string `json:"server_timezone"` - ServerUTCOffset string `json:"server_utc_offset"` - BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` - AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` - BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` - BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` - - // Feature flags — MUST match the opt-in/opt-out registry in - // frontend/src/utils/featureFlags.ts. Missing a field here is the bug - // that hid the "可用渠道" menu on page refresh. - ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` - AvailableChannelsEnabled bool `json:"available_channels_enabled"` - AffiliateEnabled bool `json:"affiliate_enabled"` - RiskControlEnabled bool `json:"risk_control_enabled"` - AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` -} - -// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. -// This implements the web.PublicSettingsProvider interface. -func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) { - settings, err := s.GetPublicSettings(ctx) - if err != nil { - return nil, err - } - - return &PublicSettingsInjectionPayload{ - RegistrationEnabled: settings.RegistrationEnabled, - EmailVerifyEnabled: settings.EmailVerifyEnabled, - RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: settings.PromoCodeEnabled, - PasswordResetEnabled: settings.PasswordResetEnabled, - InvitationCodeEnabled: settings.InvitationCodeEnabled, - TotpEnabled: settings.TotpEnabled, - LoginAgreementEnabled: settings.LoginAgreementEnabled, - LoginAgreementMode: settings.LoginAgreementMode, - LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, - LoginAgreementRevision: settings.LoginAgreementRevision, - LoginAgreementDocuments: settings.LoginAgreementDocuments, - TurnstileEnabled: settings.TurnstileEnabled, - TurnstileSiteKey: settings.TurnstileSiteKey, - SiteName: settings.SiteName, - SiteLogo: settings.SiteLogo, - SiteSubtitle: settings.SiteSubtitle, - APIBaseURL: settings.APIBaseURL, - ContactInfo: settings.ContactInfo, - DocURL: settings.DocURL, - HomeContent: settings.HomeContent, - HideCcsImportButton: settings.HideCcsImportButton, - PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, - PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, - TableDefaultPageSize: settings.TableDefaultPageSize, - TablePageSizeOptions: settings.TablePageSizeOptions, - CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), - CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), - LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, - DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, - WeChatOAuthEnabled: settings.WeChatOAuthEnabled, - WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, - WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, - WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, - OIDCOAuthEnabled: settings.OIDCOAuthEnabled, - OIDCOAuthProviderName: settings.OIDCOAuthProviderName, - GitHubOAuthEnabled: settings.GitHubOAuthEnabled, - GoogleOAuthEnabled: settings.GoogleOAuthEnabled, - BackendModeEnabled: settings.BackendModeEnabled, - PaymentEnabled: settings.PaymentEnabled, - Version: s.version, - ServerTimezone: timezone.Name(), - ServerUTCOffset: timezone.UTCOffset(), - BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, - AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, - BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, - - ChannelMonitorEnabled: settings.ChannelMonitorEnabled, - ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, - AvailableChannelsEnabled: settings.AvailableChannelsEnabled, - AffiliateEnabled: settings.AffiliateEnabled, - RiskControlEnabled: settings.RiskControlEnabled, - AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests, - }, nil -} - -func DefaultWeChatConnectScopesForMode(mode string) string { - return defaultWeChatConnectScopeForMode(mode) -} - -func (s *SettingService) parseWeChatConnectOAuthConfig(settings map[string]string) (WeChatConnectOAuthConfig, error) { - cfg := s.effectiveWeChatConnectOAuthConfig(settings) - - if !cfg.Enabled || (!cfg.OpenEnabled && !cfg.MPEnabled) { - return WeChatConnectOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled") - } - if cfg.OpenEnabled { - if cfg.AppIDForMode("open") == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app id not configured") - } - if cfg.AppSecretForMode("open") == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app secret not configured") - } - } - if cfg.MPEnabled { - if cfg.AppIDForMode("mp") == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app id not configured") - } - if cfg.AppSecretForMode("mp") == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app secret not configured") - } - } - if cfg.MobileEnabled { - if cfg.AppIDForMode("mobile") == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app id not configured") - } - if cfg.AppSecretForMode("mobile") == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app secret not configured") - } - } - if v := strings.TrimSpace(cfg.RedirectURL); v != "" { - if err := config.ValidateAbsoluteHTTPURL(v); err != nil { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url invalid") - } - } - if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth frontend redirect url invalid") - } - return cfg, nil -} - -func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string]string) (bool, bool, bool, bool) { - cfg := s.effectiveWeChatConnectOAuthConfig(settings) - if !cfg.Enabled { - return false, false, false, false - } - - openReady := cfg.OpenEnabled && cfg.AppIDForMode("open") != "" && cfg.AppSecretForMode("open") != "" - mpReady := cfg.MPEnabled && cfg.AppIDForMode("mp") != "" && cfg.AppSecretForMode("mp") != "" - mobileReady := cfg.MobileEnabled && cfg.AppIDForMode("mobile") != "" && cfg.AppSecretForMode("mobile") != "" - - return openReady || mpReady, openReady, mpReady, mobileReady -} - -func (s *SettingService) emailOAuthBaseConfig(provider string) config.EmailOAuthProviderConfig { - switch strings.ToLower(strings.TrimSpace(provider)) { - case "github": - cfg := config.EmailOAuthProviderConfig{ - AuthorizeURL: defaultGitHubOAuthAuthorize, - TokenURL: defaultGitHubOAuthToken, - UserInfoURL: defaultGitHubOAuthUserInfo, - EmailsURL: defaultGitHubOAuthEmails, - Scopes: defaultGitHubOAuthScopes, - FrontendRedirectURL: defaultGitHubOAuthFrontend, - } - if s != nil && s.cfg != nil { - cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GitHubOAuth) - } - return cfg - case "google": - cfg := config.EmailOAuthProviderConfig{ - AuthorizeURL: defaultGoogleOAuthAuthorize, - TokenURL: defaultGoogleOAuthToken, - UserInfoURL: defaultGoogleOAuthUserInfo, - Scopes: defaultGoogleOAuthScopes, - FrontendRedirectURL: defaultGoogleOAuthFrontend, - } - if s != nil && s.cfg != nil { - cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GoogleOAuth) - } - return cfg - default: - return config.EmailOAuthProviderConfig{} - } -} - -func mergeEmailOAuthBaseConfig(base, override config.EmailOAuthProviderConfig) config.EmailOAuthProviderConfig { - base.Enabled = override.Enabled - if strings.TrimSpace(override.ClientID) != "" { - base.ClientID = strings.TrimSpace(override.ClientID) - } - if strings.TrimSpace(override.ClientSecret) != "" { - base.ClientSecret = strings.TrimSpace(override.ClientSecret) - } - if strings.TrimSpace(override.AuthorizeURL) != "" { - base.AuthorizeURL = strings.TrimSpace(override.AuthorizeURL) - } - if strings.TrimSpace(override.TokenURL) != "" { - base.TokenURL = strings.TrimSpace(override.TokenURL) - } - if strings.TrimSpace(override.UserInfoURL) != "" { - base.UserInfoURL = strings.TrimSpace(override.UserInfoURL) - } - if strings.TrimSpace(override.EmailsURL) != "" { - base.EmailsURL = strings.TrimSpace(override.EmailsURL) - } - if strings.TrimSpace(override.Scopes) != "" { - base.Scopes = strings.TrimSpace(override.Scopes) - } - if strings.TrimSpace(override.RedirectURL) != "" { - base.RedirectURL = strings.TrimSpace(override.RedirectURL) - } - if strings.TrimSpace(override.FrontendRedirectURL) != "" { - base.FrontendRedirectURL = strings.TrimSpace(override.FrontendRedirectURL) - } - return base -} - -func (s *SettingService) emailOAuthPublicEnabled(settings map[string]string, provider string) bool { - cfg := s.effectiveEmailOAuthConfig(settings, provider) - return cfg.Enabled && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != "" -} - -func (s *SettingService) effectiveEmailOAuthConfig(settings map[string]string, provider string) config.EmailOAuthProviderConfig { - cfg := s.emailOAuthBaseConfig(provider) - switch strings.ToLower(strings.TrimSpace(provider)) { - case "github": - if raw, ok := settings[SettingKeyGitHubOAuthEnabled]; ok { - cfg.Enabled = raw == "true" - } - cfg.ClientID = firstNonEmpty(settings[SettingKeyGitHubOAuthClientID], cfg.ClientID) - cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGitHubOAuthClientSecret], cfg.ClientSecret) - cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthRedirectURL], cfg.RedirectURL) - cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGitHubOAuthFrontend) - case "google": - if raw, ok := settings[SettingKeyGoogleOAuthEnabled]; ok { - cfg.Enabled = raw == "true" - } - cfg.ClientID = firstNonEmpty(settings[SettingKeyGoogleOAuthClientID], cfg.ClientID) - cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGoogleOAuthClientSecret], cfg.ClientSecret) - cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthRedirectURL], cfg.RedirectURL) - cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGoogleOAuthFrontend) - } - return cfg -} - -// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON -// array string, returning only items with visibility != "admin". -func filterUserVisibleMenuItems(raw string) json.RawMessage { - raw = strings.TrimSpace(raw) - if raw == "" || raw == "[]" { - return json.RawMessage("[]") - } - var items []struct { - Visibility string `json:"visibility"` - } - if err := json.Unmarshal([]byte(raw), &items); err != nil { - return json.RawMessage("[]") - } - - // Parse full items to preserve all fields - var fullItems []json.RawMessage - if err := json.Unmarshal([]byte(raw), &fullItems); err != nil { - return json.RawMessage("[]") - } - - var filtered []json.RawMessage - for i, item := range items { - if item.Visibility != "admin" { - filtered = append(filtered, fullItems[i]) - } - } - if len(filtered) == 0 { - return json.RawMessage("[]") - } - result, err := json.Marshal(filtered) - if err != nil { - return json.RawMessage("[]") - } - return result -} - -// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]". -func safeRawJSONArray(raw string) json.RawMessage { - raw = strings.TrimSpace(raw) - if raw == "" { - return json.RawMessage("[]") - } - if json.Valid([]byte(raw)) { - return json.RawMessage(raw) - } - return json.RawMessage("[]") -} - -// GetFrameSrcOrigins returns deduplicated http(s) origins from home_content URL, -// purchase_subscription_url, and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection. -func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) { - settings, err := s.GetPublicSettings(ctx) - if err != nil { - return nil, err - } - - seen := make(map[string]struct{}) - var origins []string - - addOrigin := func(rawURL string) { - if origin := extractOriginFromURL(rawURL); origin != "" { - if _, ok := seen[origin]; !ok { - seen[origin] = struct{}{} - origins = append(origins, origin) - } - } - } - - // home content URL (when home_content is set to a URL for iframe embedding) - addOrigin(settings.HomeContent) - - // purchase subscription URL - if settings.PurchaseSubscriptionEnabled { - addOrigin(settings.PurchaseSubscriptionURL) - } - - // all custom menu items (including admin-only, since CSP must allow all iframes) - for _, item := range parseCustomMenuItemURLs(settings.CustomMenuItems) { - addOrigin(item) - } - - return origins, nil -} - -// extractOriginFromURL returns the scheme+host origin from rawURL. -// Only http and https schemes are accepted. -func extractOriginFromURL(rawURL string) string { - rawURL = strings.TrimSpace(rawURL) - if rawURL == "" { - return "" - } - u, err := url.Parse(rawURL) - if err != nil || u.Host == "" { - return "" - } - if u.Scheme != "http" && u.Scheme != "https" { - return "" - } - return u.Scheme + "://" + u.Host -} - -// parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items. -func parseCustomMenuItemURLs(raw string) []string { - raw = strings.TrimSpace(raw) - if raw == "" || raw == "[]" { - return nil - } - var items []struct { - URL string `json:"url"` - } - if err := json.Unmarshal([]byte(raw), &items); err != nil { - return nil - } - urls := make([]string, 0, len(items)) - for _, item := range items { - if item.URL != "" { - urls = append(urls, item.URL) - } - } - return urls -} - -func oidcUsePKCECompatibilityDefault(base config.OIDCConnectConfig) bool { - if base.UsePKCEExplicit { - return base.UsePKCE - } - return true -} - -func oidcValidateIDTokenCompatibilityDefault(base config.OIDCConnectConfig) bool { - if base.ValidateIDTokenExplicit { - return base.ValidateIDToken - } - return true -} - -func oidcCompatibilityWriteDefault(base config.OIDCConnectConfig, configured bool, raw string, explicit bool, explicitValue bool) bool { - if configured { - return strings.TrimSpace(raw) == "true" - } - if explicit { - return explicitValue - } - return false -} - -// UpdateSettings 更新系统设置 -func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error { - updates, err := s.buildSystemSettingsUpdates(ctx, settings) - if err != nil { - return err - } - - err = s.settingRepo.SetMultiple(ctx, updates) - if err == nil { - s.refreshCachedSettings(settings) - } - return err -} - -func (s *SettingService) OIDCSecurityWriteDefaults(ctx context.Context) (bool, bool, error) { - rawSettings, err := s.settingRepo.GetMultiple(ctx, []string{ - SettingKeyOIDCConnectUsePKCE, - SettingKeyOIDCConnectValidateIDToken, - }) - if err != nil { - return false, false, fmt.Errorf("get oidc security write defaults: %w", err) - } - - base := config.OIDCConnectConfig{} - if s != nil && s.cfg != nil { - base = s.cfg.OIDC - } - - rawUsePKCE, hasUsePKCE := rawSettings[SettingKeyOIDCConnectUsePKCE] - rawValidateIDToken, hasValidateIDToken := rawSettings[SettingKeyOIDCConnectValidateIDToken] - - return oidcCompatibilityWriteDefault(base, hasUsePKCE, rawUsePKCE, base.UsePKCEExplicit, base.UsePKCE), - oidcCompatibilityWriteDefault(base, hasValidateIDToken, rawValidateIDToken, base.ValidateIDTokenExplicit, base.ValidateIDToken), - nil -} - -// UpdateSettingsWithAuthSourceDefaults persists system settings and auth-source defaults in a single write. -func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings) error { - updates, err := s.buildSystemSettingsUpdates(ctx, settings) - if err != nil { - return err - } - - authSourceUpdates, err := s.buildAuthSourceDefaultUpdates(ctx, authDefaults) - if err != nil { - return err - } - for key, value := range authSourceUpdates { - updates[key] = value - } - - err = s.settingRepo.SetMultiple(ctx, updates) - if err == nil { - s.refreshCachedSettings(settings) - } - return err -} - -func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, settings *SystemSettings) (map[string]string, error) { - if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil { - return nil, err - } - normalizedWhitelist, err := NormalizeRegistrationEmailSuffixWhitelist(settings.RegistrationEmailSuffixWhitelist) - if err != nil { - return nil, infraerrors.BadRequest("INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", err.Error()) - } - if normalizedWhitelist == nil { - normalizedWhitelist = []string{} - } - settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist - alipaySource, err := normalizeVisibleMethodSettingSource("alipay", settings.PaymentVisibleMethodAlipaySource, settings.PaymentVisibleMethodAlipayEnabled) - if err != nil { - return nil, err - } - wxpaySource, err := normalizeVisibleMethodSettingSource("wxpay", settings.PaymentVisibleMethodWxpaySource, settings.PaymentVisibleMethodWxpayEnabled) - if err != nil { - return nil, err - } - if err := s.normalizeOpenAIAdvancedSchedulerOverrides(settings); err != nil { - return nil, err - } - settings.PaymentVisibleMethodAlipaySource = alipaySource - settings.PaymentVisibleMethodWxpaySource = wxpaySource - settings.WeChatConnectAppID = strings.TrimSpace(settings.WeChatConnectAppID) - settings.WeChatConnectAppSecret = strings.TrimSpace(settings.WeChatConnectAppSecret) - settings.WeChatConnectOpenAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppID, settings.WeChatConnectAppID)) - settings.WeChatConnectOpenAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppSecret, settings.WeChatConnectAppSecret)) - settings.WeChatConnectMPAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppID, settings.WeChatConnectAppID)) - settings.WeChatConnectMPAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppSecret, settings.WeChatConnectAppSecret)) - settings.WeChatConnectMobileAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppID, settings.WeChatConnectAppID)) - settings.WeChatConnectMobileAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppSecret, settings.WeChatConnectAppSecret)) - settings.WeChatConnectMode = normalizeWeChatConnectStoredMode( - settings.WeChatConnectOpenEnabled, - settings.WeChatConnectMPEnabled, - settings.WeChatConnectMobileEnabled, - settings.WeChatConnectMode, - ) - settings.WeChatConnectScopes = normalizeWeChatConnectScopeSetting(settings.WeChatConnectScopes, settings.WeChatConnectMode) - settings.WeChatConnectRedirectURL = strings.TrimSpace(settings.WeChatConnectRedirectURL) - settings.WeChatConnectFrontendRedirectURL = strings.TrimSpace(settings.WeChatConnectFrontendRedirectURL) - if settings.WeChatConnectFrontendRedirectURL == "" { - settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend - } - settings.GitHubOAuthRedirectURL = strings.TrimSpace(settings.GitHubOAuthRedirectURL) - settings.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(settings.GitHubOAuthFrontendRedirectURL) - if settings.GitHubOAuthFrontendRedirectURL == "" { - settings.GitHubOAuthFrontendRedirectURL = defaultGitHubOAuthFrontend - } - settings.GoogleOAuthRedirectURL = strings.TrimSpace(settings.GoogleOAuthRedirectURL) - settings.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(settings.GoogleOAuthFrontendRedirectURL) - if settings.GoogleOAuthFrontendRedirectURL == "" { - settings.GoogleOAuthFrontendRedirectURL = defaultGoogleOAuthFrontend - } - - updates := make(map[string]string) - - // 注册设置 - updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled) - updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) - registrationEmailSuffixWhitelistJSON, err := json.Marshal(settings.RegistrationEmailSuffixWhitelist) - if err != nil { - return nil, fmt.Errorf("marshal registration email suffix whitelist: %w", err) - } - updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON) - updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) - updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) - updates[SettingKeyFrontendURL] = settings.FrontendURL - updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) - updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) - settings.LoginAgreementMode = normalizeLoginAgreementMode(settings.LoginAgreementMode) - settings.LoginAgreementUpdatedAt = strings.TrimSpace(settings.LoginAgreementUpdatedAt) - if settings.LoginAgreementUpdatedAt == "" { - settings.LoginAgreementUpdatedAt = defaultLoginAgreementDate - } - loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(settings.LoginAgreementDocuments) - if err != nil { - return nil, err - } - updates[SettingKeyLoginAgreementEnabled] = strconv.FormatBool(settings.LoginAgreementEnabled) - updates[SettingKeyLoginAgreementMode] = settings.LoginAgreementMode - updates[SettingKeyLoginAgreementUpdatedAt] = settings.LoginAgreementUpdatedAt - updates[SettingKeyLoginAgreementDocuments] = loginAgreementDocumentsJSON - - // 邮件服务设置(只有非空才更新密码) - updates[SettingKeySMTPHost] = settings.SMTPHost - updates[SettingKeySMTPPort] = strconv.Itoa(settings.SMTPPort) - updates[SettingKeySMTPUsername] = settings.SMTPUsername - if settings.SMTPPassword != "" { - updates[SettingKeySMTPPassword] = settings.SMTPPassword - } - updates[SettingKeySMTPFrom] = settings.SMTPFrom - updates[SettingKeySMTPFromName] = settings.SMTPFromName - updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS) - - // Cloudflare Turnstile 设置(只有非空才更新密钥) - updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled) - updates[SettingKeyTurnstileSiteKey] = settings.TurnstileSiteKey - if settings.TurnstileSecretKey != "" { - updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey - } - updates[SettingKeyAPIKeyACLTrustForwardedIP] = strconv.FormatBool(settings.APIKeyACLTrustForwardedIP) - - // LinuxDo Connect OAuth 登录 - updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled) - updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID - updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL - if settings.LinuxDoConnectClientSecret != "" { - updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret - } - - // DingTalk Connect OAuth 登录 - updates[SettingKeyDingTalkConnectEnabled] = strconv.FormatBool(settings.DingTalkConnectEnabled) - updates[SettingKeyDingTalkConnectClientID] = settings.DingTalkConnectClientID - updates[SettingKeyDingTalkConnectRedirectURL] = settings.DingTalkConnectRedirectURL - if settings.DingTalkConnectClientSecret != "" { - updates[SettingKeyDingTalkConnectClientSecret] = settings.DingTalkConnectClientSecret - } - updates[SettingKeyDingTalkConnectCorpRestrictionPolicy] = settings.DingTalkConnectCorpRestrictionPolicy - updates[SettingKeyDingTalkConnectInternalCorpID] = settings.DingTalkConnectInternalCorpID - updates[SettingKeyDingTalkConnectBypassRegistration] = strconv.FormatBool(settings.DingTalkConnectBypassRegistration) - updates[SettingKeyDingTalkConnectSyncCorpEmail] = strconv.FormatBool(settings.DingTalkConnectSyncCorpEmail) - updates[SettingKeyDingTalkConnectSyncDisplayName] = strconv.FormatBool(settings.DingTalkConnectSyncDisplayName) - updates[SettingKeyDingTalkConnectSyncDept] = strconv.FormatBool(settings.DingTalkConnectSyncDept) - updates[SettingKeyDingTalkConnectSyncCorpEmailAttrKey] = settings.DingTalkConnectSyncCorpEmailAttrKey - updates[SettingKeyDingTalkConnectSyncDisplayNameAttrKey] = settings.DingTalkConnectSyncDisplayNameAttrKey - updates[SettingKeyDingTalkConnectSyncDeptAttrKey] = settings.DingTalkConnectSyncDeptAttrKey - updates[SettingKeyDingTalkConnectSyncCorpEmailAttrName] = settings.DingTalkConnectSyncCorpEmailAttrName - updates[SettingKeyDingTalkConnectSyncDisplayNameAttrName] = settings.DingTalkConnectSyncDisplayNameAttrName - updates[SettingKeyDingTalkConnectSyncDeptAttrName] = settings.DingTalkConnectSyncDeptAttrName - - // Generic OIDC OAuth 登录 - updates[SettingKeyOIDCConnectEnabled] = strconv.FormatBool(settings.OIDCConnectEnabled) - updates[SettingKeyOIDCConnectProviderName] = settings.OIDCConnectProviderName - updates[SettingKeyOIDCConnectClientID] = settings.OIDCConnectClientID - updates[SettingKeyOIDCConnectIssuerURL] = settings.OIDCConnectIssuerURL - updates[SettingKeyOIDCConnectDiscoveryURL] = settings.OIDCConnectDiscoveryURL - updates[SettingKeyOIDCConnectAuthorizeURL] = settings.OIDCConnectAuthorizeURL - updates[SettingKeyOIDCConnectTokenURL] = settings.OIDCConnectTokenURL - updates[SettingKeyOIDCConnectUserInfoURL] = settings.OIDCConnectUserInfoURL - updates[SettingKeyOIDCConnectJWKSURL] = settings.OIDCConnectJWKSURL - updates[SettingKeyOIDCConnectScopes] = settings.OIDCConnectScopes - updates[SettingKeyOIDCConnectRedirectURL] = settings.OIDCConnectRedirectURL - updates[SettingKeyOIDCConnectFrontendRedirectURL] = settings.OIDCConnectFrontendRedirectURL - updates[SettingKeyOIDCConnectTokenAuthMethod] = settings.OIDCConnectTokenAuthMethod - updates[SettingKeyOIDCConnectUsePKCE] = strconv.FormatBool(settings.OIDCConnectUsePKCE) - updates[SettingKeyOIDCConnectValidateIDToken] = strconv.FormatBool(settings.OIDCConnectValidateIDToken) - updates[SettingKeyOIDCConnectAllowedSigningAlgs] = settings.OIDCConnectAllowedSigningAlgs - updates[SettingKeyOIDCConnectClockSkewSeconds] = strconv.Itoa(settings.OIDCConnectClockSkewSeconds) - updates[SettingKeyOIDCConnectRequireEmailVerified] = strconv.FormatBool(settings.OIDCConnectRequireEmailVerified) - updates[SettingKeyOIDCConnectUserInfoEmailPath] = settings.OIDCConnectUserInfoEmailPath - updates[SettingKeyOIDCConnectUserInfoIDPath] = settings.OIDCConnectUserInfoIDPath - updates[SettingKeyOIDCConnectUserInfoUsernamePath] = settings.OIDCConnectUserInfoUsernamePath - if settings.OIDCConnectClientSecret != "" { - updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret - } - - // GitHub / Google 邮箱快捷登录 - updates[SettingKeyGitHubOAuthEnabled] = strconv.FormatBool(settings.GitHubOAuthEnabled) - updates[SettingKeyGitHubOAuthClientID] = strings.TrimSpace(settings.GitHubOAuthClientID) - updates[SettingKeyGitHubOAuthRedirectURL] = settings.GitHubOAuthRedirectURL - updates[SettingKeyGitHubOAuthFrontendRedirectURL] = settings.GitHubOAuthFrontendRedirectURL - if settings.GitHubOAuthClientSecret != "" { - updates[SettingKeyGitHubOAuthClientSecret] = strings.TrimSpace(settings.GitHubOAuthClientSecret) - } - updates[SettingKeyGoogleOAuthEnabled] = strconv.FormatBool(settings.GoogleOAuthEnabled) - updates[SettingKeyGoogleOAuthClientID] = strings.TrimSpace(settings.GoogleOAuthClientID) - updates[SettingKeyGoogleOAuthRedirectURL] = settings.GoogleOAuthRedirectURL - updates[SettingKeyGoogleOAuthFrontendRedirectURL] = settings.GoogleOAuthFrontendRedirectURL - if settings.GoogleOAuthClientSecret != "" { - updates[SettingKeyGoogleOAuthClientSecret] = strings.TrimSpace(settings.GoogleOAuthClientSecret) - } - - // WeChat Connect OAuth 登录 - updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled) - updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID - updates[SettingKeyWeChatConnectOpenAppID] = settings.WeChatConnectOpenAppID - updates[SettingKeyWeChatConnectMPAppID] = settings.WeChatConnectMPAppID - updates[SettingKeyWeChatConnectMobileAppID] = settings.WeChatConnectMobileAppID - updates[SettingKeyWeChatConnectOpenEnabled] = strconv.FormatBool(settings.WeChatConnectOpenEnabled) - updates[SettingKeyWeChatConnectMPEnabled] = strconv.FormatBool(settings.WeChatConnectMPEnabled) - updates[SettingKeyWeChatConnectMobileEnabled] = strconv.FormatBool(settings.WeChatConnectMobileEnabled) - updates[SettingKeyWeChatConnectMode] = settings.WeChatConnectMode - updates[SettingKeyWeChatConnectScopes] = settings.WeChatConnectScopes - updates[SettingKeyWeChatConnectRedirectURL] = settings.WeChatConnectRedirectURL - updates[SettingKeyWeChatConnectFrontendRedirectURL] = settings.WeChatConnectFrontendRedirectURL - if settings.WeChatConnectAppSecret != "" { - updates[SettingKeyWeChatConnectAppSecret] = settings.WeChatConnectAppSecret - } - if settings.WeChatConnectOpenAppSecret != "" { - updates[SettingKeyWeChatConnectOpenAppSecret] = settings.WeChatConnectOpenAppSecret - } - if settings.WeChatConnectMPAppSecret != "" { - updates[SettingKeyWeChatConnectMPAppSecret] = settings.WeChatConnectMPAppSecret - } - if settings.WeChatConnectMobileAppSecret != "" { - updates[SettingKeyWeChatConnectMobileAppSecret] = settings.WeChatConnectMobileAppSecret - } - - // OEM设置 - updates[SettingKeySiteName] = settings.SiteName - updates[SettingKeySiteLogo] = settings.SiteLogo - updates[SettingKeySiteSubtitle] = settings.SiteSubtitle - updates[SettingKeyAPIBaseURL] = settings.APIBaseURL - updates[SettingKeyContactInfo] = settings.ContactInfo - updates[SettingKeyDocURL] = settings.DocURL - updates[SettingKeyHomeContent] = settings.HomeContent - updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton) - updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled) - updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL) - tableDefaultPageSize, tablePageSizeOptions := normalizeTablePreferences( - settings.TableDefaultPageSize, - settings.TablePageSizeOptions, - ) - updates[SettingKeyTableDefaultPageSize] = strconv.Itoa(tableDefaultPageSize) - tablePageSizeOptionsJSON, err := json.Marshal(tablePageSizeOptions) - if err != nil { - return nil, fmt.Errorf("marshal table page size options: %w", err) - } - updates[SettingKeyTablePageSizeOptions] = string(tablePageSizeOptionsJSON) - updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems - updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints - - // 默认配置 - updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) - updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64) - settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate) - updates[SettingKeyAffiliateRebateRate] = strconv.FormatFloat(settings.AffiliateRebateRate, 'f', 8, 64) - if settings.AffiliateRebateFreezeHours < 0 { - settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursDefault - } - if settings.AffiliateRebateFreezeHours > AffiliateRebateFreezeHoursMax { - settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursMax - } - updates[SettingKeyAffiliateRebateFreezeHours] = strconv.Itoa(settings.AffiliateRebateFreezeHours) - if settings.AffiliateRebateDurationDays < 0 { - settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysDefault - } - if settings.AffiliateRebateDurationDays > AffiliateRebateDurationDaysMax { - settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysMax - } - updates[SettingKeyAffiliateRebateDurationDays] = strconv.Itoa(settings.AffiliateRebateDurationDays) - if settings.AffiliateRebatePerInviteeCap < 0 { - settings.AffiliateRebatePerInviteeCap = AffiliateRebatePerInviteeCapDefault - } - updates[SettingKeyAffiliateRebatePerInviteeCap] = strconv.FormatFloat(settings.AffiliateRebatePerInviteeCap, 'f', 8, 64) - updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit) - defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions) - if err != nil { - return nil, fmt.Errorf("marshal default subscriptions: %w", err) - } - updates[SettingKeyDefaultSubscriptions] = string(defaultSubsJSON) - - // Model fallback configuration - updates[SettingKeyEnableModelFallback] = strconv.FormatBool(settings.EnableModelFallback) - updates[SettingKeyFallbackModelAnthropic] = settings.FallbackModelAnthropic - updates[SettingKeyFallbackModelOpenAI] = settings.FallbackModelOpenAI - updates[SettingKeyFallbackModelGemini] = settings.FallbackModelGemini - updates[SettingKeyFallbackModelAntigravity] = settings.FallbackModelAntigravity - - // Identity patch configuration (Claude -> Gemini) - updates[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch) - updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt - - // Ops monitoring (vNext) - updates[SettingKeyOpsMonitoringEnabled] = strconv.FormatBool(settings.OpsMonitoringEnabled) - updates[SettingKeyOpsRealtimeMonitoringEnabled] = strconv.FormatBool(settings.OpsRealtimeMonitoringEnabled) - updates[SettingKeyOpsQueryModeDefault] = string(ParseOpsQueryMode(settings.OpsQueryModeDefault)) - if settings.OpsMetricsIntervalSeconds > 0 { - updates[SettingKeyOpsMetricsIntervalSeconds] = strconv.Itoa(settings.OpsMetricsIntervalSeconds) - } - - // Channel monitor feature switch - updates[SettingKeyChannelMonitorEnabled] = strconv.FormatBool(settings.ChannelMonitorEnabled) - if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 { - updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v) - } - - // Available channels feature switch - updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled) - - // Affiliate (邀请返利) feature switch - updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled) - - // 风控中心功能开关 - updates[SettingKeyRiskControlEnabled] = strconv.FormatBool(settings.RiskControlEnabled) - - // cyber 会话屏蔽开关 + TTL - updates[SettingKeyCyberSessionBlockEnabled] = strconv.FormatBool(settings.CyberSessionBlockEnabled) - if settings.CyberSessionBlockTTLSeconds > 0 { - updates[SettingKeyCyberSessionBlockTTLSeconds] = strconv.Itoa(settings.CyberSessionBlockTTLSeconds) - } - - // Claude Code version check - updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion - updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion - - // 分组隔离 - updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling) - - // Backend Mode - updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled) - - // Gateway forwarding behavior - updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification) - updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough) - updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning) - updates[SettingKeyEnableClaudeOAuthSystemPromptInjection] = strconv.FormatBool(settings.EnableClaudeOAuthSystemPromptInjection) - updates[SettingKeyClaudeOAuthSystemPrompt] = settings.ClaudeOAuthSystemPrompt - if err := ValidateClaudeOAuthSystemPromptBlocksConfig(settings.ClaudeOAuthSystemPromptBlocks); err != nil { - return nil, err - } - updates[SettingKeyClaudeOAuthSystemPromptBlocks] = settings.ClaudeOAuthSystemPromptBlocks - updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection) - updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl) - updates[SettingKeyEnableClientDatelineNormalization] = strconv.FormatBool(settings.EnableClientDatelineNormalization) - updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion) - updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent) - // codex_cli_only 加固 - updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion) - updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion) - updates[SettingKeyCodexCLIOnlyBlacklist] = strings.TrimSpace(settings.CodexCLIOnlyBlacklist) - updates[SettingKeyCodexCLIOnlyWhitelist] = strings.TrimSpace(settings.CodexCLIOnlyWhitelist) - updates[SettingKeyCodexCLIOnlyAllowAppServerClients] = strconv.FormatBool(settings.CodexCLIOnlyAllowAppServerClients) - updates[SettingKeyCodexCLIOnlyEngineFingerprintSignals] = strings.TrimSpace(settings.CodexCLIOnlyEngineFingerprintSignals) - updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource - updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource - updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled) - updates[SettingPaymentVisibleMethodWxpayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodWxpayEnabled) - updates[openAIAdvancedSchedulerSettingKey] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerEnabled) - updates[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerStickyWeightedEnabled) - updates[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled) - updates[SettingKeyOpenAIAdvancedSchedulerLBTopK] = settings.OpenAIAdvancedSchedulerLBTopK - updates[SettingKeyOpenAIAdvancedSchedulerWeightPriority] = settings.OpenAIAdvancedSchedulerWeightPriority - updates[SettingKeyOpenAIAdvancedSchedulerWeightLoad] = settings.OpenAIAdvancedSchedulerWeightLoad - updates[SettingKeyOpenAIAdvancedSchedulerWeightQueue] = settings.OpenAIAdvancedSchedulerWeightQueue - updates[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate] = settings.OpenAIAdvancedSchedulerWeightErrorRate - updates[SettingKeyOpenAIAdvancedSchedulerWeightTTFT] = settings.OpenAIAdvancedSchedulerWeightTTFT - updates[SettingKeyOpenAIAdvancedSchedulerWeightReset] = settings.OpenAIAdvancedSchedulerWeightReset - updates[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom] = settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom - updates[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse] = settings.OpenAIAdvancedSchedulerWeightPreviousResponse - updates[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky] = settings.OpenAIAdvancedSchedulerWeightSessionSticky - - // 余额、订阅到期与账号限额通知 - updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled) - updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64) - updates[SettingKeyBalanceLowNotifyRechargeURL] = settings.BalanceLowNotifyRechargeURL - updates[SettingKeySubscriptionExpiryNotifyEnabled] = strconv.FormatBool(settings.SubscriptionExpiryNotifyEnabled) - updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled) - updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails) - - // 系统全局 platform quota:整体替换语义(null/缺省 = 不限制)。 - if settings.DefaultPlatformQuotas != nil { - if err := validateDefaultPlatformQuotaMap(settings.DefaultPlatformQuotas); err != nil { - return nil, err - } - blob, err := json.Marshal(settings.DefaultPlatformQuotas) - if err != nil { - return nil, fmt.Errorf("marshal default platform quotas: %w", err) - } - updates[SettingKeyDefaultPlatformQuotas] = string(blob) - } - - updates[SettingKeyAllowUserViewErrorRequests] = strconv.FormatBool(settings.AllowUserViewErrorRequests) - - return updates, nil -} - -// validateDefaultPlatformQuotaMap 校验 platform quota map 的合法性: -// 平台名须在 AllowedQuotaPlatforms 白名单内,每个非 nil 上限须 finite 且 >= 0。 -// 系统层和 auth-source 层共用此 helper。 -func validateDefaultPlatformQuotaMap(m map[string]*DefaultPlatformQuotaSetting) error { - for platform, pq := range m { - if !IsAllowedQuotaPlatform(platform) { - return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", fmt.Sprintf("unknown platform %q", platform)) - } - if pq == nil { - continue - } - for _, v := range []*float64{pq.DailyLimitUSD, pq.WeeklyLimitUSD, pq.MonthlyLimitUSD} { - if v != nil && (*v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0)) { - return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", "platform quota limit must be a finite non-negative number") - } - } - } - return nil -} - -func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, settings *AuthSourceDefaultSettings) (map[string]string, error) { - if settings == nil { - return nil, nil - } - - for _, subscriptions := range [][]DefaultSubscriptionSetting{ - settings.Email.Subscriptions, - settings.LinuxDo.Subscriptions, - settings.OIDC.Subscriptions, - settings.WeChat.Subscriptions, - settings.GitHub.Subscriptions, - settings.Google.Subscriptions, - settings.DingTalk.Subscriptions, - } { - if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil { - return nil, err - } - } - - // 校验各 auth source 的 platform quota map(改动 C:对等系统层校验) - for _, pgs := range []struct { - name string - pq map[string]*DefaultPlatformQuotaSetting - }{ - {"email", settings.Email.PlatformQuotas}, - {"linuxdo", settings.LinuxDo.PlatformQuotas}, - {"oidc", settings.OIDC.PlatformQuotas}, - {"wechat", settings.WeChat.PlatformQuotas}, - {"github", settings.GitHub.PlatformQuotas}, - {"google", settings.Google.PlatformQuotas}, - {"dingtalk", settings.DingTalk.PlatformQuotas}, - } { - if pgs.pq != nil { - if err := validateDefaultPlatformQuotaMap(pgs.pq); err != nil { - return nil, err - } - } - } - - updates := make(map[string]string, 36) - writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email) - writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo) - writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC) - writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat) - writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub) - writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google) - writeProviderDefaultGrantUpdates(updates, dingTalkAuthSourceDefaultKeys, settings.DingTalk) - updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup) - return updates, nil -} - -func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { - if settings == nil { - return - } - - // 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口 - versionBoundsSF.Forget("version_bounds") - versionBoundsCache.Store(&cachedVersionBounds{ - min: settings.MinClaudeCodeVersion, - max: settings.MaxClaudeCodeVersion, - expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(), - }) - backendModeSF.Forget("backend_mode") - backendModeCache.Store(&cachedBackendMode{ - value: settings.BackendModeEnabled, - expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), - }) - gatewayForwardingSF.Forget("gateway_forwarding") - gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ - fingerprintUnification: settings.EnableFingerprintUnification, - metadataPassthrough: settings.EnableMetadataPassthrough, - cchSigning: settings.EnableCCHSigning, - claudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection, - claudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt, - claudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks, - anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, - rewriteMessageCacheControl: settings.RewriteMessageCacheControl, - clientDatelineNormalization: settings.EnableClientDatelineNormalization, - expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), - }) - s.antigravityUAVersionSF.Forget("antigravity_user_agent_version") - antigravityUserAgentVersion := antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion) - if antigravityUserAgentVersion == "" { - antigravityUserAgentVersion = antigravity.GetDefaultUserAgentVersion() - } - s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{ - version: antigravityUserAgentVersion, - expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(), - }) - s.openAICodexUASF.Forget("openai_codex_user_agent") - codexUA := strings.TrimSpace(settings.OpenAICodexUserAgent) - if codexUA == "" { - codexUA = DefaultOpenAICodexUserAgent - } - s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{ - value: codexUA, - expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(), - }) - openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey) - openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ - enabled: settings.OpenAIAdvancedSchedulerEnabled, - stickyWeightedEnabled: settings.OpenAIAdvancedSchedulerStickyWeightedEnabled, - subscriptionPriorityEnabled: settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled, - lbTopKOverride: parsePositiveIntOverride(settings.OpenAIAdvancedSchedulerLBTopK), - weightOverrides: parseOpenAIAdvancedSchedulerWeightOverrides(map[string]string{ - SettingKeyOpenAIAdvancedSchedulerWeightPriority: settings.OpenAIAdvancedSchedulerWeightPriority, - SettingKeyOpenAIAdvancedSchedulerWeightLoad: settings.OpenAIAdvancedSchedulerWeightLoad, - SettingKeyOpenAIAdvancedSchedulerWeightQueue: settings.OpenAIAdvancedSchedulerWeightQueue, - SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: settings.OpenAIAdvancedSchedulerWeightErrorRate, - SettingKeyOpenAIAdvancedSchedulerWeightTTFT: settings.OpenAIAdvancedSchedulerWeightTTFT, - SettingKeyOpenAIAdvancedSchedulerWeightReset: settings.OpenAIAdvancedSchedulerWeightReset, - SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, - SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: settings.OpenAIAdvancedSchedulerWeightPreviousResponse, - SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: settings.OpenAIAdvancedSchedulerWeightSessionSticky, - }), - expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(), - }) - // Invalidate the quota auto-pause cache and let the next read trigger a fresh load. - // We can't know from here whether ops_advanced_settings was also touched, so be - // defensive: store an expired entry — GetOpenAIQuotaAutoPauseSettings will serve - // stale and kick off an async refresh, never blocking the request that follows. - s.openAIQuotaAutoPauseSettingsSF.Forget(openAIQuotaAutoPauseSettingsRefreshKey) - if cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); cached != nil { - s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{ - settings: cached.settings, - expiresAt: 0, - }) - } - if s.cfg != nil { - s.cfg.SetTrustForwardedIPForAPIKeyACL(settings.APIKeyACLTrustForwardedIP) - } - // codex_cli_only 加固策略缓存:设置更新后强制下次重载(涉及 4 个键 + JSON 解析,直接置过期)。 - s.codexRestrictionPolicySF.Forget("codex_restriction_policy") - s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) - if s.onUpdate != nil { - s.onUpdate() // Invalidate cache after settings update - } -} - -func (s *SettingService) defaultRewriteMessageCacheControl() bool { - return false -} - -func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context, items []DefaultSubscriptionSetting) error { - if len(items) == 0 { - return nil - } - - checked := make(map[int64]struct{}, len(items)) - for _, item := range items { - if item.GroupID <= 0 { - continue - } - if _, ok := checked[item.GroupID]; ok { - return ErrDefaultSubGroupDuplicate.WithMetadata(map[string]string{ - "group_id": strconv.FormatInt(item.GroupID, 10), - }) - } - checked[item.GroupID] = struct{}{} - if s.defaultSubGroupReader == nil { - continue - } - - group, err := s.defaultSubGroupReader.GetByID(ctx, item.GroupID) - if err != nil { - if errors.Is(err, ErrGroupNotFound) { - return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{ - "group_id": strconv.FormatInt(item.GroupID, 10), - }) - } - return fmt.Errorf("get default subscription group %d: %w", item.GroupID, err) - } - if !group.IsSubscriptionType() { - return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{ - "group_id": strconv.FormatInt(item.GroupID, 10), - }) - } - } - - return nil -} - -func (s *SettingService) GetEmailOAuthProviderConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) { - provider = strings.ToLower(strings.TrimSpace(provider)) - if provider != "github" && provider != "google" { - return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_PROVIDER_NOT_FOUND", "oauth provider not found") - } - keys := []string{ - SettingKeyGitHubOAuthEnabled, - SettingKeyGitHubOAuthClientID, - SettingKeyGitHubOAuthClientSecret, - SettingKeyGitHubOAuthRedirectURL, - SettingKeyGitHubOAuthFrontendRedirectURL, - SettingKeyGoogleOAuthEnabled, - SettingKeyGoogleOAuthClientID, - SettingKeyGoogleOAuthClientSecret, - SettingKeyGoogleOAuthRedirectURL, - SettingKeyGoogleOAuthFrontendRedirectURL, - } - settings, err := s.settingRepo.GetMultiple(ctx, keys) - if err != nil { - return config.EmailOAuthProviderConfig{}, fmt.Errorf("get email oauth settings: %w", err) - } - cfg := s.effectiveEmailOAuthConfig(settings, provider) - if !cfg.Enabled { - return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") - } - if strings.TrimSpace(cfg.ClientID) == "" { - return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") - } - if strings.TrimSpace(cfg.ClientSecret) == "" { - return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") - } - for label, rawURL := range map[string]string{ - "authorize": cfg.AuthorizeURL, - "token": cfg.TokenURL, - "userinfo": cfg.UserInfoURL, - "redirect": cfg.RedirectURL, - } { - if strings.TrimSpace(rawURL) == "" { - return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url not configured") - } - if err := config.ValidateAbsoluteHTTPURL(rawURL); err != nil { - return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url invalid") - } - } - if strings.TrimSpace(cfg.EmailsURL) != "" { - if err := config.ValidateAbsoluteHTTPURL(cfg.EmailsURL); err != nil { - return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth emails url invalid") - } - } - if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil { - return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") - } - return cfg, nil -} - -// IsRegistrationEnabled 检查是否开放注册 -func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled) - if err != nil { - // 安全默认:如果设置不存在或查询出错,默认关闭注册 - return false - } - return value == "true" -} - -// IsBackendModeEnabled checks if backend mode is enabled -// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path -func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool { - if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.value - } - } - result, _, _ := backendModeSF.Do("backend_mode", func() (any, error) { - if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return cached.value, nil - } - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), backendModeDBTimeout) - defer cancel() - value, err := s.settingRepo.GetValue(dbCtx, SettingKeyBackendModeEnabled) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - // Setting not yet created (fresh install) - default to disabled with full TTL - backendModeCache.Store(&cachedBackendMode{ - value: false, - expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), - }) - return false, nil - } - slog.Warn("failed to get backend_mode_enabled setting", "error", err) - backendModeCache.Store(&cachedBackendMode{ - value: false, - expiresAt: time.Now().Add(backendModeErrorTTL).UnixNano(), - }) - return false, nil - } - enabled := value == "true" - backendModeCache.Store(&cachedBackendMode{ - value: enabled, - expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), - }) - return enabled, nil - }) - if val, ok := result.(bool); ok { - return val - } - return false -} - -type gatewayForwardingSettingsResult struct { - fp, mp, cch, claudeOAuthSystemPromptInjection, cacheTTL1h, rewriteMessageCacheControl bool - clientDatelineNormalization bool - claudeOAuthSystemPrompt, claudeOAuthSystemPromptBlocks string -} - -func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) gatewayForwardingSettingsResult { - if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return gatewayForwardingSettingsResult{ - fp: cached.fingerprintUnification, - mp: cached.metadataPassthrough, - cch: cached.cchSigning, - claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection, - claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt, - claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks, - cacheTTL1h: cached.anthropicCacheTTL1hInjection, - rewriteMessageCacheControl: cached.rewriteMessageCacheControl, - clientDatelineNormalization: cached.clientDatelineNormalization, - } - } - } - val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) { - if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { - if time.Now().UnixNano() < cached.expiresAt { - return gatewayForwardingSettingsResult{ - fp: cached.fingerprintUnification, - mp: cached.metadataPassthrough, - cch: cached.cchSigning, - claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection, - claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt, - claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks, - cacheTTL1h: cached.anthropicCacheTTL1hInjection, - rewriteMessageCacheControl: cached.rewriteMessageCacheControl, - clientDatelineNormalization: cached.clientDatelineNormalization, - }, nil - } - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout) - defer cancel() - values, err := s.settingRepo.GetMultiple(dbCtx, []string{ - SettingKeyEnableFingerprintUnification, - SettingKeyEnableMetadataPassthrough, - SettingKeyEnableCCHSigning, - SettingKeyEnableClaudeOAuthSystemPromptInjection, - SettingKeyClaudeOAuthSystemPrompt, - SettingKeyClaudeOAuthSystemPromptBlocks, - SettingKeyEnableAnthropicCacheTTL1hInjection, - SettingKeyRewriteMessageCacheControl, - SettingKeyEnableClientDatelineNormalization, - }) - if err != nil { - slog.Warn("failed to get gateway forwarding settings", "error", err) - gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ - fingerprintUnification: true, - metadataPassthrough: false, - cchSigning: false, - claudeOAuthSystemPromptInjection: true, - anthropicCacheTTL1hInjection: false, - rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), - clientDatelineNormalization: true, - expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(), - }) - return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), clientDatelineNormalization: true}, nil - } - fp := true - if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" { - fp = v == "true" - } - mp := values[SettingKeyEnableMetadataPassthrough] == "true" - cch := values[SettingKeyEnableCCHSigning] == "true" - systemPromptInjection := true - if v, ok := values[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" { - systemPromptInjection = v == "true" - } - systemPrompt := values[SettingKeyClaudeOAuthSystemPrompt] - systemPromptBlocks := values[SettingKeyClaudeOAuthSystemPromptBlocks] - cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" - rewriteMessageCacheControl := s.defaultRewriteMessageCacheControl() - if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" { - rewriteMessageCacheControl = v == "true" - } - clientDatelineNormalization := true - if v, ok := values[SettingKeyEnableClientDatelineNormalization]; ok && v != "" { - clientDatelineNormalization = v == "true" - } - gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ - fingerprintUnification: fp, - metadataPassthrough: mp, - cchSigning: cch, - claudeOAuthSystemPromptInjection: systemPromptInjection, - claudeOAuthSystemPrompt: systemPrompt, - claudeOAuthSystemPromptBlocks: systemPromptBlocks, - anthropicCacheTTL1hInjection: cacheTTL1h, - rewriteMessageCacheControl: rewriteMessageCacheControl, - clientDatelineNormalization: clientDatelineNormalization, - expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), - }) - return gatewayForwardingSettingsResult{ - fp: fp, - mp: mp, - cch: cch, - claudeOAuthSystemPromptInjection: systemPromptInjection, - claudeOAuthSystemPrompt: systemPrompt, - claudeOAuthSystemPromptBlocks: systemPromptBlocks, - cacheTTL1h: cacheTTL1h, - rewriteMessageCacheControl: rewriteMessageCacheControl, - clientDatelineNormalization: clientDatelineNormalization, - }, nil - }) - if r, ok := val.(gatewayForwardingSettingsResult); ok { - return r - } - return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, clientDatelineNormalization: true} -} - -// GetGatewayForwardingSettings returns cached gateway forwarding settings. -// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path. -// Returns (fingerprintUnification, metadataPassthrough, cchSigning). -func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) { - result := s.getGatewayForwardingSettingsCached(ctx) - return result.fp, result.mp, result.cch -} - -// IsAnthropicCacheTTL1hInjectionEnabled 检查是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl。 -func (s *SettingService) IsAnthropicCacheTTL1hInjectionEnabled(ctx context.Context) bool { - return s.getGatewayForwardingSettingsCached(ctx).cacheTTL1h -} - -// IsRewriteMessageCacheControlEnabled 检查是否启用 messages cache_control 改写。 -func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context) bool { - return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl -} - -// IsClientDatelineNormalizationEnabled 检查是否启用 Anthropic OAuth/SetupToken 请求体 -// 的客户端 dateline 归一化。默认开启。 -func (s *SettingService) IsClientDatelineNormalizationEnabled(ctx context.Context) bool { - return s.getGatewayForwardingSettingsCached(ctx).clientDatelineNormalization -} - -// GetClaudeOAuthSystemPromptInjectionSettings returns the Claude OAuth mimic -// system block switch, legacy custom expansion prompt, and configurable blocks JSON. -// Empty values mean use the built-in Claude Code default blocks. -func (s *SettingService) GetClaudeOAuthSystemPromptInjectionSettings(ctx context.Context) (enabled bool, prompt string, blocks string) { - result := s.getGatewayForwardingSettingsCached(ctx) - return result.claudeOAuthSystemPromptInjection, result.claudeOAuthSystemPrompt, result.claudeOAuthSystemPromptBlocks -} - -// IsEmailVerifyEnabled 检查是否开启邮件验证 -func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled) - if err != nil { - return false - } - return value == "true" -} - -// GetRegistrationEmailSuffixWhitelist returns normalized registration email suffix whitelist. -func (s *SettingService) GetRegistrationEmailSuffixWhitelist(ctx context.Context) []string { - value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailSuffixWhitelist) - if err != nil { - return []string{} - } - return ParseRegistrationEmailSuffixWhitelist(value) -} - -// IsPromoCodeEnabled 检查是否启用优惠码功能 -func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled) - if err != nil { - return true // 默认启用 - } - return value != "false" -} - -// IsInvitationCodeEnabled 检查是否启用邀请码注册功能 -func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled) - if err != nil { - return false // 默认关闭 - } - return value == "true" -} - -// GetCustomMenuItemsRaw returns the raw JSON string of custom_menu_items setting. -func (s *SettingService) GetCustomMenuItemsRaw(ctx context.Context) string { - value, err := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems) - if err != nil { - return "[]" - } - return value -} - -// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关) -func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled) - if err != nil { - return false // 默认关闭 - } - return value == "true" -} - -// GetAffiliateRebateRatePercent 读取并 clamp 全局返利比例。 -// 解析失败、缺失或越界都回退到 AffiliateRebateRateDefault — 该比例从不抛错, -// 调用方只关心一个可用的数值。 -func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) float64 { - raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateRate) - if err != nil { - return AffiliateRebateRateDefault - } - rate, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) - if err != nil || math.IsNaN(rate) || math.IsInf(rate, 0) { - return AffiliateRebateRateDefault - } - return clampAffiliateRebateRate(rate) -} - -// GetAffiliateRebateFreezeHours 返回返利冻结期(小时)。 -// 返回 0 表示不冻结(向后兼容)。 -func (s *SettingService) GetAffiliateRebateFreezeHours(ctx context.Context) int { - raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateFreezeHours) - if err != nil { - return AffiliateRebateFreezeHoursDefault - } - hours, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil || hours < 0 { - return AffiliateRebateFreezeHoursDefault - } - if hours > AffiliateRebateFreezeHoursMax { - return AffiliateRebateFreezeHoursMax - } - return hours -} - -// GetAffiliateRebateDurationDays 返回返利有效期(天)。 -// 返回 0 表示永久有效。 -func (s *SettingService) GetAffiliateRebateDurationDays(ctx context.Context) int { - raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateDurationDays) - if err != nil { - return AffiliateRebateDurationDaysDefault - } - days, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil || days < 0 { - return AffiliateRebateDurationDaysDefault - } - if days > AffiliateRebateDurationDaysMax { - return AffiliateRebateDurationDaysMax - } - return days -} - -// GetAffiliateRebatePerInviteeCap 返回单人返利上限。 -// 返回 0 表示无上限。 -func (s *SettingService) GetAffiliateRebatePerInviteeCap(ctx context.Context) float64 { - raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebatePerInviteeCap) - if err != nil { - return AffiliateRebatePerInviteeCapDefault - } - cap, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) - if err != nil || cap < 0 || math.IsNaN(cap) || math.IsInf(cap, 0) { - return AffiliateRebatePerInviteeCapDefault - } - return cap -} - -// IsPasswordResetEnabled 检查是否启用密码重置功能 -// 要求:必须同时开启邮件验证 -func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool { - // Password reset requires email verification to be enabled - if !s.IsEmailVerifyEnabled(ctx) { - return false - } - value, err := s.settingRepo.GetValue(ctx, SettingKeyPasswordResetEnabled) - if err != nil { - return false // 默认关闭 - } - return value == "true" -} - -// IsTotpEnabled 检查是否启用 TOTP 双因素认证功能 -func (s *SettingService) IsTotpEnabled(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyTotpEnabled) - if err != nil { - return false // 默认关闭 - } - return value == "true" -} - -// IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置 -// 只有手动配置了密钥才允许在管理后台启用 TOTP 功能 -func (s *SettingService) IsTotpEncryptionKeyConfigured() bool { - return s.cfg.Totp.EncryptionKeyConfigured -} - -// GetSiteName 获取网站名称 -func (s *SettingService) GetSiteName(ctx context.Context) string { - value, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) - if err != nil || value == "" { - return "Sub2API" - } - return value -} - -// GetDefaultConcurrency 获取默认并发量 -func (s *SettingService) GetDefaultConcurrency(ctx context.Context) int { - value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultConcurrency) - if err != nil { - return s.cfg.Default.UserConcurrency - } - if v, err := strconv.Atoi(value); err == nil && v > 0 { - return v - } - return s.cfg.Default.UserConcurrency -} - -// GetDefaultBalance 获取默认余额 -func (s *SettingService) GetDefaultBalance(ctx context.Context) float64 { - value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultBalance) - if err != nil { - return s.cfg.Default.UserBalance - } - if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 { - return v - } - return s.cfg.Default.UserBalance -} - -// GetDefaultUserRPMLimit 获取新用户默认 RPM 限制(0 = 不限制)。未配置则返回 0。 -func (s *SettingService) GetDefaultUserRPMLimit(ctx context.Context) int { - value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultUserRPMLimit) - if err != nil || value == "" { - return 0 - } - if v, err := strconv.Atoi(value); err == nil && v >= 0 { - return v - } - return 0 -} - -// GetDefaultSubscriptions 获取新用户默认订阅配置列表。 -func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultSubscriptionSetting { - value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultSubscriptions) - if err != nil { - return nil - } - return parseDefaultSubscriptions(value) -} - -func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*AuthSourceDefaultSettings, error) { - keys := []string{ - SettingKeyAuthSourceDefaultEmailBalance, - SettingKeyAuthSourceDefaultEmailConcurrency, - SettingKeyAuthSourceDefaultEmailSubscriptions, - SettingKeyAuthSourceDefaultEmailGrantOnSignup, - SettingKeyAuthSourceDefaultEmailGrantOnFirstBind, - SettingKeyAuthSourceDefaultLinuxDoBalance, - SettingKeyAuthSourceDefaultLinuxDoConcurrency, - SettingKeyAuthSourceDefaultLinuxDoSubscriptions, - SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup, - SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind, - SettingKeyAuthSourceDefaultOIDCBalance, - SettingKeyAuthSourceDefaultOIDCConcurrency, - SettingKeyAuthSourceDefaultOIDCSubscriptions, - SettingKeyAuthSourceDefaultOIDCGrantOnSignup, - SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind, - SettingKeyAuthSourceDefaultWeChatBalance, - SettingKeyAuthSourceDefaultWeChatConcurrency, - SettingKeyAuthSourceDefaultWeChatSubscriptions, - SettingKeyAuthSourceDefaultWeChatGrantOnSignup, - SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind, - SettingKeyAuthSourceDefaultGitHubBalance, - SettingKeyAuthSourceDefaultGitHubConcurrency, - SettingKeyAuthSourceDefaultGitHubSubscriptions, - SettingKeyAuthSourceDefaultGitHubGrantOnSignup, - SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind, - SettingKeyAuthSourceDefaultGoogleBalance, - SettingKeyAuthSourceDefaultGoogleConcurrency, - SettingKeyAuthSourceDefaultGoogleSubscriptions, - SettingKeyAuthSourceDefaultGoogleGrantOnSignup, - SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind, - SettingKeyAuthSourceDefaultDingTalkBalance, - SettingKeyAuthSourceDefaultDingTalkConcurrency, - SettingKeyAuthSourceDefaultDingTalkSubscriptions, - SettingKeyAuthSourceDefaultDingTalkGrantOnSignup, - SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind, - SettingKeyAuthSourcePlatformQuotas("email"), - SettingKeyAuthSourcePlatformQuotas("linuxdo"), - SettingKeyAuthSourcePlatformQuotas("oidc"), - SettingKeyAuthSourcePlatformQuotas("wechat"), - SettingKeyAuthSourcePlatformQuotas("github"), - SettingKeyAuthSourcePlatformQuotas("google"), - SettingKeyAuthSourcePlatformQuotas("dingtalk"), - SettingKeyForceEmailOnThirdPartySignup, - } - - settings, err := s.settingRepo.GetMultiple(ctx, keys) - if err != nil { - return nil, fmt.Errorf("get auth source default settings: %w", err) - } - - return &AuthSourceDefaultSettings{ - Email: parseProviderDefaultGrantSettings(settings, emailAuthSourceDefaultKeys), - LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys), - OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys), - WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys), - GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys), - Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys), - DingTalk: parseProviderDefaultGrantSettings(settings, dingTalkAuthSourceDefaultKeys), - ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", - }, nil -} - -func (s *SettingService) ResolveAuthSourceGrantSettings(ctx context.Context, signupSource string, firstBind bool) (ProviderDefaultGrantSettings, bool, error) { - result := ProviderDefaultGrantSettings{ - Balance: s.GetDefaultBalance(ctx), - Concurrency: s.GetDefaultConcurrency(ctx), - Subscriptions: s.GetDefaultSubscriptions(ctx), - } - - defaults, err := s.GetAuthSourceDefaultSettings(ctx) - if err != nil { - return result, false, err - } - - providerDefaults, ok := authSourceSignupSettings(defaults, signupSource) - if !ok { - return result, false, nil - } - - enabled := providerDefaults.GrantOnSignup - if firstBind { - enabled = providerDefaults.GrantOnFirstBind - } - if !enabled { - return result, false, nil - } - - return mergeProviderDefaultGrantSettings(result, providerDefaults), true, nil -} - -func (s *SettingService) UpdateAuthSourceDefaultSettings(ctx context.Context, settings *AuthSourceDefaultSettings) error { - updates, err := s.buildAuthSourceDefaultUpdates(ctx, settings) - if err != nil { - return err - } - if len(updates) == 0 { - return nil - } - - if err := s.settingRepo.SetMultiple(ctx, updates); err != nil { - return fmt.Errorf("update auth source default settings: %w", err) - } - return nil -} - -// InitializeDefaultSettings 初始化默认设置 -func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { - // 检查是否已有设置 - _, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled) - if err == nil { - // 已有设置,不需要初始化 - return nil - } - if !errors.Is(err, ErrSettingNotFound) { - return fmt.Errorf("check existing settings: %w", err) - } - - oidcUsePKCEDefault := true - oidcValidateIDTokenDefault := true - if s != nil && s.cfg != nil { - if s.cfg.OIDC.UsePKCEExplicit { - oidcUsePKCEDefault = s.cfg.OIDC.UsePKCE - } - if s.cfg.OIDC.ValidateIDTokenExplicit { - oidcValidateIDTokenDefault = s.cfg.OIDC.ValidateIDToken - } - } - loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(defaultLoginAgreementDocuments()) - if err != nil { - return err - } - - // 初始化默认设置 - defaults := map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyEmailVerifyEnabled: "false", - SettingKeyRegistrationEmailSuffixWhitelist: "[]", - SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 - SettingKeyLoginAgreementEnabled: "false", - SettingKeyLoginAgreementMode: defaultLoginAgreementMode, - SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate, - SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON, - SettingKeyAPIKeyACLTrustForwardedIP: "false", - SettingKeySiteName: "Sub2API", - SettingKeySiteLogo: "", - SettingKeyPurchaseSubscriptionEnabled: "false", - SettingKeyPurchaseSubscriptionURL: "", - SettingKeyTableDefaultPageSize: "20", - SettingKeyTablePageSizeOptions: "[10,20,50,100]", - SettingKeyCustomMenuItems: "[]", - SettingKeyCustomEndpoints: "[]", - SettingKeyWeChatConnectEnabled: "false", - SettingKeyWeChatConnectAppID: "", - SettingKeyWeChatConnectAppSecret: "", - SettingKeyWeChatConnectOpenAppID: "", - SettingKeyWeChatConnectOpenAppSecret: "", - SettingKeyWeChatConnectMPAppID: "", - SettingKeyWeChatConnectMPAppSecret: "", - SettingKeyWeChatConnectMobileAppID: "", - SettingKeyWeChatConnectMobileAppSecret: "", - SettingKeyWeChatConnectOpenEnabled: "false", - SettingKeyWeChatConnectMPEnabled: "false", - SettingKeyWeChatConnectMobileEnabled: "false", - SettingKeyWeChatConnectMode: "open", - SettingKeyWeChatConnectScopes: "snsapi_login", - SettingKeyWeChatConnectRedirectURL: "", - SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend, - SettingKeyGitHubOAuthEnabled: "false", - SettingKeyGitHubOAuthClientID: "", - SettingKeyGitHubOAuthClientSecret: "", - SettingKeyGitHubOAuthRedirectURL: "", - SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend, - SettingKeyGoogleOAuthEnabled: "false", - SettingKeyGoogleOAuthClientID: "", - SettingKeyGoogleOAuthClientSecret: "", - SettingKeyGoogleOAuthRedirectURL: "", - SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend, - SettingKeyOIDCConnectEnabled: "false", - SettingKeyOIDCConnectProviderName: "OIDC", - SettingKeyOIDCConnectClientID: "", - SettingKeyOIDCConnectClientSecret: "", - SettingKeyOIDCConnectIssuerURL: "", - SettingKeyOIDCConnectDiscoveryURL: "", - SettingKeyOIDCConnectAuthorizeURL: "", - SettingKeyOIDCConnectTokenURL: "", - SettingKeyOIDCConnectUserInfoURL: "", - SettingKeyOIDCConnectJWKSURL: "", - SettingKeyOIDCConnectScopes: "openid email profile", - SettingKeyOIDCConnectRedirectURL: "", - SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback", - SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post", - SettingKeyOIDCConnectUsePKCE: strconv.FormatBool(oidcUsePKCEDefault), - SettingKeyOIDCConnectValidateIDToken: strconv.FormatBool(oidcValidateIDTokenDefault), - SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256", - SettingKeyOIDCConnectClockSkewSeconds: "120", - SettingKeyOIDCConnectRequireEmailVerified: "false", - SettingKeyOIDCConnectUserInfoEmailPath: "", - SettingKeyOIDCConnectUserInfoIDPath: "", - SettingKeyOIDCConnectUserInfoUsernamePath: "", - SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), - SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), - SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64), - SettingKeyAffiliateRebateFreezeHours: strconv.Itoa(AffiliateRebateFreezeHoursDefault), - SettingKeyAffiliateRebateDurationDays: strconv.Itoa(AffiliateRebateDurationDaysDefault), - SettingKeyAffiliateRebatePerInviteeCap: strconv.FormatFloat(AffiliateRebatePerInviteeCapDefault, 'f', 2, 64), - SettingKeyDefaultUserRPMLimit: "0", - SettingKeyDefaultSubscriptions: "[]", - SettingKeyAuthSourceDefaultEmailBalance: "0", - SettingKeyAuthSourceDefaultEmailConcurrency: "5", - SettingKeyAuthSourceDefaultEmailSubscriptions: "[]", - SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false", - SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultLinuxDoBalance: "0", - SettingKeyAuthSourceDefaultLinuxDoConcurrency: "5", - SettingKeyAuthSourceDefaultLinuxDoSubscriptions: "[]", - SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "false", - SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultOIDCBalance: "0", - SettingKeyAuthSourceDefaultOIDCConcurrency: "5", - SettingKeyAuthSourceDefaultOIDCSubscriptions: "[]", - SettingKeyAuthSourceDefaultOIDCGrantOnSignup: "false", - SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultWeChatBalance: "0", - SettingKeyAuthSourceDefaultWeChatConcurrency: "5", - SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]", - SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false", - SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultGitHubBalance: "0", - SettingKeyAuthSourceDefaultGitHubConcurrency: "5", - SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]", - SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false", - SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultGoogleBalance: "0", - SettingKeyAuthSourceDefaultGoogleConcurrency: "5", - SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]", - SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false", - SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultDingTalkBalance: "0", - SettingKeyAuthSourceDefaultDingTalkConcurrency: "5", - SettingKeyAuthSourceDefaultDingTalkSubscriptions: "[]", - SettingKeyAuthSourceDefaultDingTalkGrantOnSignup: "false", - SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind: "false", - SettingKeyForceEmailOnThirdPartySignup: "false", - SettingKeySMTPPort: "587", - SettingKeySMTPUseTLS: "false", - // Model fallback defaults - SettingKeyEnableModelFallback: "false", - SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022", - SettingKeyFallbackModelOpenAI: "gpt-4o", - SettingKeyFallbackModelGemini: "gemini-2.5-pro", - SettingKeyFallbackModelAntigravity: "gemini-2.5-pro", - // Identity patch defaults - SettingKeyEnableIdentityPatch: "true", - SettingKeyIdentityPatchPrompt: "", - - // Ops monitoring defaults (vNext) - SettingKeyOpsMonitoringEnabled: "true", - SettingKeyOpsRealtimeMonitoringEnabled: "true", - SettingKeyOpsQueryModeDefault: "auto", - SettingKeyOpsMetricsIntervalSeconds: "60", - - // Channel monitor defaults (enabled, 60s) - SettingKeyChannelMonitorEnabled: "true", - SettingKeyChannelMonitorDefaultIntervalSeconds: "60", - - // Available channels feature (default disabled; opt-in) - SettingKeyAvailableChannelsEnabled: "false", - - // Affiliate (邀请返利) feature (default disabled; opt-in) - SettingKeyAffiliateEnabled: "false", - - // 风控中心功能(默认关闭,显式启用) - SettingKeyRiskControlEnabled: "false", - - // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) - SettingKeyCyberSessionBlockEnabled: "false", - SettingKeyCyberSessionBlockTTLSeconds: "3600", - - // Claude Code version check (default: empty = disabled) - SettingKeyMinClaudeCodeVersion: "", - SettingKeyMaxClaudeCodeVersion: "", - - // codex_cli_only 加固(默认:版本不检查、名单空、默认种子指纹信号) - SettingKeyMinCodexVersion: "", - SettingKeyMaxCodexVersion: "", - SettingKeyCodexCLIOnlyBlacklist: "", - SettingKeyCodexCLIOnlyWhitelist: "", - SettingKeyCodexCLIOnlyAllowAppServerClients: "false", - SettingKeyCodexCLIOnlyEngineFingerprintSignals: openai.DefaultEngineFingerprintSignalsJSON(), - - // 分组隔离(默认不允许未分组 Key 调度) - SettingKeyAllowUngroupedKeyScheduling: "false", - SettingKeyEnableAnthropicCacheTTL1hInjection: "false", - SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()), - SettingKeyEnableClientDatelineNormalization: "true", - SettingKeyAntigravityUserAgentVersion: "", - SettingKeyOpenAICodexUserAgent: "", - SettingPaymentVisibleMethodAlipaySource: "", - SettingPaymentVisibleMethodWxpaySource: "", - SettingPaymentVisibleMethodAlipayEnabled: "false", - SettingPaymentVisibleMethodWxpayEnabled: "false", - openAIAdvancedSchedulerSettingKey: "false", - SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled: "false", - SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled: "false", - SettingKeyOpenAIAdvancedSchedulerLBTopK: "", - SettingKeyOpenAIAdvancedSchedulerWeightPriority: "", - SettingKeyOpenAIAdvancedSchedulerWeightLoad: "", - SettingKeyOpenAIAdvancedSchedulerWeightQueue: "", - SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: "", - SettingKeyOpenAIAdvancedSchedulerWeightTTFT: "", - SettingKeyOpenAIAdvancedSchedulerWeightReset: "", - SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: "", - SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: "", - SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: "", - - SettingKeyAllowUserViewErrorRequests: "false", - } - - return s.settingRepo.SetMultiple(ctx, defaults) -} - -// parseSettings 解析设置到结构体 -func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings { - emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" - loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments]) - loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt]) - if loginAgreementUpdatedAt == "" { - loginAgreementUpdatedAt = defaultLoginAgreementDate - } - apiKeyACLTrustForwardedIP := false - if value, ok := settings[SettingKeyAPIKeyACLTrustForwardedIP]; ok { - apiKeyACLTrustForwardedIP = value == "true" - } else if s != nil && s.cfg != nil { - apiKeyACLTrustForwardedIP = s.cfg.Security.TrustForwardedIPForAPIKeyACL - } - result := &SystemSettings{ - RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", - EmailVerifyEnabled: emailVerifyEnabled, - RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]), - PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 - PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", - FrontendURL: settings[SettingKeyFrontendURL], - InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", - TotpEnabled: settings[SettingKeyTotpEnabled] == "true", - LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true", - LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), - LoginAgreementUpdatedAt: loginAgreementUpdatedAt, - LoginAgreementDocuments: loginAgreementDocuments, - SMTPHost: settings[SettingKeySMTPHost], - SMTPUsername: settings[SettingKeySMTPUsername], - SMTPFrom: settings[SettingKeySMTPFrom], - SMTPFromName: settings[SettingKeySMTPFromName], - SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true", - SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "", - TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", - TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], - TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "", - APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP, - SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), - SiteLogo: settings[SettingKeySiteLogo], - SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), - APIBaseURL: settings[SettingKeyAPIBaseURL], - ContactInfo: settings[SettingKeyContactInfo], - DocURL: settings[SettingKeyDocURL], - HomeContent: settings[SettingKeyHomeContent], - HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", - PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", - PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), - CustomMenuItems: settings[SettingKeyCustomMenuItems], - CustomEndpoints: settings[SettingKeyCustomEndpoints], - BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", - } - result.TableDefaultPageSize, result.TablePageSizeOptions = parseTablePreferences( - settings[SettingKeyTableDefaultPageSize], - settings[SettingKeyTablePageSizeOptions], - ) - - // 解析整数类型 - if port, err := strconv.Atoi(settings[SettingKeySMTPPort]); err == nil { - result.SMTPPort = port - } else { - result.SMTPPort = 587 - } - - if concurrency, err := strconv.Atoi(settings[SettingKeyDefaultConcurrency]); err == nil { - result.DefaultConcurrency = concurrency - } else { - result.DefaultConcurrency = s.cfg.Default.UserConcurrency - } - - if rpm, err := strconv.Atoi(settings[SettingKeyDefaultUserRPMLimit]); err == nil && rpm >= 0 { - result.DefaultUserRPMLimit = rpm - } - - // 解析浮点数类型 - if balance, err := strconv.ParseFloat(settings[SettingKeyDefaultBalance], 64); err == nil { - result.DefaultBalance = balance - } else { - result.DefaultBalance = s.cfg.Default.UserBalance - } - if rebateRate, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebateRate], 64); err == nil { - result.AffiliateRebateRate = clampAffiliateRebateRate(rebateRate) - } else { - result.AffiliateRebateRate = AffiliateRebateRateDefault - } - if freezeHours, err := strconv.Atoi(settings[SettingKeyAffiliateRebateFreezeHours]); err == nil && freezeHours >= 0 { - if freezeHours > AffiliateRebateFreezeHoursMax { - freezeHours = AffiliateRebateFreezeHoursMax - } - result.AffiliateRebateFreezeHours = freezeHours - } - if durationDays, err := strconv.Atoi(settings[SettingKeyAffiliateRebateDurationDays]); err == nil && durationDays >= 0 { - if durationDays > AffiliateRebateDurationDaysMax { - durationDays = AffiliateRebateDurationDaysMax - } - result.AffiliateRebateDurationDays = durationDays - } - if perInviteeCap, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebatePerInviteeCap], 64); err == nil && perInviteeCap >= 0 { - result.AffiliateRebatePerInviteeCap = perInviteeCap - } - result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions]) - - // 敏感信息直接返回,方便测试连接时使用 - result.SMTPPassword = settings[SettingKeySMTPPassword] - result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey] - - // LinuxDo Connect 设置: - // - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭) - // - 支持在后台“系统设置”中覆盖并持久化(存储于 DB) - linuxDoBase := config.LinuxDoConnectConfig{} - if s.cfg != nil { - linuxDoBase = s.cfg.LinuxDo - } - - if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { - result.LinuxDoConnectEnabled = raw == "true" - } else { - result.LinuxDoConnectEnabled = linuxDoBase.Enabled - } - - if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" { - result.LinuxDoConnectClientID = strings.TrimSpace(v) - } else { - result.LinuxDoConnectClientID = linuxDoBase.ClientID - } - - if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { - result.LinuxDoConnectRedirectURL = strings.TrimSpace(v) - } else { - result.LinuxDoConnectRedirectURL = linuxDoBase.RedirectURL - } - - result.LinuxDoConnectClientSecret = strings.TrimSpace(settings[SettingKeyLinuxDoConnectClientSecret]) - if result.LinuxDoConnectClientSecret == "" { - result.LinuxDoConnectClientSecret = strings.TrimSpace(linuxDoBase.ClientSecret) - } - result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != "" - - // DingTalk Connect 设置: - // - 兼容 config.yaml/env - // - 支持后台系统设置覆盖并持久化(存储于 DB) - dingTalkBase := config.DingTalkConnectConfig{} - if s.cfg != nil { - dingTalkBase = s.cfg.DingTalk - } - - if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { - result.DingTalkConnectEnabled = raw == "true" - } else { - result.DingTalkConnectEnabled = dingTalkBase.Enabled - } - - if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectClientID = strings.TrimSpace(v) - } else { - result.DingTalkConnectClientID = dingTalkBase.ClientID - } - - if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectRedirectURL = strings.TrimSpace(v) - } else { - result.DingTalkConnectRedirectURL = dingTalkBase.RedirectURL - } - - result.DingTalkConnectClientSecret = strings.TrimSpace(settings[SettingKeyDingTalkConnectClientSecret]) - if result.DingTalkConnectClientSecret == "" { - result.DingTalkConnectClientSecret = strings.TrimSpace(dingTalkBase.ClientSecret) - } - result.DingTalkConnectClientSecretConfigured = result.DingTalkConnectClientSecret != "" - - if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectCorpRestrictionPolicy = strings.TrimSpace(v) - } else { - result.DingTalkConnectCorpRestrictionPolicy = dingTalkBase.CorpRestrictionPolicy - } - result.DingTalkConnectCorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(result.DingTalkConnectCorpRestrictionPolicy) - - if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectInternalCorpID = strings.TrimSpace(v) - } else { - result.DingTalkConnectInternalCorpID = dingTalkBase.InternalCorpID - } - - if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectBypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true") - } else { - result.DingTalkConnectBypassRegistration = dingTalkBase.BypassRegistration - } - // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false, - // 以保证加载出的 effective config 永远是一致状态。 - if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" { - result.DingTalkConnectBypassRegistration = false - } - - if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectSyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true") - } else { - result.DingTalkConnectSyncCorpEmail = dingTalkBase.SyncCorpEmail - } - if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectSyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true") - } else { - result.DingTalkConnectSyncDisplayName = dingTalkBase.SyncDisplayName - } - if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" { - result.DingTalkConnectSyncDept = strings.EqualFold(strings.TrimSpace(v), "true") - } else { - result.DingTalkConnectSyncDept = dingTalkBase.SyncDept - } - // 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。 - if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" { - result.DingTalkConnectSyncCorpEmail = false - result.DingTalkConnectSyncDisplayName = false - result.DingTalkConnectSyncDept = false - } - - // 身份同步目标 attr key(DB 空 → fallback 默认值) - result.DingTalkConnectSyncCorpEmailAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]) - if result.DingTalkConnectSyncCorpEmailAttrKey == "" { - if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrKey); v != "" { - result.DingTalkConnectSyncCorpEmailAttrKey = v - } else { - result.DingTalkConnectSyncCorpEmailAttrKey = "dingtalk_email" - } - } - result.DingTalkConnectSyncDisplayNameAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]) - if result.DingTalkConnectSyncDisplayNameAttrKey == "" { - if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrKey); v != "" { - result.DingTalkConnectSyncDisplayNameAttrKey = v - } else { - result.DingTalkConnectSyncDisplayNameAttrKey = "dingtalk_name" - } - } - result.DingTalkConnectSyncDeptAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]) - if result.DingTalkConnectSyncDeptAttrKey == "" { - if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrKey); v != "" { - result.DingTalkConnectSyncDeptAttrKey = v - } else { - result.DingTalkConnectSyncDeptAttrKey = "dingtalk_department" - } - } - - // 身份同步目标 attr 显示名称(DB 空 → fallback 默认中文) - result.DingTalkConnectSyncCorpEmailAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrName]) - if result.DingTalkConnectSyncCorpEmailAttrName == "" { - if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrName); v != "" { - result.DingTalkConnectSyncCorpEmailAttrName = v - } else { - result.DingTalkConnectSyncCorpEmailAttrName = "钉钉企业邮箱" - } - } - result.DingTalkConnectSyncDisplayNameAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrName]) - if result.DingTalkConnectSyncDisplayNameAttrName == "" { - if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrName); v != "" { - result.DingTalkConnectSyncDisplayNameAttrName = v - } else { - result.DingTalkConnectSyncDisplayNameAttrName = "钉钉姓名" - } - } - result.DingTalkConnectSyncDeptAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrName]) - if result.DingTalkConnectSyncDeptAttrName == "" { - if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrName); v != "" { - result.DingTalkConnectSyncDeptAttrName = v - } else { - result.DingTalkConnectSyncDeptAttrName = "钉钉部门" - } - } - - // Generic OIDC 设置: - // - 兼容 config.yaml/env - // - 支持后台系统设置覆盖并持久化(存储于 DB) - oidcBase := config.OIDCConnectConfig{} - if s.cfg != nil { - oidcBase = s.cfg.OIDC - } - - if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { - result.OIDCConnectEnabled = raw == "true" - } else { - result.OIDCConnectEnabled = oidcBase.Enabled - } - - if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectProviderName = strings.TrimSpace(v) - } else { - result.OIDCConnectProviderName = strings.TrimSpace(oidcBase.ProviderName) - } - if result.OIDCConnectProviderName == "" { - result.OIDCConnectProviderName = "OIDC" - } - - if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectClientID = strings.TrimSpace(v) - } else { - result.OIDCConnectClientID = strings.TrimSpace(oidcBase.ClientID) - } - if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectIssuerURL = strings.TrimSpace(v) - } else { - result.OIDCConnectIssuerURL = strings.TrimSpace(oidcBase.IssuerURL) - } - if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectDiscoveryURL = strings.TrimSpace(v) - } else { - result.OIDCConnectDiscoveryURL = strings.TrimSpace(oidcBase.DiscoveryURL) - } - if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectAuthorizeURL = strings.TrimSpace(v) - } else { - result.OIDCConnectAuthorizeURL = strings.TrimSpace(oidcBase.AuthorizeURL) - } - if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectTokenURL = strings.TrimSpace(v) - } else { - result.OIDCConnectTokenURL = strings.TrimSpace(oidcBase.TokenURL) - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectUserInfoURL = strings.TrimSpace(v) - } else { - result.OIDCConnectUserInfoURL = strings.TrimSpace(oidcBase.UserInfoURL) - } - if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectJWKSURL = strings.TrimSpace(v) - } else { - result.OIDCConnectJWKSURL = strings.TrimSpace(oidcBase.JWKSURL) - } - if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectScopes = strings.TrimSpace(v) - } else { - result.OIDCConnectScopes = strings.TrimSpace(oidcBase.Scopes) - } - if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectRedirectURL = strings.TrimSpace(v) - } else { - result.OIDCConnectRedirectURL = strings.TrimSpace(oidcBase.RedirectURL) - } - if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(v) - } else { - result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(oidcBase.FrontendRedirectURL) - } - if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(v)) - } else { - result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(oidcBase.TokenAuthMethod)) - } - if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok { - result.OIDCConnectUsePKCE = raw == "true" - } else { - result.OIDCConnectUsePKCE = oidcUsePKCECompatibilityDefault(oidcBase) - } - if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok { - result.OIDCConnectValidateIDToken = raw == "true" - } else { - result.OIDCConnectValidateIDToken = oidcValidateIDTokenCompatibilityDefault(oidcBase) - } - if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" { - result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(v) - } else { - result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(oidcBase.AllowedSigningAlgs) - } - clockSkewSet := false - if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" { - if parsed, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { - result.OIDCConnectClockSkewSeconds = parsed - clockSkewSet = true - } - } - if !clockSkewSet { - result.OIDCConnectClockSkewSeconds = oidcBase.ClockSkewSeconds - } - if !clockSkewSet && result.OIDCConnectClockSkewSeconds == 0 { - result.OIDCConnectClockSkewSeconds = 120 - } - if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok { - result.OIDCConnectRequireEmailVerified = raw == "true" - } else { - result.OIDCConnectRequireEmailVerified = oidcBase.RequireEmailVerified - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok { - result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(v) - } else { - result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(oidcBase.UserInfoEmailPath) - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok { - result.OIDCConnectUserInfoIDPath = strings.TrimSpace(v) - } else { - result.OIDCConnectUserInfoIDPath = strings.TrimSpace(oidcBase.UserInfoIDPath) - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok { - result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(v) - } else { - result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(oidcBase.UserInfoUsernamePath) - } - result.OIDCConnectClientSecret = strings.TrimSpace(settings[SettingKeyOIDCConnectClientSecret]) - if result.OIDCConnectClientSecret == "" { - result.OIDCConnectClientSecret = strings.TrimSpace(oidcBase.ClientSecret) - } - result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != "" - - gitHubEffective := s.effectiveEmailOAuthConfig(settings, "github") - result.GitHubOAuthEnabled = gitHubEffective.Enabled - result.GitHubOAuthClientID = strings.TrimSpace(gitHubEffective.ClientID) - result.GitHubOAuthClientSecret = strings.TrimSpace(gitHubEffective.ClientSecret) - result.GitHubOAuthClientSecretConfigured = result.GitHubOAuthClientSecret != "" - result.GitHubOAuthRedirectURL = strings.TrimSpace(gitHubEffective.RedirectURL) - result.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(gitHubEffective.FrontendRedirectURL) - - googleEffective := s.effectiveEmailOAuthConfig(settings, "google") - result.GoogleOAuthEnabled = googleEffective.Enabled - result.GoogleOAuthClientID = strings.TrimSpace(googleEffective.ClientID) - result.GoogleOAuthClientSecret = strings.TrimSpace(googleEffective.ClientSecret) - result.GoogleOAuthClientSecretConfigured = result.GoogleOAuthClientSecret != "" - result.GoogleOAuthRedirectURL = strings.TrimSpace(googleEffective.RedirectURL) - result.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(googleEffective.FrontendRedirectURL) - - // WeChat Connect 设置: - // - 优先读取 DB 系统设置 - // - 缺失时回退到 config/env,保持升级兼容 - weChatEffective := s.effectiveWeChatConnectOAuthConfig(settings) - result.WeChatConnectEnabled = weChatEffective.Enabled - result.WeChatConnectAppID = weChatEffective.LegacyAppID - result.WeChatConnectAppSecret = weChatEffective.LegacyAppSecret - result.WeChatConnectAppSecretConfigured = weChatEffective.LegacyAppSecret != "" - result.WeChatConnectOpenAppID = weChatEffective.OpenAppID - result.WeChatConnectOpenAppSecret = weChatEffective.OpenAppSecret - result.WeChatConnectOpenAppSecretConfigured = weChatEffective.OpenAppSecret != "" - result.WeChatConnectMPAppID = weChatEffective.MPAppID - result.WeChatConnectMPAppSecret = weChatEffective.MPAppSecret - result.WeChatConnectMPAppSecretConfigured = weChatEffective.MPAppSecret != "" - result.WeChatConnectMobileAppID = weChatEffective.MobileAppID - result.WeChatConnectMobileAppSecret = weChatEffective.MobileAppSecret - result.WeChatConnectMobileAppSecretConfigured = weChatEffective.MobileAppSecret != "" - result.WeChatConnectOpenEnabled = weChatEffective.OpenEnabled - result.WeChatConnectMPEnabled = weChatEffective.MPEnabled - result.WeChatConnectMobileEnabled = weChatEffective.MobileEnabled - result.WeChatConnectMode = weChatEffective.Mode - result.WeChatConnectScopes = weChatEffective.Scopes - result.WeChatConnectRedirectURL = weChatEffective.RedirectURL - result.WeChatConnectFrontendRedirectURL = weChatEffective.FrontendRedirectURL - - // Model fallback settings - result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true" - result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022") - result.FallbackModelOpenAI = s.getStringOrDefault(settings, SettingKeyFallbackModelOpenAI, "gpt-4o") - result.FallbackModelGemini = s.getStringOrDefault(settings, SettingKeyFallbackModelGemini, "gemini-2.5-pro") - result.FallbackModelAntigravity = s.getStringOrDefault(settings, SettingKeyFallbackModelAntigravity, "gemini-2.5-pro") - - // Identity patch settings (default: enabled, to preserve existing behavior) - if v, ok := settings[SettingKeyEnableIdentityPatch]; ok && v != "" { - result.EnableIdentityPatch = v == "true" - } else { - result.EnableIdentityPatch = true - } - result.IdentityPatchPrompt = settings[SettingKeyIdentityPatchPrompt] - - // Ops monitoring settings (default: enabled, fail-open) - result.OpsMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsMonitoringEnabled]) - result.OpsRealtimeMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsRealtimeMonitoringEnabled]) - result.OpsQueryModeDefault = string(ParseOpsQueryMode(settings[SettingKeyOpsQueryModeDefault])) - result.OpsMetricsIntervalSeconds = 60 - if raw := strings.TrimSpace(settings[SettingKeyOpsMetricsIntervalSeconds]); raw != "" { - if v, err := strconv.Atoi(raw); err == nil { - if v < 60 { - v = 60 - } - if v > 3600 { - v = 3600 - } - result.OpsMetricsIntervalSeconds = v - } - } - - // Channel monitor feature (default: enabled, 60s) - result.ChannelMonitorEnabled = !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]) - result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval( - settings[SettingKeyChannelMonitorDefaultIntervalSeconds], - ) - - // Available channels feature (default: disabled; strict true) - result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true" - - // Affiliate (邀请返利) feature (default: disabled; strict true) - result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true" - - // 风控中心功能(默认关闭,严格 true 才启用) - result.RiskControlEnabled = settings[SettingKeyRiskControlEnabled] == "true" - - // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) - result.CyberSessionBlockEnabled = settings[SettingKeyCyberSessionBlockEnabled] == "true" - if v, err := strconv.Atoi(strings.TrimSpace(settings[SettingKeyCyberSessionBlockTTLSeconds])); err == nil && v > 0 { - result.CyberSessionBlockTTLSeconds = v - } else { - result.CyberSessionBlockTTLSeconds = 3600 - } - - // Claude Code version check - result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion] - result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion] - - // 分组隔离 - result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true" - - // Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false, - // cch_signing=false, claude_oauth_system_prompt_injection=true) - if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" { - result.EnableFingerprintUnification = v == "true" - } else { - result.EnableFingerprintUnification = true // default: enabled (current behavior) - } - result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true" - result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true" - if v, ok := settings[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" { - result.EnableClaudeOAuthSystemPromptInjection = v == "true" - } else { - result.EnableClaudeOAuthSystemPromptInjection = true - } - result.ClaudeOAuthSystemPrompt = settings[SettingKeyClaudeOAuthSystemPrompt] - result.ClaudeOAuthSystemPromptBlocks = settings[SettingKeyClaudeOAuthSystemPromptBlocks] - result.EnableAnthropicCacheTTL1hInjection = settings[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" - if v, ok := settings[SettingKeyRewriteMessageCacheControl]; ok && v != "" { - result.RewriteMessageCacheControl = v == "true" - } else { - result.RewriteMessageCacheControl = s.defaultRewriteMessageCacheControl() - } - if v, ok := settings[SettingKeyEnableClientDatelineNormalization]; ok && v != "" { - result.EnableClientDatelineNormalization = v == "true" - } else { - result.EnableClientDatelineNormalization = true - } - result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion]) - result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent]) - // codex_cli_only 加固 - result.MinCodexVersion = settings[SettingKeyMinCodexVersion] - result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion] - result.CodexCLIOnlyBlacklist = settings[SettingKeyCodexCLIOnlyBlacklist] - result.CodexCLIOnlyWhitelist = settings[SettingKeyCodexCLIOnlyWhitelist] - result.CodexCLIOnlyAllowAppServerClients = settings[SettingKeyCodexCLIOnlyAllowAppServerClients] == "true" - if raw := strings.TrimSpace(settings[SettingKeyCodexCLIOnlyEngineFingerprintSignals]); raw != "" { - result.CodexCLIOnlyEngineFingerprintSignals = raw - } else { - result.CodexCLIOnlyEngineFingerprintSignals = openai.DefaultEngineFingerprintSignalsJSON() // 缺失/空 → 展示默认种子 - } - - // Web search emulation: quick enabled check from the JSON config - if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" { - var wsCfg WebSearchEmulationConfig - if err := json.Unmarshal([]byte(raw), &wsCfg); err == nil { - result.WebSearchEmulationEnabled = wsCfg.Enabled && len(wsCfg.Providers) > 0 - } - } - result.PaymentVisibleMethodAlipaySource = NormalizeVisibleMethodSource("alipay", settings[SettingPaymentVisibleMethodAlipaySource]) - result.PaymentVisibleMethodWxpaySource = NormalizeVisibleMethodSource("wxpay", settings[SettingPaymentVisibleMethodWxpaySource]) - result.PaymentVisibleMethodAlipayEnabled = settings[SettingPaymentVisibleMethodAlipayEnabled] == "true" - result.PaymentVisibleMethodWxpayEnabled = settings[SettingPaymentVisibleMethodWxpayEnabled] == "true" - result.OpenAIAdvancedSchedulerEnabled = settings[openAIAdvancedSchedulerSettingKey] == "true" - result.OpenAIAdvancedSchedulerStickyWeightedEnabled = settings[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] == "true" - result.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled = settings[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] == "true" - result.OpenAIAdvancedSchedulerLBTopK = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerLBTopK]) - result.OpenAIAdvancedSchedulerWeightPriority = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightPriority]) - result.OpenAIAdvancedSchedulerWeightLoad = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightLoad]) - result.OpenAIAdvancedSchedulerWeightQueue = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightQueue]) - result.OpenAIAdvancedSchedulerWeightErrorRate = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate]) - result.OpenAIAdvancedSchedulerWeightTTFT = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightTTFT]) - result.OpenAIAdvancedSchedulerWeightReset = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightReset]) - result.OpenAIAdvancedSchedulerWeightQuotaHeadroom = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom]) - result.OpenAIAdvancedSchedulerWeightPreviousResponse = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse]) - result.OpenAIAdvancedSchedulerWeightSessionSticky = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky]) - result.OpenAIAdvancedSchedulerEffectiveLBTopK = s.openAIAdvancedSchedulerEffectiveLBTopK() - effectiveWeights := s.openAIAdvancedSchedulerEffectiveWeights() - result.OpenAIAdvancedSchedulerEffectiveWeightPriority = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Priority) - result.OpenAIAdvancedSchedulerEffectiveWeightLoad = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Load) - result.OpenAIAdvancedSchedulerEffectiveWeightQueue = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Queue) - result.OpenAIAdvancedSchedulerEffectiveWeightErrorRate = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.ErrorRate) - result.OpenAIAdvancedSchedulerEffectiveWeightTTFT = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.TTFT) - result.OpenAIAdvancedSchedulerEffectiveWeightReset = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Reset) - result.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.QuotaHeadroom) - result.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.PreviousResponse) - result.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.SessionSticky) - - // 余额、订阅到期与账号限额通知 - result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true" - if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 { - result.BalanceLowNotifyThreshold = v - } - result.BalanceLowNotifyRechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL] - result.SubscriptionExpiryNotifyEnabled = !isFalseSettingValue(settings[SettingKeySubscriptionExpiryNotifyEnabled]) - - // 账号限额通知 - result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true" - if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" { - result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw) - } - if result.AccountQuotaNotifyEmails == nil { - result.AccountQuotaNotifyEmails = []NotifyEmailEntry{} - } - - // 系统层默认 platform quota(修复 Bug B:parseSettings 不填充导致回显恒为 nil) - if raw := settings[SettingKeyDefaultPlatformQuotas]; raw != "" { - parsed := map[string]*DefaultPlatformQuotaSetting{} - if err := json.Unmarshal([]byte(raw), &parsed); err != nil { - slog.Warn("[Setting] parseSettings: unmarshal default_platform_quotas failed", "error", err) - } else { - result.DefaultPlatformQuotas = parsed - } - } - - result.AllowUserViewErrorRequests = settings[SettingKeyAllowUserViewErrorRequests] == "true" // default false - - return result -} - -func clampAffiliateRebateRate(value float64) float64 { - if math.IsNaN(value) || math.IsInf(value, 0) { - return AffiliateRebateRateDefault - } - if value < AffiliateRebateRateMin { - return AffiliateRebateRateMin - } - if value > AffiliateRebateRateMax { - return AffiliateRebateRateMax - } - return value -} - -func isFalseSettingValue(value string) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case "false", "0", "off", "disabled": - return true - default: - return false - } -} - -func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (string, error) { - _ = enabled - source = strings.TrimSpace(source) - if source == "" { - return "", nil - } - - normalized := NormalizeVisibleMethodSource(method, source) - if normalized == "" { - return "", infraerrors.BadRequest( - "INVALID_PAYMENT_VISIBLE_METHOD_SOURCE", - fmt.Sprintf("%s source must be one of the supported payment providers", method), - ) - } - return normalized, nil -} - -func (s *SettingService) openAIAdvancedSchedulerEffectiveLBTopK() string { - if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.LBTopK > 0 { - return strconv.Itoa(s.cfg.Gateway.OpenAIWS.LBTopK) - } - return "7" -} - -func (s *SettingService) openAIAdvancedSchedulerEffectiveWeights() config.GatewayOpenAIWSSchedulerScoreWeights { - defaults := config.GatewayOpenAIWSSchedulerScoreWeights{ - Priority: 1.0, - Load: 1.0, - Queue: 0.7, - ErrorRate: 0.8, - TTFT: 0.5, - Reset: 0.0, - QuotaHeadroom: 0.0, - PreviousResponse: 5.0, - SessionSticky: 3.0, - } - if s == nil || s.cfg == nil { - return defaults - } - - weights := s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights - baseSum := weights.Priority + weights.Load + weights.Queue + weights.ErrorRate + weights.TTFT + weights.QuotaHeadroom - if baseSum <= 0 { - return defaults - } - return weights -} - -func formatOpenAIAdvancedSchedulerFloat(value float64) string { - return strconv.FormatFloat(value, 'f', -1, 64) -} - -func (s *SettingService) normalizeOpenAIAdvancedSchedulerOverrides(settings *SystemSettings) error { - lbTopK, err := normalizeOptionalPositiveIntString(settings.OpenAIAdvancedSchedulerLBTopK) - if err != nil { - return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_LB_TOP_K", "openai advanced scheduler TopK must be a positive integer or empty") - } - settings.OpenAIAdvancedSchedulerLBTopK = lbTopK - - weights := []*string{ - &settings.OpenAIAdvancedSchedulerWeightPriority, - &settings.OpenAIAdvancedSchedulerWeightLoad, - &settings.OpenAIAdvancedSchedulerWeightQueue, - &settings.OpenAIAdvancedSchedulerWeightErrorRate, - &settings.OpenAIAdvancedSchedulerWeightTTFT, - &settings.OpenAIAdvancedSchedulerWeightReset, - &settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, - &settings.OpenAIAdvancedSchedulerWeightPreviousResponse, - &settings.OpenAIAdvancedSchedulerWeightSessionSticky, - } - for _, target := range weights { - normalized, err := normalizeOptionalNonNegativeFloatString(*target) - if err != nil { - return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_WEIGHT", "openai advanced scheduler weights must be non-negative numbers or empty") - } - *target = normalized - } - - // 与 config.Validate 的 "scheduler_score_weights must not all be zero" 保持一致: - // 覆盖值(空则回退到生效的配置值)叠加后的基础权重和不允许为 0, - // 否则调度会静默退化为 TopK 内均匀随机。 - effective := s.openAIAdvancedSchedulerEffectiveWeights() - baseSum := resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightPriority, effective.Priority) + - resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightLoad, effective.Load) + - resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQueue, effective.Queue) + - resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightErrorRate, effective.ErrorRate) + - resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightTTFT, effective.TTFT) + - resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, effective.QuotaHeadroom) - if baseSum <= 0 { - return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_WEIGHT", "openai advanced scheduler base weights must not all be zero") - } - return nil -} - -// resolveOpenAIAdvancedSchedulerWeight 返回覆盖值(已归一化的非空字符串),空则回退默认值。 -func resolveOpenAIAdvancedSchedulerWeight(normalized string, fallback float64) float64 { - if normalized == "" { - return fallback - } - value, err := strconv.ParseFloat(normalized, 64) - if err != nil { - return fallback - } - return value -} - -func normalizeOptionalPositiveIntString(raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return "", nil - } - value, err := strconv.Atoi(raw) - if err != nil || value <= 0 { - return "", fmt.Errorf("invalid positive integer") - } - return strconv.Itoa(value), nil -} - -func normalizeOptionalNonNegativeFloatString(raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return "", nil - } - value, err := strconv.ParseFloat(raw, 64) - if err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { - return "", fmt.Errorf("invalid non-negative float") - } - return strconv.FormatFloat(value, 'f', -1, 64), nil -} - -func parseDefaultSubscriptions(raw string) []DefaultSubscriptionSetting { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil - } - - var items []DefaultSubscriptionSetting - if err := json.Unmarshal([]byte(raw), &items); err != nil { - return nil - } - - normalized := make([]DefaultSubscriptionSetting, 0, len(items)) - for _, item := range items { - if item.GroupID <= 0 || item.ValidityDays <= 0 { - continue - } - if item.ValidityDays > MaxValidityDays { - item.ValidityDays = MaxValidityDays - } - normalized = append(normalized, item) - } - - return normalized -} - -func parseProviderDefaultGrantSettings(settings map[string]string, keys authSourceDefaultKeySet) ProviderDefaultGrantSettings { - result := ProviderDefaultGrantSettings{ - Balance: defaultAuthSourceBalance, - Concurrency: defaultAuthSourceConcurrency, - Subscriptions: []DefaultSubscriptionSetting{}, - GrantOnSignup: false, - GrantOnFirstBind: false, - } - - if v, err := strconv.ParseFloat(strings.TrimSpace(settings[keys.balance]), 64); err == nil { - result.Balance = v - } - if v, err := strconv.Atoi(strings.TrimSpace(settings[keys.concurrency])); err == nil { - result.Concurrency = v - } - if items := parseDefaultSubscriptions(settings[keys.subscriptions]); items != nil { - result.Subscriptions = items - } - if raw, ok := settings[keys.grantOnSignup]; ok { - result.GrantOnSignup = raw == "true" - } - if raw, ok := settings[keys.grantOnFirstBind]; ok { - result.GrantOnFirstBind = raw == "true" - } - - if raw := settings[keys.platformQuotas]; raw != "" { - parsed := map[string]*DefaultPlatformQuotaSetting{} - if err := json.Unmarshal([]byte(raw), &parsed); err != nil { - slog.Warn("[Setting] parseProviderDefaultGrantSettings: unmarshal auth source platform quotas failed", "source", keys.source, "error", err) - } else { - result.PlatformQuotas = parsed - } - } - - return result -} - -func writeProviderDefaultGrantUpdates(updates map[string]string, keys authSourceDefaultKeySet, settings ProviderDefaultGrantSettings) { - updates[keys.balance] = strconv.FormatFloat(settings.Balance, 'f', 8, 64) - updates[keys.concurrency] = strconv.Itoa(settings.Concurrency) - - subscriptions := settings.Subscriptions - if subscriptions == nil { - subscriptions = []DefaultSubscriptionSetting{} - } - raw, err := json.Marshal(subscriptions) - if err != nil { - raw = []byte("[]") - } - updates[keys.subscriptions] = string(raw) - updates[keys.grantOnSignup] = strconv.FormatBool(settings.GrantOnSignup) - updates[keys.grantOnFirstBind] = strconv.FormatBool(settings.GrantOnFirstBind) - - // auth source platform quota:整体替换语义。 - // nil = 请求未携带该字段,跳过写入以保留既有配置(与系统层 buildSystemSettingsUpdates 的 - // DefaultPlatformQuotas nil 守卫一致);非 nil(含空 map)才整体替换。二者语义不可混同。 - if keys.platformQuotas != "" && settings.PlatformQuotas != nil { - blob, err := json.Marshal(settings.PlatformQuotas) - if err != nil { - blob = []byte("{}") - } - updates[keys.platformQuotas] = string(blob) - } -} - -func mergeProviderDefaultGrantSettings(globalDefaults ProviderDefaultGrantSettings, providerDefaults ProviderDefaultGrantSettings) ProviderDefaultGrantSettings { - result := ProviderDefaultGrantSettings{ - Balance: globalDefaults.Balance, - Concurrency: globalDefaults.Concurrency, - Subscriptions: append([]DefaultSubscriptionSetting(nil), globalDefaults.Subscriptions...), - GrantOnSignup: providerDefaults.GrantOnSignup, - GrantOnFirstBind: providerDefaults.GrantOnFirstBind, - } - - // 注意:不能把 parse 默认值 (defaultAuthSourceBalance / defaultAuthSourceConcurrency) - // 当作"未配置"哨兵——admin 完全有权显式设成相同的值,那时仍应覆盖 globalDefaults。 - // 旧实现的 `!= defaultAuthSourceConcurrency` 会把 admin 设的 5 与 fallback 5 混淆, - // 导致渠道发放退回到全局默认(如 1),表现为"管理员设 5、新用户实际拿 1"。 - if providerDefaults.Balance >= 0 { - result.Balance = providerDefaults.Balance - } - if providerDefaults.Concurrency > 0 { - result.Concurrency = providerDefaults.Concurrency - } - if len(providerDefaults.Subscriptions) > 0 { - result.Subscriptions = append([]DefaultSubscriptionSetting(nil), providerDefaults.Subscriptions...) - } - - return result -} - -func parseTablePreferences(defaultPageSizeRaw, optionsRaw string) (int, []int) { - defaultPageSize := 20 - if v, err := strconv.Atoi(strings.TrimSpace(defaultPageSizeRaw)); err == nil { - defaultPageSize = v - } - - var options []int - if strings.TrimSpace(optionsRaw) != "" { - _ = json.Unmarshal([]byte(optionsRaw), &options) - } - - return normalizeTablePreferences(defaultPageSize, options) -} - -func normalizeTablePreferences(defaultPageSize int, options []int) (int, []int) { - const minPageSize = 5 - const maxPageSize = 1000 - const fallbackPageSize = 20 - - seen := make(map[int]struct{}, len(options)) - normalizedOptions := make([]int, 0, len(options)) - for _, option := range options { - if option < minPageSize || option > maxPageSize { - continue - } - if _, ok := seen[option]; ok { - continue - } - seen[option] = struct{}{} - normalizedOptions = append(normalizedOptions, option) - } - sort.Ints(normalizedOptions) - - if defaultPageSize < minPageSize || defaultPageSize > maxPageSize { - defaultPageSize = fallbackPageSize - } - - if len(normalizedOptions) == 0 { - normalizedOptions = []int{10, 20, 50} - } - - return defaultPageSize, normalizedOptions -} - // getStringOrDefault 获取字符串值或默认值 func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string { if value, ok := settings[key]; ok && value != "" { @@ -4185,1287 +261,3 @@ func (s *SettingService) getStringOrDefault(settings map[string]string, key, def } return defaultValue } - -// IsTurnstileEnabled 检查是否启用 Turnstile 验证 -func (s *SettingService) IsTurnstileEnabled(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileEnabled) - if err != nil { - return false - } - return value == "true" -} - -// GetTurnstileSecretKey 获取 Turnstile Secret Key -func (s *SettingService) GetTurnstileSecretKey(ctx context.Context) string { - value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileSecretKey) - if err != nil { - return "" - } - return value -} - -// IsIdentityPatchEnabled 检查是否启用身份补丁(Claude -> Gemini systemInstruction 注入) -func (s *SettingService) IsIdentityPatchEnabled(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableIdentityPatch) - if err != nil { - // 默认开启,保持兼容 - return true - } - return value == "true" -} - -// GetIdentityPatchPrompt 获取自定义身份补丁提示词(为空表示使用内置默认模板) -func (s *SettingService) GetIdentityPatchPrompt(ctx context.Context) string { - value, err := s.settingRepo.GetValue(ctx, SettingKeyIdentityPatchPrompt) - if err != nil { - return "" - } - return value -} - -// GenerateAdminAPIKey 生成新的管理员 API Key -func (s *SettingService) GenerateAdminAPIKey(ctx context.Context) (string, error) { - // 生成 32 字节随机数 = 64 位十六进制字符 - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { - return "", fmt.Errorf("generate random bytes: %w", err) - } - - key := AdminAPIKeyPrefix + hex.EncodeToString(bytes) - - // 存储到 settings 表 - if err := s.settingRepo.Set(ctx, SettingKeyAdminAPIKey, key); err != nil { - return "", fmt.Errorf("save admin api key: %w", err) - } - - return key, nil -} - -// GetAdminAPIKeyStatus 获取管理员 API Key 状态 -// 返回脱敏的 key、是否存在、错误 -func (s *SettingService) GetAdminAPIKeyStatus(ctx context.Context) (maskedKey string, exists bool, err error) { - key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return "", false, nil - } - return "", false, err - } - if key == "" { - return "", false, nil - } - - // 脱敏:显示前 10 位和后 4 位 - if len(key) > 14 { - maskedKey = key[:10] + "..." + key[len(key)-4:] - } else { - maskedKey = key - } - - return maskedKey, true, nil -} - -// GetAdminAPIKey 获取完整的管理员 API Key(仅供内部验证使用) -// 如果未配置返回空字符串和 nil 错误,只有数据库错误时才返回 error -func (s *SettingService) GetAdminAPIKey(ctx context.Context) (string, error) { - key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return "", nil // 未配置,返回空字符串 - } - return "", err // 数据库错误 - } - return key, nil -} - -// DeleteAdminAPIKey 删除管理员 API Key -func (s *SettingService) DeleteAdminAPIKey(ctx context.Context) error { - return s.settingRepo.Delete(ctx, SettingKeyAdminAPIKey) -} - -// IsModelFallbackEnabled 检查是否启用模型兜底机制 -func (s *SettingService) IsModelFallbackEnabled(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableModelFallback) - if err != nil { - return false // Default: disabled - } - return value == "true" -} - -// GetFallbackModel 获取指定平台的兜底模型 -func (s *SettingService) GetFallbackModel(ctx context.Context, platform string) string { - var key string - var defaultModel string - - switch platform { - case PlatformAnthropic: - key = SettingKeyFallbackModelAnthropic - defaultModel = "claude-3-5-sonnet-20241022" - case PlatformOpenAI: - key = SettingKeyFallbackModelOpenAI - defaultModel = "gpt-4o" - case PlatformGemini: - key = SettingKeyFallbackModelGemini - defaultModel = "gemini-2.5-pro" - case PlatformAntigravity: - key = SettingKeyFallbackModelAntigravity - defaultModel = "gemini-2.5-pro" - default: - return "" - } - - value, err := s.settingRepo.GetValue(ctx, key) - if err != nil || value == "" { - return defaultModel - } - return value -} - -// GetLinuxDoConnectOAuthConfig 返回用于登录的"最终生效" LinuxDo Connect 配置。 -// -// 优先级: -// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 -// - 否则回退到 config.yaml/env 的值 -func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) { - if s == nil || s.cfg == nil { - return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") - } - - effective := s.cfg.LinuxDo - - keys := []string{ - SettingKeyLinuxDoConnectEnabled, - SettingKeyLinuxDoConnectClientID, - SettingKeyLinuxDoConnectClientSecret, - SettingKeyLinuxDoConnectRedirectURL, - } - settings, err := s.settingRepo.GetMultiple(ctx, keys) - if err != nil { - return config.LinuxDoConnectConfig{}, fmt.Errorf("get linuxdo connect settings: %w", err) - } - - if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { - effective.Enabled = raw == "true" - } - if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" { - effective.ClientID = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyLinuxDoConnectClientSecret]; ok && strings.TrimSpace(v) != "" { - effective.ClientSecret = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { - effective.RedirectURL = strings.TrimSpace(v) - } - if !effective.Enabled { - return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") - } - - // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。 - if strings.TrimSpace(effective.ClientID) == "" { - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") - } - if strings.TrimSpace(effective.AuthorizeURL) == "" { - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured") - } - if strings.TrimSpace(effective.TokenURL) == "" { - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured") - } - if strings.TrimSpace(effective.UserInfoURL) == "" { - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url not configured") - } - if strings.TrimSpace(effective.RedirectURL) == "" { - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured") - } - if strings.TrimSpace(effective.FrontendRedirectURL) == "" { - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") - } - - if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") - } - if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") - } - if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil { - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") - } - if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") - } - if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") - } - - method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod)) - switch method { - case "", "client_secret_post", "client_secret_basic": - if strings.TrimSpace(effective.ClientSecret) == "" { - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") - } - case "none": - default: - return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") - } - - return effective, nil -} - -// GetDingTalkConnectOAuthConfig 返回用于登录的"最终生效" DingTalk Connect 配置。 -// -// 优先级: -// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 -// - 否则回退到 config.yaml/env 的值 -func (s *SettingService) GetDingTalkConnectOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) { - if s == nil || s.cfg == nil { - return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") - } - - effective := s.cfg.DingTalk - - keys := []string{ - SettingKeyDingTalkConnectEnabled, - SettingKeyDingTalkConnectClientID, - SettingKeyDingTalkConnectClientSecret, - SettingKeyDingTalkConnectRedirectURL, - SettingKeyDingTalkConnectCorpRestrictionPolicy, - SettingKeyDingTalkConnectInternalCorpID, - SettingKeyDingTalkConnectBypassRegistration, - SettingKeyDingTalkConnectSyncCorpEmail, - SettingKeyDingTalkConnectSyncDisplayName, - SettingKeyDingTalkConnectSyncDept, - SettingKeyDingTalkConnectSyncCorpEmailAttrKey, - SettingKeyDingTalkConnectSyncDisplayNameAttrKey, - SettingKeyDingTalkConnectSyncDeptAttrKey, - } - settings, err := s.settingRepo.GetMultiple(ctx, keys) - if err != nil { - return config.DingTalkConnectConfig{}, fmt.Errorf("get dingtalk connect settings: %w", err) - } - - if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { - effective.Enabled = raw == "true" - } - if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" { - effective.ClientID = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyDingTalkConnectClientSecret]; ok && strings.TrimSpace(v) != "" { - effective.ClientSecret = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { - effective.RedirectURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" { - effective.CorpRestrictionPolicy = strings.TrimSpace(v) - } - effective.CorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(effective.CorpRestrictionPolicy) - if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" { - effective.InternalCorpID = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" { - effective.BypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true") - } - // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false, - // 以保证 OAuth callback 看到的 effective config 永远是一致状态。 - if effective.CorpRestrictionPolicy != "internal_only" { - effective.BypassRegistration = false - } - - if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" { - effective.SyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true") - } - if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" { - effective.SyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true") - } - if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" { - effective.SyncDept = strings.EqualFold(strings.TrimSpace(v), "true") - } - // 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。 - if effective.CorpRestrictionPolicy != "internal_only" { - effective.SyncCorpEmail = false - effective.SyncDisplayName = false - effective.SyncDept = false - } - - // 身份同步目标 attr key(DB 空 → fallback 默认值) - if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]); v != "" { - effective.SyncCorpEmailAttrKey = v - } - if effective.SyncCorpEmailAttrKey == "" { - effective.SyncCorpEmailAttrKey = "dingtalk_email" - } - if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]); v != "" { - effective.SyncDisplayNameAttrKey = v - } - if effective.SyncDisplayNameAttrKey == "" { - effective.SyncDisplayNameAttrKey = "dingtalk_name" - } - if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]); v != "" { - effective.SyncDeptAttrKey = v - } - if effective.SyncDeptAttrKey == "" { - effective.SyncDeptAttrKey = "dingtalk_department" - } - - if !effective.Enabled { - return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled") - } - - // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。 - if strings.TrimSpace(effective.ClientID) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client id not configured") - } - if strings.TrimSpace(effective.AuthorizeURL) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url not configured") - } - if strings.TrimSpace(effective.TokenURL) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url not configured") - } - if strings.TrimSpace(effective.UserInfoURL) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url not configured") - } - if strings.TrimSpace(effective.RedirectURL) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url not configured") - } - if strings.TrimSpace(effective.FrontendRedirectURL) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url not configured") - } - - if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url invalid") - } - if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url invalid") - } - if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url invalid") - } - if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url invalid") - } - if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url invalid") - } - if strings.TrimSpace(effective.ClientSecret) == "" { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client secret not configured") - } - - // 镜像 admin handler 行为:internal_only policy 隐式要求 AppType=internal - if effective.CorpRestrictionPolicy == "internal_only" { - effective.AppType = "internal" - } - - if err := config.ValidateDingTalkConfig(effective); err != nil { - return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", err.Error()) - } - - return effective, nil -} - -// GetWeChatConnectOAuthConfig 返回用于登录的最终生效 WeChat Connect 配置。 -// -// WeChat Connect 已回归 DB 系统设置模型,不再回退到 config/env。 -func (s *SettingService) GetWeChatConnectOAuthConfig(ctx context.Context) (WeChatConnectOAuthConfig, error) { - keys := []string{ - SettingKeyWeChatConnectEnabled, - SettingKeyWeChatConnectAppID, - SettingKeyWeChatConnectAppSecret, - SettingKeyWeChatConnectOpenAppID, - SettingKeyWeChatConnectOpenAppSecret, - SettingKeyWeChatConnectMPAppID, - SettingKeyWeChatConnectMPAppSecret, - SettingKeyWeChatConnectMobileAppID, - SettingKeyWeChatConnectMobileAppSecret, - SettingKeyWeChatConnectOpenEnabled, - SettingKeyWeChatConnectMPEnabled, - SettingKeyWeChatConnectMobileEnabled, - SettingKeyWeChatConnectMode, - SettingKeyWeChatConnectScopes, - SettingKeyWeChatConnectRedirectURL, - SettingKeyWeChatConnectFrontendRedirectURL, - } - settings, err := s.settingRepo.GetMultiple(ctx, keys) - if err != nil { - return WeChatConnectOAuthConfig{}, fmt.Errorf("get wechat connect settings: %w", err) - } - return s.parseWeChatConnectOAuthConfig(settings) -} - -// GetOverloadCooldownSettings 获取529过载冷却配置 -func (s *SettingService) GetOverloadCooldownSettings(ctx context.Context) (*OverloadCooldownSettings, error) { - value, err := s.settingRepo.GetValue(ctx, SettingKeyOverloadCooldownSettings) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return DefaultOverloadCooldownSettings(), nil - } - return nil, fmt.Errorf("get overload cooldown settings: %w", err) - } - if value == "" { - return DefaultOverloadCooldownSettings(), nil - } - - var settings OverloadCooldownSettings - if err := json.Unmarshal([]byte(value), &settings); err != nil { - return DefaultOverloadCooldownSettings(), nil - } - - // 修正配置值范围 - if settings.CooldownMinutes < 1 { - settings.CooldownMinutes = 1 - } - if settings.CooldownMinutes > 120 { - settings.CooldownMinutes = 120 - } - - return &settings, nil -} - -// SetOverloadCooldownSettings 设置529过载冷却配置 -func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settings *OverloadCooldownSettings) error { - if settings == nil { - return fmt.Errorf("settings cannot be nil") - } - - // 禁用时修正为合法值即可,不拒绝请求 - if settings.CooldownMinutes < 1 || settings.CooldownMinutes > 120 { - if settings.Enabled { - return fmt.Errorf("cooldown_minutes must be between 1-120") - } - settings.CooldownMinutes = 10 // 禁用状态下归一化为默认值 - } - - data, err := json.Marshal(settings) - if err != nil { - return fmt.Errorf("marshal overload cooldown settings: %w", err) - } - - return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data)) -} - -// GetRateLimit429CooldownSettings 获取429默认回避配置 -func (s *SettingService) GetRateLimit429CooldownSettings(ctx context.Context) (*RateLimit429CooldownSettings, error) { - value, err := s.settingRepo.GetValue(ctx, SettingKeyRateLimit429CooldownSettings) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return DefaultRateLimit429CooldownSettings(), nil - } - return nil, fmt.Errorf("get 429 cooldown settings: %w", err) - } - if value == "" { - return DefaultRateLimit429CooldownSettings(), nil - } - - var settings RateLimit429CooldownSettings - if err := json.Unmarshal([]byte(value), &settings); err != nil { - return DefaultRateLimit429CooldownSettings(), nil - } - - if settings.CooldownSeconds < 1 { - settings.CooldownSeconds = 1 - } - if settings.CooldownSeconds > 7200 { - settings.CooldownSeconds = 7200 - } - - return &settings, nil -} - -// SetRateLimit429CooldownSettings 设置429默认回避配置 -func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, settings *RateLimit429CooldownSettings) error { - if settings == nil { - return fmt.Errorf("settings cannot be nil") - } - - if settings.CooldownSeconds < 1 || settings.CooldownSeconds > 7200 { - if settings.Enabled { - return fmt.Errorf("cooldown_seconds must be between 1-7200") - } - settings.CooldownSeconds = 5 - } - - data, err := json.Marshal(settings) - if err != nil { - return fmt.Errorf("marshal 429 cooldown settings: %w", err) - } - - return s.settingRepo.Set(ctx, SettingKeyRateLimit429CooldownSettings, string(data)) -} - -// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。 -// -// 优先级: -// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 -// - 否则回退到 config.yaml/env 的值 -func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) { - if s == nil || s.cfg == nil { - return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") - } - - effective := s.cfg.OIDC - - keys := []string{ - SettingKeyOIDCConnectEnabled, - SettingKeyOIDCConnectProviderName, - SettingKeyOIDCConnectClientID, - SettingKeyOIDCConnectClientSecret, - SettingKeyOIDCConnectIssuerURL, - SettingKeyOIDCConnectDiscoveryURL, - SettingKeyOIDCConnectAuthorizeURL, - SettingKeyOIDCConnectTokenURL, - SettingKeyOIDCConnectUserInfoURL, - SettingKeyOIDCConnectJWKSURL, - SettingKeyOIDCConnectScopes, - SettingKeyOIDCConnectRedirectURL, - SettingKeyOIDCConnectFrontendRedirectURL, - SettingKeyOIDCConnectTokenAuthMethod, - SettingKeyOIDCConnectUsePKCE, - SettingKeyOIDCConnectValidateIDToken, - SettingKeyOIDCConnectAllowedSigningAlgs, - SettingKeyOIDCConnectClockSkewSeconds, - SettingKeyOIDCConnectRequireEmailVerified, - SettingKeyOIDCConnectUserInfoEmailPath, - SettingKeyOIDCConnectUserInfoIDPath, - SettingKeyOIDCConnectUserInfoUsernamePath, - } - settings, err := s.settingRepo.GetMultiple(ctx, keys) - if err != nil { - return config.OIDCConnectConfig{}, fmt.Errorf("get oidc connect settings: %w", err) - } - - if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { - effective.Enabled = raw == "true" - } - if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" { - effective.ProviderName = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" { - effective.ClientID = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectClientSecret]; ok && strings.TrimSpace(v) != "" { - effective.ClientSecret = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" { - effective.IssuerURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" { - effective.DiscoveryURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" { - effective.AuthorizeURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" { - effective.TokenURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" { - effective.UserInfoURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" { - effective.JWKSURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" { - effective.Scopes = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { - effective.RedirectURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" { - effective.FrontendRedirectURL = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" { - effective.TokenAuthMethod = strings.ToLower(strings.TrimSpace(v)) - } - if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok { - effective.UsePKCE = raw == "true" - } else { - effective.UsePKCE = oidcUsePKCECompatibilityDefault(effective) - } - if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok { - effective.ValidateIDToken = raw == "true" - } else { - effective.ValidateIDToken = oidcValidateIDTokenCompatibilityDefault(effective) - } - if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" { - effective.AllowedSigningAlgs = strings.TrimSpace(v) - } - if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" { - if parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil { - effective.ClockSkewSeconds = parsed - } - } - if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok { - effective.RequireEmailVerified = raw == "true" - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok { - effective.UserInfoEmailPath = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok { - effective.UserInfoIDPath = strings.TrimSpace(v) - } - if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok { - effective.UserInfoUsernamePath = strings.TrimSpace(v) - } - - if !effective.Enabled { - return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") - } - if strings.TrimSpace(effective.ProviderName) == "" { - effective.ProviderName = "OIDC" - } - if strings.TrimSpace(effective.ClientID) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") - } - if strings.TrimSpace(effective.IssuerURL) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url not configured") - } - if strings.TrimSpace(effective.RedirectURL) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured") - } - if strings.TrimSpace(effective.FrontendRedirectURL) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") - } - if !scopesContainOpenID(effective.Scopes) { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth scopes must contain openid") - } - if effective.ClockSkewSeconds < 0 || effective.ClockSkewSeconds > 600 { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth clock skew must be between 0 and 600") - } - - if err := config.ValidateAbsoluteHTTPURL(effective.IssuerURL); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url invalid") - } - - discoveryURL := strings.TrimSpace(effective.DiscoveryURL) - if discoveryURL == "" { - discoveryURL = oidcDefaultDiscoveryURL(effective.IssuerURL) - effective.DiscoveryURL = discoveryURL - } - if discoveryURL != "" { - if err := config.ValidateAbsoluteHTTPURL(discoveryURL); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery url invalid") - } - } - - needsDiscovery := strings.TrimSpace(effective.AuthorizeURL) == "" || - strings.TrimSpace(effective.TokenURL) == "" || - (effective.ValidateIDToken && strings.TrimSpace(effective.JWKSURL) == "") - if needsDiscovery && discoveryURL != "" { - metadata, resolveErr := oidcResolveProviderMetadata(ctx, discoveryURL) - if resolveErr != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery resolve failed").WithCause(resolveErr) - } - if strings.TrimSpace(effective.AuthorizeURL) == "" { - effective.AuthorizeURL = strings.TrimSpace(metadata.AuthorizationEndpoint) - } - if strings.TrimSpace(effective.TokenURL) == "" { - effective.TokenURL = strings.TrimSpace(metadata.TokenEndpoint) - } - if strings.TrimSpace(effective.UserInfoURL) == "" { - effective.UserInfoURL = strings.TrimSpace(metadata.UserInfoEndpoint) - } - if strings.TrimSpace(effective.JWKSURL) == "" { - effective.JWKSURL = strings.TrimSpace(metadata.JWKSURI) - } - } - - if strings.TrimSpace(effective.AuthorizeURL) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured") - } - if strings.TrimSpace(effective.TokenURL) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured") - } - if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") - } - if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") - } - if v := strings.TrimSpace(effective.UserInfoURL); v != "" { - if err := config.ValidateAbsoluteHTTPURL(v); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") - } - } - if effective.ValidateIDToken { - if strings.TrimSpace(effective.JWKSURL) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url not configured") - } - if strings.TrimSpace(effective.AllowedSigningAlgs) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth signing algs not configured") - } - } - if v := strings.TrimSpace(effective.JWKSURL); v != "" { - if err := config.ValidateAbsoluteHTTPURL(v); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url invalid") - } - } - if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") - } - if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") - } - - method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod)) - switch method { - case "", "client_secret_post", "client_secret_basic": - if strings.TrimSpace(effective.ClientSecret) == "" { - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") - } - case "none": - default: - return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") - } - - return effective, nil -} - -func scopesContainOpenID(scopes string) bool { - for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) { - if scope == "openid" { - return true - } - } - return false -} - -type oidcProviderMetadata struct { - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - UserInfoEndpoint string `json:"userinfo_endpoint"` - JWKSURI string `json:"jwks_uri"` -} - -func oidcDefaultDiscoveryURL(issuerURL string) string { - issuerURL = strings.TrimSpace(issuerURL) - if issuerURL == "" { - return "" - } - return strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration" -} - -func oidcResolveProviderMetadata(ctx context.Context, discoveryURL string) (*oidcProviderMetadata, error) { - discoveryURL = strings.TrimSpace(discoveryURL) - if discoveryURL == "" { - return nil, fmt.Errorf("discovery url is empty") - } - - resp, err := req.C(). - SetTimeout(15*time.Second). - R(). - SetContext(ctx). - SetHeader("Accept", "application/json"). - Get(discoveryURL) - if err != nil { - return nil, fmt.Errorf("request discovery document: %w", err) - } - if !resp.IsSuccessState() { - return nil, fmt.Errorf("discovery request failed: status=%d", resp.StatusCode) - } - - metadata := &oidcProviderMetadata{} - if err := json.Unmarshal(resp.Bytes(), metadata); err != nil { - return nil, fmt.Errorf("parse discovery document: %w", err) - } - return metadata, nil -} - -// GetStreamTimeoutSettings 获取流超时处理配置 -func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) { - value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return DefaultStreamTimeoutSettings(), nil - } - return nil, fmt.Errorf("get stream timeout settings: %w", err) - } - if value == "" { - return DefaultStreamTimeoutSettings(), nil - } - - var settings StreamTimeoutSettings - if err := json.Unmarshal([]byte(value), &settings); err != nil { - return DefaultStreamTimeoutSettings(), nil - } - - // 验证并修正配置值 - if settings.TempUnschedMinutes < 1 { - settings.TempUnschedMinutes = 1 - } - if settings.TempUnschedMinutes > 60 { - settings.TempUnschedMinutes = 60 - } - if settings.ThresholdCount < 1 { - settings.ThresholdCount = 1 - } - if settings.ThresholdCount > 10 { - settings.ThresholdCount = 10 - } - if settings.ThresholdWindowMinutes < 1 { - settings.ThresholdWindowMinutes = 1 - } - if settings.ThresholdWindowMinutes > 60 { - settings.ThresholdWindowMinutes = 60 - } - - // 验证 action - switch settings.Action { - case StreamTimeoutActionTempUnsched, StreamTimeoutActionError, StreamTimeoutActionNone: - // valid - default: - settings.Action = StreamTimeoutActionTempUnsched - } - - return &settings, nil -} - -// IsUngroupedKeySchedulingAllowed 查询是否允许未分组 Key 调度 -func (s *SettingService) IsUngroupedKeySchedulingAllowed(ctx context.Context) bool { - value, err := s.settingRepo.GetValue(ctx, SettingKeyAllowUngroupedKeyScheduling) - if err != nil { - return false // fail-closed: 查询失败时默认不允许 - } - return value == "true" -} - -// GetClaudeCodeVersionBounds 获取 Claude Code 版本号上下限要求 -// 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销 -// singleflight 防止缓存过期时 thundering herd -// 返回空字符串表示不做对应方向的版本检查 -func (s *SettingService) GetClaudeCodeVersionBounds(ctx context.Context) (min, max string) { - if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok { - if time.Now().UnixNano() < cached.expiresAt { - return cached.min, cached.max - } - } - // singleflight: 同一时刻只有一个 goroutine 查询 DB,其余复用结果 - type bounds struct{ min, max string } - result, err, _ := versionBoundsSF.Do("version_bounds", func() (any, error) { - // 二次检查,避免排队的 goroutine 重复查询 - if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok { - if time.Now().UnixNano() < cached.expiresAt { - return bounds{cached.min, cached.max}, nil - } - } - // 使用独立 context:断开请求取消链,避免客户端断连导致空值被长期缓存 - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), versionBoundsDBTimeout) - defer cancel() - values, err := s.settingRepo.GetMultiple(dbCtx, []string{ - SettingKeyMinClaudeCodeVersion, - SettingKeyMaxClaudeCodeVersion, - }) - if err != nil { - // fail-open: DB 错误时不阻塞请求,但记录日志并使用短 TTL 快速重试 - slog.Warn("failed to get claude code version bounds setting, skipping version check", "error", err) - versionBoundsCache.Store(&cachedVersionBounds{ - min: "", - max: "", - expiresAt: time.Now().Add(versionBoundsErrorTTL).UnixNano(), - }) - return bounds{"", ""}, nil - } - b := bounds{ - min: values[SettingKeyMinClaudeCodeVersion], - max: values[SettingKeyMaxClaudeCodeVersion], - } - versionBoundsCache.Store(&cachedVersionBounds{ - min: b.min, - max: b.max, - expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(), - }) - return b, nil - }) - if err != nil { - return "", "" - } - b, ok := result.(bounds) - if !ok { - return "", "" - } - return b.min, b.max -} - -// GetOpenAIQuotaAutoPauseSettings returns the current global default quota auto-pause -// settings. It is invoked on the OpenAI scheduling hot path (once per request) and is -// therefore designed to never block on the DB: -// -// - Fresh cached value → returned immediately. -// - Stale or empty cache → the last known value is returned, and a background -// goroutine refreshes the cache via singleflight (stale-while-revalidate). -// - First call with no cache yet → zero defaults are returned and the same async -// refresh is kicked off; the next call gets the freshly populated value. -// -// Callers that need the freshly persisted value synchronously (tests, post-update -// confirmation, optional startup warm-up) should call WarmOpenAIQuotaAutoPauseSettings. -func (s *SettingService) GetOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings { - if s == nil { - return OpsOpenAIAccountQuotaAutoPauseSettings{} - } - cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings) - now := time.Now().UnixNano() - if cached != nil && now < cached.expiresAt { - return cached.settings - } - // Stale or unset: trigger background refresh without blocking this request. - // singleflight.DoChan dedupes concurrent refreshes; we deliberately ignore the - // returned channel — the result is observable via the atomic cache. - s.openAIQuotaAutoPauseSettingsSF.DoChan(openAIQuotaAutoPauseSettingsRefreshKey, func() (any, error) { - s.refreshOpenAIQuotaAutoPauseSettings(context.Background()) - return nil, nil - }) - if cached != nil { - return cached.settings // serve stale value while revalidating - } - return OpsOpenAIAccountQuotaAutoPauseSettings{} -} - -// WarmOpenAIQuotaAutoPauseSettings synchronously loads the quota auto-pause settings -// into the in-memory cache. Useful for application startup (so the first request hits -// a warm cache) and for tests that need deterministic reads immediately after -// constructing the service. -func (s *SettingService) WarmOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings { - if s == nil { - return OpsOpenAIAccountQuotaAutoPauseSettings{} - } - s.refreshOpenAIQuotaAutoPauseSettings(ctx) - cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings) - if cached == nil { - return OpsOpenAIAccountQuotaAutoPauseSettings{} - } - return cached.settings -} - -// refreshOpenAIQuotaAutoPauseSettings reads the latest settings from the DB and stores -// them into the in-memory cache. On error it stores the prior value (or zero defaults -// if nothing is cached yet) with the shorter error TTL so the next refresh comes -// sooner. Always uses its own timeout-bounded context to keep refresh latency -// predictable regardless of the caller. -func (s *SettingService) refreshOpenAIQuotaAutoPauseSettings(ctx context.Context) { - if s == nil || s.settingRepo == nil { - return - } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIQuotaAutoPauseSettingsDBTimeout) - defer cancel() - - settings := OpsOpenAIAccountQuotaAutoPauseSettings{} - ttl := openAIQuotaAutoPauseSettingsCacheTTL - raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpsAdvancedSettings) - if err == nil { - cfg := defaultOpsAdvancedSettings() - if strings.TrimSpace(raw) != "" { - if jsonErr := json.Unmarshal([]byte(raw), cfg); jsonErr == nil { - normalizeOpsAdvancedSettings(cfg) - } - } - settings = cfg.OpenAIAccountQuotaAutoPause - } else if !errors.Is(err, ErrSettingNotFound) { - // Real error: keep serving prior value but refresh sooner. - if prior, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); prior != nil { - settings = prior.settings - } - ttl = openAIQuotaAutoPauseSettingsErrorTTL - } - - s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{ - settings: settings, - expiresAt: time.Now().Add(ttl).UnixNano(), - }) -} - -// SetOpenAIQuotaAutoPauseSettings writes the given settings directly into the in-memory -// cache. Called from settings-write code paths so that the next read reflects the new -// value immediately, without waiting for the background refresh. -func (s *SettingService) SetOpenAIQuotaAutoPauseSettings(settings OpsOpenAIAccountQuotaAutoPauseSettings) { - if s == nil { - return - } - s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{ - settings: settings, - expiresAt: time.Now().Add(openAIQuotaAutoPauseSettingsCacheTTL).UnixNano(), - }) -} - -// GetRectifierSettings 获取请求整流器配置 -func (s *SettingService) GetRectifierSettings(ctx context.Context) (*RectifierSettings, error) { - value, err := s.settingRepo.GetValue(ctx, SettingKeyRectifierSettings) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return DefaultRectifierSettings(), nil - } - return nil, fmt.Errorf("get rectifier settings: %w", err) - } - if value == "" { - return DefaultRectifierSettings(), nil - } - - var settings RectifierSettings - if err := json.Unmarshal([]byte(value), &settings); err != nil { - return DefaultRectifierSettings(), nil - } - - return &settings, nil -} - -// SetRectifierSettings 设置请求整流器配置 -func (s *SettingService) SetRectifierSettings(ctx context.Context, settings *RectifierSettings) error { - if settings == nil { - return fmt.Errorf("settings cannot be nil") - } - - data, err := json.Marshal(settings) - if err != nil { - return fmt.Errorf("marshal rectifier settings: %w", err) - } - - return s.settingRepo.Set(ctx, SettingKeyRectifierSettings, string(data)) -} - -// IsSignatureRectifierEnabled 判断签名整流是否启用(总开关 && 签名子开关) -func (s *SettingService) IsSignatureRectifierEnabled(ctx context.Context) bool { - settings, err := s.GetRectifierSettings(ctx) - if err != nil { - return true // fail-open: 查询失败时默认启用 - } - return settings.Enabled && settings.ThinkingSignatureEnabled -} - -// IsBudgetRectifierEnabled 判断 Budget 整流是否启用(总开关 && Budget 子开关) -func (s *SettingService) IsBudgetRectifierEnabled(ctx context.Context) bool { - settings, err := s.GetRectifierSettings(ctx) - if err != nil { - return true // fail-open: 查询失败时默认启用 - } - return settings.Enabled && settings.ThinkingBudgetEnabled -} - -// GetBetaPolicySettings 获取 Beta 策略配置 -func (s *SettingService) GetBetaPolicySettings(ctx context.Context) (*BetaPolicySettings, error) { - value, err := s.settingRepo.GetValue(ctx, SettingKeyBetaPolicySettings) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return DefaultBetaPolicySettings(), nil - } - return nil, fmt.Errorf("get beta policy settings: %w", err) - } - if value == "" { - return DefaultBetaPolicySettings(), nil - } - - var settings BetaPolicySettings - if err := json.Unmarshal([]byte(value), &settings); err != nil { - return DefaultBetaPolicySettings(), nil - } - - return &settings, nil -} - -// SetBetaPolicySettings 设置 Beta 策略配置 -func (s *SettingService) SetBetaPolicySettings(ctx context.Context, settings *BetaPolicySettings) error { - if settings == nil { - return fmt.Errorf("settings cannot be nil") - } - - validActions := map[string]bool{ - BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true, - } - validScopes := map[string]bool{ - BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true, - } - - for i, rule := range settings.Rules { - if rule.BetaToken == "" { - return fmt.Errorf("rule[%d]: beta_token cannot be empty", i) - } - if !validActions[rule.Action] { - return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action) - } - if !validScopes[rule.Scope] { - return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope) - } - // Validate model_whitelist patterns - for j, pattern := range rule.ModelWhitelist { - trimmed := strings.TrimSpace(pattern) - if trimmed == "" { - return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j) - } - settings.Rules[i].ModelWhitelist[j] = trimmed - } - // Validate fallback_action - if rule.FallbackAction != "" && !validActions[rule.FallbackAction] { - return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction) - } - } - - data, err := json.Marshal(settings) - if err != nil { - return fmt.Errorf("marshal beta policy settings: %w", err) - } - - return s.settingRepo.Set(ctx, SettingKeyBetaPolicySettings, string(data)) -} - -// GetOpenAIFastPolicySettings 获取 OpenAI fast 策略配置 -func (s *SettingService) GetOpenAIFastPolicySettings(ctx context.Context) (*OpenAIFastPolicySettings, error) { - value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAIFastPolicySettings) - if err != nil { - if errors.Is(err, ErrSettingNotFound) { - return DefaultOpenAIFastPolicySettings(), nil - } - return nil, fmt.Errorf("get openai fast policy settings: %w", err) - } - if value == "" { - return DefaultOpenAIFastPolicySettings(), nil - } - - var settings OpenAIFastPolicySettings - if err := json.Unmarshal([]byte(value), &settings); err != nil { - // JSON 损坏时静默 fallback 到默认配置会让策略意外失效(管理员配 - // 置的 block/filter 规则被忽略)。记录 Warn 让运维能在出现异常 - // 行为时定位到 settings 表里的脏数据。 - slog.Warn("failed to unmarshal openai fast policy settings, falling back to defaults", - "error", err, - "key", SettingKeyOpenAIFastPolicySettings) - return DefaultOpenAIFastPolicySettings(), nil - } - - return &settings, nil -} - -// SetOpenAIFastPolicySettings 设置 OpenAI fast 策略配置 -func (s *SettingService) SetOpenAIFastPolicySettings(ctx context.Context, settings *OpenAIFastPolicySettings) error { - if settings == nil { - return fmt.Errorf("settings cannot be nil") - } - - validActions := map[string]bool{ - BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true, - OpenAIFastPolicyActionForcePriority: true, - } - validScopes := map[string]bool{ - BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true, - } - validTiers := map[string]bool{ - OpenAIFastTierAny: true, OpenAIFastTierPriority: true, OpenAIFastTierFlex: true, - } - - for i, rule := range settings.Rules { - tier := strings.ToLower(strings.TrimSpace(rule.ServiceTier)) - if tier == "" { - tier = OpenAIFastTierAny - } - if !validTiers[tier] { - return fmt.Errorf("rule[%d]: invalid service_tier %q", i, rule.ServiceTier) - } - settings.Rules[i].ServiceTier = tier - if !validActions[rule.Action] { - return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action) - } - if !validScopes[rule.Scope] { - return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope) - } - for j, pattern := range rule.ModelWhitelist { - trimmed := strings.TrimSpace(pattern) - if trimmed == "" { - return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j) - } - settings.Rules[i].ModelWhitelist[j] = trimmed - } - if rule.FallbackAction != "" && !validActions[rule.FallbackAction] { - return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction) - } - } - - data, err := json.Marshal(settings) - if err != nil { - return fmt.Errorf("marshal openai fast policy settings: %w", err) - } - - return s.settingRepo.Set(ctx, SettingKeyOpenAIFastPolicySettings, string(data)) -} - -// SetStreamTimeoutSettings 设置流超时处理配置 -func (s *SettingService) SetStreamTimeoutSettings(ctx context.Context, settings *StreamTimeoutSettings) error { - if settings == nil { - return fmt.Errorf("settings cannot be nil") - } - - // 验证配置值 - if settings.TempUnschedMinutes < 1 || settings.TempUnschedMinutes > 60 { - return fmt.Errorf("temp_unsched_minutes must be between 1-60") - } - if settings.ThresholdCount < 1 || settings.ThresholdCount > 10 { - return fmt.Errorf("threshold_count must be between 1-10") - } - if settings.ThresholdWindowMinutes < 1 || settings.ThresholdWindowMinutes > 60 { - return fmt.Errorf("threshold_window_minutes must be between 1-60") - } - - switch settings.Action { - case StreamTimeoutActionTempUnsched, StreamTimeoutActionError, StreamTimeoutActionNone: - // valid - default: - return fmt.Errorf("invalid action: %s", settings.Action) - } - - data, err := json.Marshal(settings) - if err != nil { - return fmt.Errorf("marshal stream timeout settings: %w", err) - } - - return s.settingRepo.Set(ctx, SettingKeyStreamTimeoutSettings, string(data)) -} - -// GetDefaultPlatformQuotas 读取系统全局 platform quota JSON key,返回全部允许平台 x 3 window 的设置。 -// 永远返回包含全部允许 platform key 的 map(值可能为零值/nil 字段,表示"上层未配置 = 不限制")。 -// -// 使用单个 JSON key(default_platform_quotas),一次 DB roundtrip,消除旧 12-KV 格式的 N+1 问题。 -// 容错语义:取值失败或 unmarshal 失败 → 返回补齐全部允许平台 key 的空 map(fail-open,注册不被阻断)。 -func (s *SettingService) GetDefaultPlatformQuotas(ctx context.Context) (map[string]*DefaultPlatformQuotaSetting, error) { - out := make(map[string]*DefaultPlatformQuotaSetting, len(AllowedQuotaPlatforms)) - for _, platform := range AllowedQuotaPlatforms { - out[platform] = &DefaultPlatformQuotaSetting{} - } - raw, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultPlatformQuotas) - if err != nil || raw == "" { - return out, nil // 无配置 = 全部不限制 - } - parsed := map[string]*DefaultPlatformQuotaSetting{} - if err := json.Unmarshal([]byte(raw), &parsed); err != nil { - slog.Warn("[Setting] unmarshal default_platform_quotas failed (fail-open)", "error", err) - return out, nil - } - for _, platform := range AllowedQuotaPlatforms { - if v := parsed[platform]; v != nil { - out[platform] = v - } - } - return out, nil // 补齐全部允许 platform key,保持与旧实现一致的下游契约 -} - -// GetAuthSourcePlatformQuotas 读取指定 auth source 的 platform quota 覆盖(仅返回有配置的平台,override 语义)。 -func (s *SettingService) GetAuthSourcePlatformQuotas(ctx context.Context, source string) map[string]*DefaultPlatformQuotaSetting { - out := map[string]*DefaultPlatformQuotaSetting{} - raw, err := s.settingRepo.GetValue(ctx, SettingKeyAuthSourcePlatformQuotas(source)) - if err != nil || raw == "" { - return out // 无 override - } - if err := json.Unmarshal([]byte(raw), &out); err != nil { - slog.Warn("[Setting] unmarshal auth source platform quotas failed (fail-open)", "source", source, "error", err) - return map[string]*DefaultPlatformQuotaSetting{} - } - return out // 仅含已配置平台,保持 override 语义 -} - -// mergePlatformQuotaDefaults 按字段级 patch:src 中非 nil 字段覆盖 dst。 -// 区分 nil("未配置",保留 dst)vs &0.0("显式禁用",覆盖 dst 为 0) -func mergePlatformQuotaDefaults(dst, src *DefaultPlatformQuotaSetting) { - if src == nil || dst == nil { - return - } - if src.DailyLimitUSD != nil { - dst.DailyLimitUSD = src.DailyLimitUSD - } - if src.WeeklyLimitUSD != nil { - dst.WeeklyLimitUSD = src.WeeklyLimitUSD - } - if src.MonthlyLimitUSD != nil { - dst.MonthlyLimitUSD = src.MonthlyLimitUSD - } -} diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go new file mode 100644 index 0000000000..ba819a5ce6 --- /dev/null +++ b/backend/internal/service/setting_update.go @@ -0,0 +1,623 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// UpdateSettings 更新系统设置 +func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error { + updates, err := s.buildSystemSettingsUpdates(ctx, settings) + if err != nil { + return err + } + + err = s.settingRepo.SetMultiple(ctx, updates) + if err == nil { + s.refreshCachedSettings(settings) + } + return err +} + +// UpdateSettingsWithAuthSourceDefaults persists system settings and auth-source defaults in a single write. +func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings) error { + updates, err := s.buildSystemSettingsUpdates(ctx, settings) + if err != nil { + return err + } + + authSourceUpdates, err := s.buildAuthSourceDefaultUpdates(ctx, authDefaults) + if err != nil { + return err + } + for key, value := range authSourceUpdates { + updates[key] = value + } + + err = s.settingRepo.SetMultiple(ctx, updates) + if err == nil { + s.refreshCachedSettings(settings) + } + return err +} + +func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, settings *SystemSettings) (map[string]string, error) { + if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil { + return nil, err + } + normalizedWhitelist, err := NormalizeRegistrationEmailSuffixWhitelist(settings.RegistrationEmailSuffixWhitelist) + if err != nil { + return nil, infraerrors.BadRequest("INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", err.Error()) + } + if normalizedWhitelist == nil { + normalizedWhitelist = []string{} + } + settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist + alipaySource, err := normalizeVisibleMethodSettingSource("alipay", settings.PaymentVisibleMethodAlipaySource, settings.PaymentVisibleMethodAlipayEnabled) + if err != nil { + return nil, err + } + wxpaySource, err := normalizeVisibleMethodSettingSource("wxpay", settings.PaymentVisibleMethodWxpaySource, settings.PaymentVisibleMethodWxpayEnabled) + if err != nil { + return nil, err + } + if err := s.normalizeOpenAIAdvancedSchedulerOverrides(settings); err != nil { + return nil, err + } + settings.PaymentVisibleMethodAlipaySource = alipaySource + settings.PaymentVisibleMethodWxpaySource = wxpaySource + settings.WeChatConnectAppID = strings.TrimSpace(settings.WeChatConnectAppID) + settings.WeChatConnectAppSecret = strings.TrimSpace(settings.WeChatConnectAppSecret) + settings.WeChatConnectOpenAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppID, settings.WeChatConnectAppID)) + settings.WeChatConnectOpenAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppSecret, settings.WeChatConnectAppSecret)) + settings.WeChatConnectMPAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppID, settings.WeChatConnectAppID)) + settings.WeChatConnectMPAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppSecret, settings.WeChatConnectAppSecret)) + settings.WeChatConnectMobileAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppID, settings.WeChatConnectAppID)) + settings.WeChatConnectMobileAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppSecret, settings.WeChatConnectAppSecret)) + settings.WeChatConnectMode = normalizeWeChatConnectStoredMode( + settings.WeChatConnectOpenEnabled, + settings.WeChatConnectMPEnabled, + settings.WeChatConnectMobileEnabled, + settings.WeChatConnectMode, + ) + settings.WeChatConnectScopes = normalizeWeChatConnectScopeSetting(settings.WeChatConnectScopes, settings.WeChatConnectMode) + settings.WeChatConnectRedirectURL = strings.TrimSpace(settings.WeChatConnectRedirectURL) + settings.WeChatConnectFrontendRedirectURL = strings.TrimSpace(settings.WeChatConnectFrontendRedirectURL) + if settings.WeChatConnectFrontendRedirectURL == "" { + settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend + } + settings.GitHubOAuthRedirectURL = strings.TrimSpace(settings.GitHubOAuthRedirectURL) + settings.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(settings.GitHubOAuthFrontendRedirectURL) + if settings.GitHubOAuthFrontendRedirectURL == "" { + settings.GitHubOAuthFrontendRedirectURL = defaultGitHubOAuthFrontend + } + settings.GoogleOAuthRedirectURL = strings.TrimSpace(settings.GoogleOAuthRedirectURL) + settings.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(settings.GoogleOAuthFrontendRedirectURL) + if settings.GoogleOAuthFrontendRedirectURL == "" { + settings.GoogleOAuthFrontendRedirectURL = defaultGoogleOAuthFrontend + } + + updates := make(map[string]string) + + // 注册设置 + updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled) + updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) + registrationEmailSuffixWhitelistJSON, err := json.Marshal(settings.RegistrationEmailSuffixWhitelist) + if err != nil { + return nil, fmt.Errorf("marshal registration email suffix whitelist: %w", err) + } + updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON) + updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) + updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) + updates[SettingKeyFrontendURL] = settings.FrontendURL + updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) + updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) + settings.LoginAgreementMode = normalizeLoginAgreementMode(settings.LoginAgreementMode) + settings.LoginAgreementUpdatedAt = strings.TrimSpace(settings.LoginAgreementUpdatedAt) + if settings.LoginAgreementUpdatedAt == "" { + settings.LoginAgreementUpdatedAt = defaultLoginAgreementDate + } + loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(settings.LoginAgreementDocuments) + if err != nil { + return nil, err + } + updates[SettingKeyLoginAgreementEnabled] = strconv.FormatBool(settings.LoginAgreementEnabled) + updates[SettingKeyLoginAgreementMode] = settings.LoginAgreementMode + updates[SettingKeyLoginAgreementUpdatedAt] = settings.LoginAgreementUpdatedAt + updates[SettingKeyLoginAgreementDocuments] = loginAgreementDocumentsJSON + + // 邮件服务设置(只有非空才更新密码) + updates[SettingKeySMTPHost] = settings.SMTPHost + updates[SettingKeySMTPPort] = strconv.Itoa(settings.SMTPPort) + updates[SettingKeySMTPUsername] = settings.SMTPUsername + if settings.SMTPPassword != "" { + updates[SettingKeySMTPPassword] = settings.SMTPPassword + } + updates[SettingKeySMTPFrom] = settings.SMTPFrom + updates[SettingKeySMTPFromName] = settings.SMTPFromName + updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS) + + // Cloudflare Turnstile 设置(只有非空才更新密钥) + updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled) + updates[SettingKeyTurnstileSiteKey] = settings.TurnstileSiteKey + if settings.TurnstileSecretKey != "" { + updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey + } + updates[SettingKeyAPIKeyACLTrustForwardedIP] = strconv.FormatBool(settings.APIKeyACLTrustForwardedIP) + + // LinuxDo Connect OAuth 登录 + updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled) + updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID + updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL + if settings.LinuxDoConnectClientSecret != "" { + updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret + } + + // DingTalk Connect OAuth 登录 + updates[SettingKeyDingTalkConnectEnabled] = strconv.FormatBool(settings.DingTalkConnectEnabled) + updates[SettingKeyDingTalkConnectClientID] = settings.DingTalkConnectClientID + updates[SettingKeyDingTalkConnectRedirectURL] = settings.DingTalkConnectRedirectURL + if settings.DingTalkConnectClientSecret != "" { + updates[SettingKeyDingTalkConnectClientSecret] = settings.DingTalkConnectClientSecret + } + updates[SettingKeyDingTalkConnectCorpRestrictionPolicy] = settings.DingTalkConnectCorpRestrictionPolicy + updates[SettingKeyDingTalkConnectInternalCorpID] = settings.DingTalkConnectInternalCorpID + updates[SettingKeyDingTalkConnectBypassRegistration] = strconv.FormatBool(settings.DingTalkConnectBypassRegistration) + updates[SettingKeyDingTalkConnectSyncCorpEmail] = strconv.FormatBool(settings.DingTalkConnectSyncCorpEmail) + updates[SettingKeyDingTalkConnectSyncDisplayName] = strconv.FormatBool(settings.DingTalkConnectSyncDisplayName) + updates[SettingKeyDingTalkConnectSyncDept] = strconv.FormatBool(settings.DingTalkConnectSyncDept) + updates[SettingKeyDingTalkConnectSyncCorpEmailAttrKey] = settings.DingTalkConnectSyncCorpEmailAttrKey + updates[SettingKeyDingTalkConnectSyncDisplayNameAttrKey] = settings.DingTalkConnectSyncDisplayNameAttrKey + updates[SettingKeyDingTalkConnectSyncDeptAttrKey] = settings.DingTalkConnectSyncDeptAttrKey + updates[SettingKeyDingTalkConnectSyncCorpEmailAttrName] = settings.DingTalkConnectSyncCorpEmailAttrName + updates[SettingKeyDingTalkConnectSyncDisplayNameAttrName] = settings.DingTalkConnectSyncDisplayNameAttrName + updates[SettingKeyDingTalkConnectSyncDeptAttrName] = settings.DingTalkConnectSyncDeptAttrName + + // Generic OIDC OAuth 登录 + updates[SettingKeyOIDCConnectEnabled] = strconv.FormatBool(settings.OIDCConnectEnabled) + updates[SettingKeyOIDCConnectProviderName] = settings.OIDCConnectProviderName + updates[SettingKeyOIDCConnectClientID] = settings.OIDCConnectClientID + updates[SettingKeyOIDCConnectIssuerURL] = settings.OIDCConnectIssuerURL + updates[SettingKeyOIDCConnectDiscoveryURL] = settings.OIDCConnectDiscoveryURL + updates[SettingKeyOIDCConnectAuthorizeURL] = settings.OIDCConnectAuthorizeURL + updates[SettingKeyOIDCConnectTokenURL] = settings.OIDCConnectTokenURL + updates[SettingKeyOIDCConnectUserInfoURL] = settings.OIDCConnectUserInfoURL + updates[SettingKeyOIDCConnectJWKSURL] = settings.OIDCConnectJWKSURL + updates[SettingKeyOIDCConnectScopes] = settings.OIDCConnectScopes + updates[SettingKeyOIDCConnectRedirectURL] = settings.OIDCConnectRedirectURL + updates[SettingKeyOIDCConnectFrontendRedirectURL] = settings.OIDCConnectFrontendRedirectURL + updates[SettingKeyOIDCConnectTokenAuthMethod] = settings.OIDCConnectTokenAuthMethod + updates[SettingKeyOIDCConnectUsePKCE] = strconv.FormatBool(settings.OIDCConnectUsePKCE) + updates[SettingKeyOIDCConnectValidateIDToken] = strconv.FormatBool(settings.OIDCConnectValidateIDToken) + updates[SettingKeyOIDCConnectAllowedSigningAlgs] = settings.OIDCConnectAllowedSigningAlgs + updates[SettingKeyOIDCConnectClockSkewSeconds] = strconv.Itoa(settings.OIDCConnectClockSkewSeconds) + updates[SettingKeyOIDCConnectRequireEmailVerified] = strconv.FormatBool(settings.OIDCConnectRequireEmailVerified) + updates[SettingKeyOIDCConnectUserInfoEmailPath] = settings.OIDCConnectUserInfoEmailPath + updates[SettingKeyOIDCConnectUserInfoIDPath] = settings.OIDCConnectUserInfoIDPath + updates[SettingKeyOIDCConnectUserInfoUsernamePath] = settings.OIDCConnectUserInfoUsernamePath + if settings.OIDCConnectClientSecret != "" { + updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret + } + + // GitHub / Google 邮箱快捷登录 + updates[SettingKeyGitHubOAuthEnabled] = strconv.FormatBool(settings.GitHubOAuthEnabled) + updates[SettingKeyGitHubOAuthClientID] = strings.TrimSpace(settings.GitHubOAuthClientID) + updates[SettingKeyGitHubOAuthRedirectURL] = settings.GitHubOAuthRedirectURL + updates[SettingKeyGitHubOAuthFrontendRedirectURL] = settings.GitHubOAuthFrontendRedirectURL + if settings.GitHubOAuthClientSecret != "" { + updates[SettingKeyGitHubOAuthClientSecret] = strings.TrimSpace(settings.GitHubOAuthClientSecret) + } + updates[SettingKeyGoogleOAuthEnabled] = strconv.FormatBool(settings.GoogleOAuthEnabled) + updates[SettingKeyGoogleOAuthClientID] = strings.TrimSpace(settings.GoogleOAuthClientID) + updates[SettingKeyGoogleOAuthRedirectURL] = settings.GoogleOAuthRedirectURL + updates[SettingKeyGoogleOAuthFrontendRedirectURL] = settings.GoogleOAuthFrontendRedirectURL + if settings.GoogleOAuthClientSecret != "" { + updates[SettingKeyGoogleOAuthClientSecret] = strings.TrimSpace(settings.GoogleOAuthClientSecret) + } + + // WeChat Connect OAuth 登录 + updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled) + updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID + updates[SettingKeyWeChatConnectOpenAppID] = settings.WeChatConnectOpenAppID + updates[SettingKeyWeChatConnectMPAppID] = settings.WeChatConnectMPAppID + updates[SettingKeyWeChatConnectMobileAppID] = settings.WeChatConnectMobileAppID + updates[SettingKeyWeChatConnectOpenEnabled] = strconv.FormatBool(settings.WeChatConnectOpenEnabled) + updates[SettingKeyWeChatConnectMPEnabled] = strconv.FormatBool(settings.WeChatConnectMPEnabled) + updates[SettingKeyWeChatConnectMobileEnabled] = strconv.FormatBool(settings.WeChatConnectMobileEnabled) + updates[SettingKeyWeChatConnectMode] = settings.WeChatConnectMode + updates[SettingKeyWeChatConnectScopes] = settings.WeChatConnectScopes + updates[SettingKeyWeChatConnectRedirectURL] = settings.WeChatConnectRedirectURL + updates[SettingKeyWeChatConnectFrontendRedirectURL] = settings.WeChatConnectFrontendRedirectURL + if settings.WeChatConnectAppSecret != "" { + updates[SettingKeyWeChatConnectAppSecret] = settings.WeChatConnectAppSecret + } + if settings.WeChatConnectOpenAppSecret != "" { + updates[SettingKeyWeChatConnectOpenAppSecret] = settings.WeChatConnectOpenAppSecret + } + if settings.WeChatConnectMPAppSecret != "" { + updates[SettingKeyWeChatConnectMPAppSecret] = settings.WeChatConnectMPAppSecret + } + if settings.WeChatConnectMobileAppSecret != "" { + updates[SettingKeyWeChatConnectMobileAppSecret] = settings.WeChatConnectMobileAppSecret + } + + // OEM设置 + updates[SettingKeySiteName] = settings.SiteName + updates[SettingKeySiteLogo] = settings.SiteLogo + updates[SettingKeySiteSubtitle] = settings.SiteSubtitle + updates[SettingKeyAPIBaseURL] = settings.APIBaseURL + updates[SettingKeyContactInfo] = settings.ContactInfo + updates[SettingKeyDocURL] = settings.DocURL + updates[SettingKeyHomeContent] = settings.HomeContent + updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton) + updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled) + updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL) + tableDefaultPageSize, tablePageSizeOptions := normalizeTablePreferences( + settings.TableDefaultPageSize, + settings.TablePageSizeOptions, + ) + updates[SettingKeyTableDefaultPageSize] = strconv.Itoa(tableDefaultPageSize) + tablePageSizeOptionsJSON, err := json.Marshal(tablePageSizeOptions) + if err != nil { + return nil, fmt.Errorf("marshal table page size options: %w", err) + } + updates[SettingKeyTablePageSizeOptions] = string(tablePageSizeOptionsJSON) + updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems + updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints + + // 默认配置 + updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) + updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64) + settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate) + updates[SettingKeyAffiliateRebateRate] = strconv.FormatFloat(settings.AffiliateRebateRate, 'f', 8, 64) + if settings.AffiliateRebateFreezeHours < 0 { + settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursDefault + } + if settings.AffiliateRebateFreezeHours > AffiliateRebateFreezeHoursMax { + settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursMax + } + updates[SettingKeyAffiliateRebateFreezeHours] = strconv.Itoa(settings.AffiliateRebateFreezeHours) + if settings.AffiliateRebateDurationDays < 0 { + settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysDefault + } + if settings.AffiliateRebateDurationDays > AffiliateRebateDurationDaysMax { + settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysMax + } + updates[SettingKeyAffiliateRebateDurationDays] = strconv.Itoa(settings.AffiliateRebateDurationDays) + if settings.AffiliateRebatePerInviteeCap < 0 { + settings.AffiliateRebatePerInviteeCap = AffiliateRebatePerInviteeCapDefault + } + updates[SettingKeyAffiliateRebatePerInviteeCap] = strconv.FormatFloat(settings.AffiliateRebatePerInviteeCap, 'f', 8, 64) + updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit) + defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions) + if err != nil { + return nil, fmt.Errorf("marshal default subscriptions: %w", err) + } + updates[SettingKeyDefaultSubscriptions] = string(defaultSubsJSON) + + // Model fallback configuration + updates[SettingKeyEnableModelFallback] = strconv.FormatBool(settings.EnableModelFallback) + updates[SettingKeyFallbackModelAnthropic] = settings.FallbackModelAnthropic + updates[SettingKeyFallbackModelOpenAI] = settings.FallbackModelOpenAI + updates[SettingKeyFallbackModelGemini] = settings.FallbackModelGemini + updates[SettingKeyFallbackModelAntigravity] = settings.FallbackModelAntigravity + + // Identity patch configuration (Claude -> Gemini) + updates[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch) + updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt + + // Ops monitoring (vNext) + updates[SettingKeyOpsMonitoringEnabled] = strconv.FormatBool(settings.OpsMonitoringEnabled) + updates[SettingKeyOpsRealtimeMonitoringEnabled] = strconv.FormatBool(settings.OpsRealtimeMonitoringEnabled) + updates[SettingKeyOpsQueryModeDefault] = string(ParseOpsQueryMode(settings.OpsQueryModeDefault)) + if settings.OpsMetricsIntervalSeconds > 0 { + updates[SettingKeyOpsMetricsIntervalSeconds] = strconv.Itoa(settings.OpsMetricsIntervalSeconds) + } + + // Channel monitor feature switch + updates[SettingKeyChannelMonitorEnabled] = strconv.FormatBool(settings.ChannelMonitorEnabled) + if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 { + updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v) + } + + // Available channels feature switch + updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled) + + // Affiliate (邀请返利) feature switch + updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled) + + // 风控中心功能开关 + updates[SettingKeyRiskControlEnabled] = strconv.FormatBool(settings.RiskControlEnabled) + + // cyber 会话屏蔽开关 + TTL + updates[SettingKeyCyberSessionBlockEnabled] = strconv.FormatBool(settings.CyberSessionBlockEnabled) + if settings.CyberSessionBlockTTLSeconds > 0 { + updates[SettingKeyCyberSessionBlockTTLSeconds] = strconv.Itoa(settings.CyberSessionBlockTTLSeconds) + } + + // Claude Code version check + updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion + updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion + + // 分组隔离 + updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling) + + // Backend Mode + updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled) + + // Gateway forwarding behavior + updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification) + updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough) + updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning) + updates[SettingKeyEnableClaudeOAuthSystemPromptInjection] = strconv.FormatBool(settings.EnableClaudeOAuthSystemPromptInjection) + updates[SettingKeyClaudeOAuthSystemPrompt] = settings.ClaudeOAuthSystemPrompt + if err := ValidateClaudeOAuthSystemPromptBlocksConfig(settings.ClaudeOAuthSystemPromptBlocks); err != nil { + return nil, err + } + updates[SettingKeyClaudeOAuthSystemPromptBlocks] = settings.ClaudeOAuthSystemPromptBlocks + updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection) + updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl) + updates[SettingKeyEnableClientDatelineNormalization] = strconv.FormatBool(settings.EnableClientDatelineNormalization) + updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion) + updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent) + // codex_cli_only 加固 + updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion) + updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion) + updates[SettingKeyCodexCLIOnlyBlacklist] = strings.TrimSpace(settings.CodexCLIOnlyBlacklist) + updates[SettingKeyCodexCLIOnlyWhitelist] = strings.TrimSpace(settings.CodexCLIOnlyWhitelist) + updates[SettingKeyCodexCLIOnlyAllowAppServerClients] = strconv.FormatBool(settings.CodexCLIOnlyAllowAppServerClients) + updates[SettingKeyCodexCLIOnlyEngineFingerprintSignals] = strings.TrimSpace(settings.CodexCLIOnlyEngineFingerprintSignals) + updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource + updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource + updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled) + updates[SettingPaymentVisibleMethodWxpayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodWxpayEnabled) + updates[openAIAdvancedSchedulerSettingKey] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerEnabled) + updates[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerStickyWeightedEnabled) + updates[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled) + updates[SettingKeyOpenAIAdvancedSchedulerLBTopK] = settings.OpenAIAdvancedSchedulerLBTopK + updates[SettingKeyOpenAIAdvancedSchedulerWeightPriority] = settings.OpenAIAdvancedSchedulerWeightPriority + updates[SettingKeyOpenAIAdvancedSchedulerWeightLoad] = settings.OpenAIAdvancedSchedulerWeightLoad + updates[SettingKeyOpenAIAdvancedSchedulerWeightQueue] = settings.OpenAIAdvancedSchedulerWeightQueue + updates[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate] = settings.OpenAIAdvancedSchedulerWeightErrorRate + updates[SettingKeyOpenAIAdvancedSchedulerWeightTTFT] = settings.OpenAIAdvancedSchedulerWeightTTFT + updates[SettingKeyOpenAIAdvancedSchedulerWeightReset] = settings.OpenAIAdvancedSchedulerWeightReset + updates[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom] = settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom + updates[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse] = settings.OpenAIAdvancedSchedulerWeightPreviousResponse + updates[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky] = settings.OpenAIAdvancedSchedulerWeightSessionSticky + + // 余额、订阅到期与账号限额通知 + updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled) + updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64) + updates[SettingKeyBalanceLowNotifyRechargeURL] = settings.BalanceLowNotifyRechargeURL + updates[SettingKeySubscriptionExpiryNotifyEnabled] = strconv.FormatBool(settings.SubscriptionExpiryNotifyEnabled) + updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled) + updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails) + + // 系统全局 platform quota:整体替换语义(null/缺省 = 不限制)。 + if settings.DefaultPlatformQuotas != nil { + if err := validateDefaultPlatformQuotaMap(settings.DefaultPlatformQuotas); err != nil { + return nil, err + } + blob, err := json.Marshal(settings.DefaultPlatformQuotas) + if err != nil { + return nil, fmt.Errorf("marshal default platform quotas: %w", err) + } + updates[SettingKeyDefaultPlatformQuotas] = string(blob) + } + + updates[SettingKeyAllowUserViewErrorRequests] = strconv.FormatBool(settings.AllowUserViewErrorRequests) + + return updates, nil +} + +// validateDefaultPlatformQuotaMap 校验 platform quota map 的合法性: +// 平台名须在 AllowedQuotaPlatforms 白名单内,每个非 nil 上限须 finite 且 >= 0。 +// 系统层和 auth-source 层共用此 helper。 +func validateDefaultPlatformQuotaMap(m map[string]*DefaultPlatformQuotaSetting) error { + for platform, pq := range m { + if !IsAllowedQuotaPlatform(platform) { + return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", fmt.Sprintf("unknown platform %q", platform)) + } + if pq == nil { + continue + } + for _, v := range []*float64{pq.DailyLimitUSD, pq.WeeklyLimitUSD, pq.MonthlyLimitUSD} { + if v != nil && (*v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0)) { + return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", "platform quota limit must be a finite non-negative number") + } + } + } + return nil +} + +func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, settings *AuthSourceDefaultSettings) (map[string]string, error) { + if settings == nil { + return nil, nil + } + + for _, subscriptions := range [][]DefaultSubscriptionSetting{ + settings.Email.Subscriptions, + settings.LinuxDo.Subscriptions, + settings.OIDC.Subscriptions, + settings.WeChat.Subscriptions, + settings.GitHub.Subscriptions, + settings.Google.Subscriptions, + settings.DingTalk.Subscriptions, + } { + if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil { + return nil, err + } + } + + // 校验各 auth source 的 platform quota map(改动 C:对等系统层校验) + for _, pgs := range []struct { + name string + pq map[string]*DefaultPlatformQuotaSetting + }{ + {"email", settings.Email.PlatformQuotas}, + {"linuxdo", settings.LinuxDo.PlatformQuotas}, + {"oidc", settings.OIDC.PlatformQuotas}, + {"wechat", settings.WeChat.PlatformQuotas}, + {"github", settings.GitHub.PlatformQuotas}, + {"google", settings.Google.PlatformQuotas}, + {"dingtalk", settings.DingTalk.PlatformQuotas}, + } { + if pgs.pq != nil { + if err := validateDefaultPlatformQuotaMap(pgs.pq); err != nil { + return nil, err + } + } + } + + updates := make(map[string]string, 36) + writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email) + writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo) + writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC) + writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat) + writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub) + writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google) + writeProviderDefaultGrantUpdates(updates, dingTalkAuthSourceDefaultKeys, settings.DingTalk) + updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup) + return updates, nil +} + +func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { + if settings == nil { + return + } + + // 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口 + versionBoundsSF.Forget("version_bounds") + versionBoundsCache.Store(&cachedVersionBounds{ + min: settings.MinClaudeCodeVersion, + max: settings.MaxClaudeCodeVersion, + expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(), + }) + backendModeSF.Forget("backend_mode") + backendModeCache.Store(&cachedBackendMode{ + value: settings.BackendModeEnabled, + expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(), + }) + gatewayForwardingSF.Forget("gateway_forwarding") + gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ + fingerprintUnification: settings.EnableFingerprintUnification, + metadataPassthrough: settings.EnableMetadataPassthrough, + cchSigning: settings.EnableCCHSigning, + claudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection, + claudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt, + claudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks, + anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, + rewriteMessageCacheControl: settings.RewriteMessageCacheControl, + clientDatelineNormalization: settings.EnableClientDatelineNormalization, + expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), + }) + s.antigravityUAVersionSF.Forget("antigravity_user_agent_version") + antigravityUserAgentVersion := antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion) + if antigravityUserAgentVersion == "" { + antigravityUserAgentVersion = antigravity.GetDefaultUserAgentVersion() + } + s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{ + version: antigravityUserAgentVersion, + expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(), + }) + s.openAICodexUASF.Forget("openai_codex_user_agent") + codexUA := strings.TrimSpace(settings.OpenAICodexUserAgent) + if codexUA == "" { + codexUA = DefaultOpenAICodexUserAgent + } + s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{ + value: codexUA, + expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(), + }) + openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey) + openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ + enabled: settings.OpenAIAdvancedSchedulerEnabled, + stickyWeightedEnabled: settings.OpenAIAdvancedSchedulerStickyWeightedEnabled, + subscriptionPriorityEnabled: settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled, + lbTopKOverride: parsePositiveIntOverride(settings.OpenAIAdvancedSchedulerLBTopK), + weightOverrides: parseOpenAIAdvancedSchedulerWeightOverrides(map[string]string{ + SettingKeyOpenAIAdvancedSchedulerWeightPriority: settings.OpenAIAdvancedSchedulerWeightPriority, + SettingKeyOpenAIAdvancedSchedulerWeightLoad: settings.OpenAIAdvancedSchedulerWeightLoad, + SettingKeyOpenAIAdvancedSchedulerWeightQueue: settings.OpenAIAdvancedSchedulerWeightQueue, + SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: settings.OpenAIAdvancedSchedulerWeightErrorRate, + SettingKeyOpenAIAdvancedSchedulerWeightTTFT: settings.OpenAIAdvancedSchedulerWeightTTFT, + SettingKeyOpenAIAdvancedSchedulerWeightReset: settings.OpenAIAdvancedSchedulerWeightReset, + SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, + SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: settings.OpenAIAdvancedSchedulerWeightPreviousResponse, + SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: settings.OpenAIAdvancedSchedulerWeightSessionSticky, + }), + expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(), + }) + // Invalidate the quota auto-pause cache and let the next read trigger a fresh load. + // We can't know from here whether ops_advanced_settings was also touched, so be + // defensive: store an expired entry — GetOpenAIQuotaAutoPauseSettings will serve + // stale and kick off an async refresh, never blocking the request that follows. + s.openAIQuotaAutoPauseSettingsSF.Forget(openAIQuotaAutoPauseSettingsRefreshKey) + if cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); cached != nil { + s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{ + settings: cached.settings, + expiresAt: 0, + }) + } + if s.cfg != nil { + s.cfg.SetTrustForwardedIPForAPIKeyACL(settings.APIKeyACLTrustForwardedIP) + } + // codex_cli_only 加固策略缓存:设置更新后强制下次重载(涉及 4 个键 + JSON 解析,直接置过期)。 + s.codexRestrictionPolicySF.Forget("codex_restriction_policy") + s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) + if s.onUpdate != nil { + s.onUpdate() // Invalidate cache after settings update + } +} + +func (s *SettingService) defaultRewriteMessageCacheControl() bool { + return false +} + +func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context, items []DefaultSubscriptionSetting) error { + if len(items) == 0 { + return nil + } + + checked := make(map[int64]struct{}, len(items)) + for _, item := range items { + if item.GroupID <= 0 { + continue + } + if _, ok := checked[item.GroupID]; ok { + return ErrDefaultSubGroupDuplicate.WithMetadata(map[string]string{ + "group_id": strconv.FormatInt(item.GroupID, 10), + }) + } + checked[item.GroupID] = struct{}{} + if s.defaultSubGroupReader == nil { + continue + } + + group, err := s.defaultSubGroupReader.GetByID(ctx, item.GroupID) + if err != nil { + if errors.Is(err, ErrGroupNotFound) { + return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{ + "group_id": strconv.FormatInt(item.GroupID, 10), + }) + } + return fmt.Errorf("get default subscription group %d: %w", item.GroupID, err) + } + if !group.IsSubscriptionType() { + return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{ + "group_id": strconv.FormatInt(item.GroupID, 10), + }) + } + } + + return nil +} diff --git a/frontend/src/i18n/__tests__/localesNoKeyCollision.spec.ts b/frontend/src/i18n/__tests__/localesNoKeyCollision.spec.ts new file mode 100644 index 0000000000..6e89d10de9 --- /dev/null +++ b/frontend/src/i18n/__tests__/localesNoKeyCollision.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' + +import enAdminAccounts from '../locales/en/admin/accounts' +import enAdminChannels from '../locales/en/admin/channels' +import enAdminOps from '../locales/en/admin/ops' +import enAdminOverview from '../locales/en/admin/overview' +import enAdminResources from '../locales/en/admin/resources' +import enAdminSettings from '../locales/en/admin/settings' +import enCommon from '../locales/en/common' +import enDashboard from '../locales/en/dashboard' +import enLanding from '../locales/en/landing' +import enMisc from '../locales/en/misc' +import zhAdminAccounts from '../locales/zh/admin/accounts' +import zhAdminChannels from '../locales/zh/admin/channels' +import zhAdminOps from '../locales/zh/admin/ops' +import zhAdminOverview from '../locales/zh/admin/overview' +import zhAdminResources from '../locales/zh/admin/resources' +import zhAdminSettings from '../locales/zh/admin/settings' +import zhCommon from '../locales/zh/common' +import zhDashboard from '../locales/zh/dashboard' +import zhLanding from '../locales/zh/landing' +import zhMisc from '../locales/zh/misc' + +// locales/{zh,en}/index.ts 与 admin/index.ts 使用对象展开聚合各域模块, +// 展开模块之间若出现同名顶层键会静默覆盖。本测试将该风险固化为显式失败。 +type Modules = Record> + +function collisions(modules: Modules): string[] { + const seen = new Map() + const out: string[] = [] + for (const [name, mod] of Object.entries(modules)) { + for (const key of Object.keys(mod)) { + const prev = seen.get(key) + if (prev) { + out.push(`"${key}" in both ${prev} and ${name}`) + } else { + seen.set(key, name) + } + } + } + return out +} + +const roots: Record = { + zh: { landing: zhLanding, common: zhCommon, dashboard: zhDashboard, misc: zhMisc }, + en: { landing: enLanding, common: enCommon, dashboard: enDashboard, misc: enMisc } +} + +const admins: Record = { + zh: { + overview: zhAdminOverview, + channels: zhAdminChannels, + accounts: zhAdminAccounts, + resources: zhAdminResources, + ops: zhAdminOps, + settings: zhAdminSettings + }, + en: { + overview: enAdminOverview, + channels: enAdminChannels, + accounts: enAdminAccounts, + resources: enAdminResources, + ops: enAdminOps, + settings: enAdminSettings + } +} + +describe.each(Object.keys(roots))('locale %s spread assembly', (locale) => { + it('root modules have no overlapping top-level keys', () => { + expect(collisions(roots[locale])).toEqual([]) + }) + + it('root modules do not shadow the explicit "admin" namespace', () => { + for (const [name, mod] of Object.entries(roots[locale])) { + expect(Object.keys(mod), `module ${name} must not define "admin"`).not.toContain('admin') + } + }) + + it('admin modules have no overlapping top-level keys', () => { + expect(collisions(admins[locale])).toEqual([]) + }) +}) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts deleted file mode 100644 index 6761311469..0000000000 --- a/frontend/src/i18n/locales/en.ts +++ /dev/null @@ -1,7554 +0,0 @@ -export default { - batchImageGuide: { - title: 'Batch Image Generation', - description: 'Submit multiple prompts in one job and download the generated images when complete' - }, - // Home Page - home: { - viewOnGithub: 'View on GitHub', - viewDocs: 'View Documentation', - docs: 'Docs', - switchToLight: 'Switch to Light Mode', - switchToDark: 'Switch to Dark Mode', - dashboard: 'Dashboard', - login: 'Login', - getStarted: 'Get Started', - goToDashboard: 'Go to Dashboard', - // User-focused value proposition - heroSubtitle: 'One Key, All AI Models', - heroDescription: 'No need to manage multiple subscriptions. Access Claude, GPT, Gemini and more with a single API key', - tags: { - subscriptionToApi: 'Subscription to API', - stickySession: 'Session Persistence', - realtimeBilling: 'Pay As You Go' - }, - // Pain points section - painPoints: { - title: 'Sound Familiar?', - items: { - expensive: { - title: 'High Subscription Costs', - desc: 'Paying for multiple AI subscriptions that add up every month' - }, - complex: { - title: 'Account Chaos', - desc: 'Managing scattered accounts and API keys across different platforms' - }, - unstable: { - title: 'Service Interruptions', - desc: 'Single accounts hitting rate limits and disrupting your workflow' - }, - noControl: { - title: 'No Usage Control', - desc: "Can't track where your money goes or limit team member usage" - } - } - }, - // Solutions section - solutions: { - title: 'We Solve These Problems', - subtitle: 'Three simple steps to stress-free AI access' - }, - features: { - unifiedGateway: 'One-Click Access', - unifiedGatewayDesc: 'Get a single API key to call all connected AI models. No separate applications needed.', - multiAccount: 'Always Reliable', - multiAccountDesc: 'Smart routing across multiple upstream accounts with automatic failover. Say goodbye to errors.', - balanceQuota: 'Pay What You Use', - balanceQuotaDesc: 'Usage-based billing with quota limits. Full visibility into team consumption.' - }, - // Comparison section - comparison: { - title: 'Why Choose Us?', - headers: { - feature: 'Comparison', - official: 'Official Subscriptions', - us: 'Our Platform' - }, - items: { - pricing: { - feature: 'Pricing', - official: 'Fixed monthly fee, pay even if unused', - us: 'Pay only for what you use' - }, - models: { - feature: 'Model Selection', - official: 'Single provider only', - us: 'Switch between models freely' - }, - management: { - feature: 'Account Management', - official: 'Manage each service separately', - us: 'Unified key, one dashboard' - }, - stability: { - feature: 'Stability', - official: 'Single account rate limits', - us: 'Multi-account pool, auto-failover' - }, - control: { - feature: 'Usage Control', - official: 'Not available', - us: 'Quotas & detailed analytics' - } - } - }, - providers: { - title: 'Supported AI Models', - description: 'One API, Multiple Choices', - supported: 'Supported', - soon: 'Soon', - claude: 'Claude', - gemini: 'Gemini', - antigravity: 'Antigravity', - more: 'More' - }, - // CTA section - cta: { - title: 'Ready to Get Started?', - description: 'Sign up now and get free trial credits to experience seamless AI access', - button: 'Sign Up Free' - }, - footer: { - allRightsReserved: 'All rights reserved.' - } - }, - - // Key Usage Query Page - keyUsage: { - title: 'API Key Usage', - subtitle: 'Enter your API Key to view real-time spending and usage status', - placeholder: 'sk-ant-mirror-xxxxxxxxxxxx', - query: 'Query', - querying: 'Querying...', - privacyNote: 'Your Key is processed locally in the browser and will not be stored', - dateRange: 'Date Range:', - dateRangeToday: 'Today', - dateRange7d: '7 Days', - dateRange30d: '30 Days', - dateRange90d: '90 Days', - dateRangeCustom: 'Custom', - apply: 'Apply', - used: 'Used', - detailInfo: 'Detail Information', - tokenStats: 'Token Statistics', - dailyDetail: 'Daily Detail', - modelStats: 'Model Usage Statistics', - // Table headers - date: 'Date', - model: 'Model', - requests: 'Requests', - inputTokens: 'Input Tokens', - outputTokens: 'Output Tokens', - cacheCreationTokens: 'Cache Creation', - cacheReadTokens: 'Cache Read', - cacheWriteTokens: 'Cache Write', - totalTokens: 'Total Tokens', - cost: 'Cost', - // Status - quotaMode: 'Key Quota Mode', - walletBalance: 'Wallet Balance', - // Ring card titles - totalQuota: 'Total Quota', - limit5h: '5-Hour Limit', - limitDaily: 'Daily Limit', - limit7d: '7-Day Limit', - limitWeekly: 'Weekly Limit', - limitMonthly: 'Monthly Limit', - // Detail rows - remainingQuota: 'Remaining Quota', - expiresAt: 'Expires At', - todayExpires: '(expires today)', - daysLeft: '({days} days)', - usedQuota: 'Used Quota', - resetNow: 'Resetting soon', - subscriptionType: 'Subscription Type', - subscriptionExpires: 'Subscription Expires', - // Usage stat cells - todayRequests: 'Today Requests', - todayInputTokens: 'Today Input', - todayOutputTokens: 'Today Output', - todayTokens: 'Today Tokens', - todayCacheCreation: 'Today Cache Creation', - todayCacheRead: 'Today Cache Read', - todayCost: 'Today Cost', - rpmTpm: 'RPM / TPM', - totalRequests: 'Total Requests', - totalInputTokens: 'Total Input', - totalOutputTokens: 'Total Output', - totalTokensLabel: 'Total Tokens', - totalCacheCreation: 'Total Cache Creation', - totalCacheRead: 'Total Cache Read', - totalCost: 'Total Cost', - avgDuration: 'Avg Duration', - // Messages - enterApiKey: 'Please enter an API Key', - querySuccess: 'Query successful', - queryFailed: 'Query failed', - queryFailedRetry: 'Query failed, please try again later', - noDailyUsage: 'No daily usage data', - }, - - // Setup Wizard - setup: { - title: 'Sub2API Setup', - description: 'Configure your Sub2API instance', - database: { - title: 'Database Configuration', - description: 'Connect to your PostgreSQL database', - host: 'Host', - port: 'Port', - username: 'Username', - password: 'Password', - databaseName: 'Database Name', - sslMode: 'SSL Mode', - passwordPlaceholder: 'Password', - ssl: { - disable: 'Disable', - require: 'Require', - verifyCa: 'Verify CA', - verifyFull: 'Verify Full' - } - }, - redis: { - title: 'Redis Configuration', - description: 'Connect to your Redis server', - host: 'Host', - port: 'Port', - password: 'Password (optional)', - database: 'Database', - passwordPlaceholder: 'Password', - enableTls: 'Enable TLS', - enableTlsHint: 'Use TLS when connecting to Redis (public CA certs)' - }, - admin: { - title: 'Admin Account', - description: 'Create your administrator account', - email: 'Email', - password: 'Password', - confirmPassword: 'Confirm Password', - passwordPlaceholder: 'Min 8 characters', - confirmPasswordPlaceholder: 'Confirm password', - passwordMismatch: 'Passwords do not match' - }, - ready: { - title: 'Ready to Install', - description: 'Review your configuration and complete setup', - database: 'Database', - redis: 'Redis', - adminEmail: 'Admin Email' - }, - status: { - testing: 'Testing...', - success: 'Connection Successful', - testConnection: 'Test Connection', - installing: 'Installing...', - completeInstallation: 'Complete Installation', - completed: 'Installation completed!', - redirecting: 'Redirecting to login page...', - restarting: 'Service is restarting, please wait...', - timeout: 'Service restart is taking longer than expected. Please refresh the page manually.' - } - }, - - // Common - common: { - loading: 'Loading...', - submitting: 'Submitting...', - justNow: 'just now', - peakRateTooltip: 'Peak rate: {window}', - peakRateImageNote: '; image tokens billed as tokens are also affected, per-image billing is unaffected', - save: 'Save', - saved: 'Saved successfully', - deleted: 'Deleted successfully', - cancel: 'Cancel', - delete: 'Delete', - edit: 'Edit', - create: 'Create', - update: 'Update', - confirm: 'Confirm', - reset: 'Reset', - search: 'Search', - filter: 'Filter', - export: 'Export', - import: 'Import', - actions: 'Actions', - status: 'Status', - name: 'Name', - email: 'Email', - password: 'Password', - submit: 'Submit', - back: 'Back', - next: 'Next', - yes: 'Yes', - no: 'No', - all: 'All', - none: 'None', - selectAll: 'Select all', - noData: 'No data', - expand: 'Expand', - collapse: 'Collapse', - success: 'Success', - error: 'Error', - critical: 'Critical', - warning: 'Warning', - info: 'Info', - active: 'Active', - inactive: 'Inactive', - more: 'More', - close: 'Close', - enabled: 'Enabled', - disabled: 'Disabled', - total: 'Total', - balance: 'Balance', - availableBalance: 'Available balance', - frozenBalance: 'Frozen balance', - totalBalance: 'Total balance', - available: 'Available', - copiedToClipboard: 'Copied to clipboard', - copied: 'Copied', - copyFailed: 'Failed to copy', - verifying: 'Verifying...', - processing: 'Processing...', - contactSupport: 'Contact Support', - add: 'Add', - invalidEmail: 'Please enter a valid email address', - optional: 'optional', - selectOption: 'Select an option', - searchPlaceholder: 'Search...', - noOptionsFound: 'No options found', - noGroupsAvailable: 'No groups available', - unknownError: 'Unknown error occurred', - saving: 'Saving...', - selectedCount: '({count} selected)', - refresh: 'Refresh', - autoRefresh: { - title: 'Auto Refresh', - enable: 'Enable auto refresh', - countdown: 'Auto refresh: {seconds}s', - seconds: '{n} seconds', - }, - view: 'View', - settings: 'Settings', - chooseFile: 'Choose File', - copy: 'Copy', - notAvailable: 'N/A', - now: 'Now', - today: 'Today', - tomorrow: 'Tomorrow', - unknown: 'Unknown', - minutes: 'min', - time: { - never: 'Never', - justNow: 'Just now', - minutesAgo: '{n}m ago', - hoursAgo: '{n}h ago', - daysAgo: '{n}d ago', - countdown: { - daysHours: '{d}d {h}h', - hoursMinutes: '{h}h {m}m', - minutes: '{m}m', - withSuffix: '{time} to lift' - } - } - }, - - adminCompliance: { - title: 'Deployment and Operation Compliance Acknowledgment', - blockingNotice: 'Deployment and operation compliance acknowledgment is required before continuing to use the console.', - riskNotice: 'This acknowledgment provides clear, conspicuous, and reproducible notice of compliance obligations and operation risks for self-hosted instances.', - version: 'Document Version', - openDocument: 'Open the GitHub document', - documentSource: 'The agreement text comes from Markdown files in this project repository. When the agreement content changes, the document version must be incremented; acknowledgments of older versions become invalid and console users must acknowledge again.', - inputLabel: 'Type the following confirmation phrase exactly', - inputPlaceholder: 'Type the confirmation phrase to continue', - inputMismatch: 'The confirmation phrase does not match. Type the displayed text exactly.', - legalNote: 'This acknowledgment defines the no-affiliation relationship and responsibility boundary between self-hosted instances and the open-source project, copyright holders, contributors, and maintainers. The party that deploys, operates, or controls the relevant instance remains independently responsible for its applicable obligations.', - logout: 'Log out', - accept: 'Acknowledge and Continue', - accepted: 'Compliance acknowledgment recorded', - acceptFailed: 'Failed to submit acknowledgment' - }, - - legal: { - loadFailed: 'Failed to load document', - retryLater: 'Refresh the page and try again later.', - notFound: 'Document not found', - notFoundDescription: 'This legal document does not exist or has been removed by an administrator.', - updatedAt: 'Updated: {date}', - empty: 'No content', - loginAgreement: 'Login Agreement', - adminCompliance: 'Deployment and Operation Compliance Commitment', - loginAgreementPrompt: { - checkboxPrefix: 'I have read and agree to ', - documentSeparator: ', ', - noticeTitle: 'Accept the latest terms before continuing.', - noticeDescription: 'Account/password login and quick sign-in stay disabled until you accept.', - viewTerms: 'View terms', - dialogTitle: 'Terms Update Notice', - dialogDescription: 'Our service terms were updated on {date}. Please read and accept the following terms before continuing.', - recently: 'recently', - relatedDocuments: 'Related documents', - reject: 'Reject', - accept: 'Accept and continue', - loginRejectedWarning: 'Account/password login and quick sign-in are disabled until you accept the latest terms.', - loginRequiredWarning: 'Please read and accept the latest terms before logging in.', - registerRejectedWarning: 'Registration and quick sign-in are disabled until you accept the latest terms.', - registerRequiredWarning: 'Please read and accept the latest terms before registering.' - } - }, - - // Navigation - nav: { - dashboard: 'Dashboard', - announcements: 'Announcements', - apiKeys: 'API Keys', - batchImage: 'Batch Images', - usage: 'Usage', - redeem: 'Redeem', - affiliate: 'Affiliate Rebates', - affiliateManagement: 'Affiliate Rebates', - affiliateInviteRecords: 'Invite Records', - affiliateRebateRecords: 'Rebate Records', - affiliateTransferRecords: 'Transfer Records', - profile: 'Profile', - users: 'Users', - groups: 'Groups', - channels: 'Channels', - availableChannels: 'Available Channels', - subscriptions: 'Subscriptions', - accounts: 'Accounts', - proxies: 'Proxies', - redeemCodes: 'Redeem Codes', - ops: 'Ops', - promoCodes: 'Promo Codes', - settings: 'Settings', - myAccount: 'My Account', - lightMode: 'Light Mode', - darkMode: 'Dark Mode', - collapse: 'Collapse', - expand: 'Expand', - logout: 'Logout', - github: 'GitHub', - mySubscriptions: 'My Subscriptions', - buySubscription: 'Recharge / Subscription', - docs: 'Docs', - myOrders: 'My Orders', - orderManagement: 'Orders', - paymentDashboard: 'Payment Dashboard', - paymentConfig: 'Payment Config', - paymentPlans: 'Plans', - channelManagement: 'Channels', - channelPricing: 'Channel Pricing', - channelMonitor: 'Channel Monitor', - channelStatus: 'Channel Status', - riskControl: 'Risk Control', - }, - - // Auth - auth: { - welcomeBack: 'Welcome Back', - signInToAccount: 'Sign in to your account to continue', - signIn: 'Sign In', - signingIn: 'Signing in...', - createAccount: 'Create Account', - signUpToStart: 'Sign up to start using {siteName}', - signUp: 'Sign up', - processing: 'Processing...', - continue: 'Continue', - rememberMe: 'Remember me', - dontHaveAccount: "Don't have an account?", - alreadyHaveAccount: 'Already have an account?', - registrationDisabled: 'Registration is currently disabled. Please contact the administrator.', - emailLabel: 'Email', - emailPlaceholder: 'Enter your email', - passwordLabel: 'Password', - passwordPlaceholder: 'Enter your password', - createPasswordPlaceholder: 'Create a strong password', - passwordHint: 'At least 6 characters', - emailRequired: 'Email is required', - invalidEmail: 'Please enter a valid email address', - passwordRequired: 'Password is required', - passwordMinLength: 'Password must be at least 6 characters', - loginFailed: 'Login failed. Please check your credentials and try again.', - errors: { - USER_NOT_ACTIVE: 'Account has been disabled.', - }, - registrationFailed: 'Registration failed. Please try again.', - emailSuffixNotAllowed: 'This email domain is not allowed for registration.', - emailSuffixNotAllowedWithAllowed: - 'This email domain is not allowed. Allowed domains: {suffixes}', - emailSuffixAllowedMore: 'and {count} more', - loginSuccess: 'Login successful! Welcome back.', - accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.', - reloginRequired: 'Session expired. Please log in again.', - turnstileExpired: 'Verification expired, please try again', - turnstileFailed: 'Verification failed, please try again', - completeVerification: 'Please complete the verification', - verifyYourEmail: 'Verify Your Email', - sessionExpired: 'Session expired', - sessionExpiredDesc: 'Please go back to the registration page and start again.', - verificationCode: 'Verification Code', - verificationCodeHint: 'Enter the 6-digit code sent to your email', - sendingCode: 'Sending...', - sendCode: 'Send code', - clickToResend: 'Click to resend code', - resendCode: 'Resend verification code', - sendCodeDesc: "We'll send a verification code to", - codeSentSuccess: 'Verification code sent! Please check your inbox.', - verifying: 'Verifying...', - verifyAndCreate: 'Verify & Create Account', - resendCountdown: 'Resend code in {countdown}s', - backToRegistration: 'Back to registration', - sendCodeFailed: 'Failed to send verification code. Please try again.', - verifyFailed: 'Verification failed. Please try again.', - codeRequired: 'Verification code is required', - invalidCode: 'Please enter a valid 6-digit code', - promoCodeLabel: 'Promo Code', - promoCodePlaceholder: 'Enter promo code (optional)', - promoCodeValid: 'Valid! You will receive ${amount} bonus balance', - promoCodeInvalid: 'Invalid promo code', - promoCodeNotFound: 'Promo code not found', - promoCodeExpired: 'This promo code has expired', - promoCodeDisabled: 'This promo code is disabled', - promoCodeMaxUsed: 'This promo code has reached its usage limit', - promoCodeAlreadyUsed: 'You have already used this promo code', - promoCodeValidating: 'Promo code is being validated, please wait', - promoCodeInvalidCannotRegister: 'Invalid promo code. Please check and try again or clear the promo code field', - invitationCodeLabel: 'Invitation Code', - invitationCodePlaceholder: 'Enter invitation code', - invitationCodeRequired: 'Invitation code is required', - invitationCodeValid: 'Invitation code is valid', - invitationCodeInvalid: 'Invalid or used invitation code', - invitationCodeValidating: 'Validating invitation code...', - invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again', - oauthOrContinue: 'or continue with others', - linuxdo: { - signIn: 'Continue with Linux.do', - orContinue: 'or continue with email', - callbackTitle: 'Signing you in', - callbackProcessing: 'Completing login, please wait...', - callbackHint: 'If you are not redirected automatically, go back to the login page and try again.', - callbackMissingToken: 'Missing login token, please try again.', - backToLogin: 'Back to Login', - invitationRequired: 'This Linux.do account is not yet registered. The site requires an invitation code — please enter one to complete registration.', - invalidPendingToken: 'The registration token has expired. Please sign in with Linux.do again.', - completeRegistration: 'Complete Registration', - completing: 'Completing registration…', - completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.' - }, - dingtalk: { - signIn: 'Continue with DingTalk', - callbackTitle: 'Signing you in with DingTalk', - callbackProcessing: 'Completing DingTalk login, please wait...', - callbackHint: 'If you are not redirected automatically, go back to the login page and try again.', - callbackMissingToken: 'Missing login token, please try again.', - backToLogin: 'Back to Login', - invitationRequired: 'This DingTalk account is not yet registered. The site requires an invitation code — please enter one to complete registration.', - invalidPendingToken: 'The registration token has expired. Please sign in with DingTalk again.', - completeRegistration: 'Complete Registration', - completing: 'Completing registration…', - completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.', - createAccountTitle: 'Create DingTalk Account', - registrationDisabledRedirectToBind: 'New account registration is currently disabled. Please bind to your existing account with its email and password.', - error: { - title: 'DingTalk Sign-in Failed', - csrf: 'Login session expired, please scan again', - corp_rejected: 'Your DingTalk account is not part of this organization. Please contact administrator', - dingtalk_not_enabled: 'DingTalk login is not enabled', - upstream_error: 'DingTalk service is temporarily unavailable. Please try again later', - missing_browser_session: 'Browser session lost. Please login again', - missing_params: 'Request parameters are incomplete', - invalid_state: 'Invalid login state', - provider_error: 'DingTalk authorization failed', - session_error: 'Failed to create session. Please retry', - retry: 'Retry Login' - } - }, - emailOAuth: { - signIn: 'Continue with {providerName}' - }, - oidc: { - signIn: 'Continue with {providerName}', - callbackTitle: 'Signing you in with {providerName}', - callbackProcessing: 'Completing login with {providerName}, please wait...', - callbackHint: 'If you are not redirected automatically, go back to the login page and try again.', - callbackMissingToken: 'Missing login token, please try again.', - backToLogin: 'Back to Login', - invitationRequired: - 'This {providerName} account is not yet registered. The site requires an invitation code — please enter one to complete registration.', - invalidPendingToken: 'The registration token has expired. Please sign in again.', - completeRegistration: 'Complete Registration', - completing: 'Completing registration…', - completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.' - }, - oauthFlow: { - profileDetailsTitle: 'Use {providerName} profile details', - profileDetailsDescription: 'Choose whether to apply the nickname or avatar from {providerName} to this account.', - useDisplayName: 'Use display name', - useAvatar: 'Use avatar', - avatarAlt: '{providerName} avatar', - reviewProfileBeforeContinue: 'Review the {providerName} profile details before continuing.', - chooseHowToContinue: 'Choose how to continue', - chooseAccountActionHint: 'Choose whether to bind an existing account or create a new one.', - suggestedEmail: 'Suggested email: {email}', - bindExistingAccount: 'Bind existing account', - createNewAccount: 'Create new account', - createAccountHint: 'Enter an email address to create your account and continue.', - bindLoginHint: 'Log in to an existing account to bind this {providerName} sign-in.', - signInThenBindDescription: 'Sign in to an existing account, then bind this {providerName} sign-in to it.', - bindSignInToExistingAccount: 'Bind this {providerName} sign-in to an existing account.', - bindCurrentAccountTitle: 'Bind the current account', - bindCurrentAccountDescription: 'Bind this {providerName} sign-in to the account currently signed in on this browser.', - bindCurrentAccount: 'Bind current account', - logInAndBind: 'Log in and bind', - useDifferentEmail: 'Use a different email', - backToOptions: 'Back to options', - yourAccount: 'your account', - totpHint: 'Enter the 6-digit verification code for {account} to finish binding this {providerName} sign-in.', - verifyAndContinue: 'Verify and continue', - wechatAvailabilityUnknown: 'WeChat sign-in availability could not be confirmed. Refresh and retry.', - wechatSystemBrowserOnly: 'This WeChat sign-in flow is only available in your system browser.', - wechatBrowserOnly: 'This WeChat sign-in flow is only available inside the WeChat browser.', - wechatNotConfigured: 'WeChat sign-in is not configured yet.' - }, - linuxdoCallbackPageTitle: 'LinuxDo Sign-In Callback', - dingtalkCallbackPageTitle: 'DingTalk Sign-In Callback', - dingtalkProviderName: 'DingTalk', - oidcCallbackPageTitle: 'OIDC Sign-In Callback', - oauthCallbackPageTitle: 'OAuth Callback', - wechatProviderName: 'WeChat', - wechatCallbackPageTitle: 'WeChat Sign-In Callback', - wechatPaymentCallbackPageTitle: 'WeChat Payment Callback', - wechatPayment: { - callbackTitle: 'Resuming WeChat payment', - callbackProcessing: 'Resuming WeChat payment...', - backToPayment: 'Back to payment', - callbackMissingResumeToken: 'The WeChat payment callback is missing the resume token.' - }, - oauth: { - callbackTitle: 'OAuth Callback', - callbackHint: 'Copy the code and state back to the admin authorization flow when needed.', - invalidCallbackTitle: 'Invalid sign-in callback', - invalidCallbackHint: 'This page does not contain a valid authorization result. Return to the login page and start quick sign-in again.', - code: 'Code', - state: 'State', - fullUrl: 'Full URL' - }, - // Forgot password - forgotPassword: 'Forgot password?', - forgotPasswordTitle: 'Reset Your Password', - forgotPasswordHint: 'Enter your email address and we will send you a link to reset your password.', - sendResetLink: 'Send Reset Link', - sendingResetLink: 'Sending...', - sendResetLinkFailed: 'Failed to send reset link. Please try again.', - resetEmailSent: 'Reset Link Sent', - resetEmailSentHint: 'If an account exists with this email, you will receive a password reset link shortly. Please check your inbox and spam folder.', - backToLogin: 'Back to Login', - rememberedPassword: 'Remembered your password?', - // Reset password - resetPasswordTitle: 'Set New Password', - resetPasswordHint: 'Enter your new password below.', - newPassword: 'New Password', - newPasswordPlaceholder: 'Enter your new password', - confirmPassword: 'Confirm Password', - confirmPasswordPlaceholder: 'Confirm your new password', - confirmPasswordRequired: 'Please confirm your password', - passwordsDoNotMatch: 'Passwords do not match', - resetPassword: 'Reset Password', - resettingPassword: 'Resetting...', - resetPasswordFailed: 'Failed to reset password. Please try again.', - passwordResetSuccess: 'Password Reset Successful', - passwordResetSuccessHint: 'Your password has been reset. You can now sign in with your new password.', - invalidResetLink: 'Invalid Reset Link', - invalidResetLinkHint: 'This password reset link is invalid or has expired. Please request a new one.', - requestNewResetLink: 'Request New Reset Link', - invalidOrExpiredToken: 'The password reset link is invalid or has expired. Please request a new one.' - }, - - // Dashboard - dashboard: { - title: 'Dashboard', - welcomeMessage: "Welcome back! Here's an overview of your account.", - balance: 'Balance', - apiKeys: 'API Keys', - todayRequests: 'Today Requests', - todayCost: 'Today Cost', - todayTokens: 'Today Tokens', - totalTokens: 'Total Tokens', - cacheToday: 'Cache (Today)', - performance: 'Performance', - avgResponse: 'Avg Response', - averageTime: 'Average time', - timeRange: 'Time Range', - granularity: 'Granularity', - day: 'Day', - hour: 'Hour', - modelDistribution: 'Model Distribution', - groupDistribution: 'Group Usage Distribution', - platformBreakdown: 'Per-platform Breakdown', - platformBreakdownEmpty: 'No platform usage yet', - platformCount: '{count} platforms', - platformOther: 'Other', - platformQuota: { - title: 'Quota Usage', - daily: 'Daily', - weekly: 'Weekly', - monthly: 'Monthly (30-day rolling)', - resetsAt: 'Resets {time}', - noLimit: 'unlimited', - disabled: 'Disabled', - }, - tokenUsageTrend: 'Token Usage Trend', - noDataAvailable: 'No data available', - model: 'Model', - group: 'Group', - noGroup: 'No Group', - requests: 'Requests', - tokens: 'Tokens', - actual: 'Actual', - standard: 'Standard', - input: 'Input', - output: 'Output', - cache: 'Cache', - recentUsage: 'Recent Usage', - last7Days: 'Last 7 days', - noUsageRecords: 'No usage records', - startUsingApi: 'Start using the API to see your usage history here.', - viewAllUsage: 'View all usage', - quickActions: 'Quick Actions', - createApiKey: 'Create API Key', - generateNewKey: 'Generate a new API key', - batchImageAgent: 'Batch Image Assistant', - batchImageAgentDesc: 'Copy instructions for an agent', - viewUsage: 'View Usage', - checkDetailedLogs: 'Check detailed usage logs', - redeemCode: 'Redeem Code', - addBalanceWithCode: 'Add balance with a code' - }, - - // Groups (shared) - groups: { - subscription: 'Sub' - }, - - // API Keys - keys: { - title: 'API Keys', - description: 'Manage your API keys and access tokens', - searchPlaceholder: 'Search name or key...', - endpoints: { - title: 'API Endpoints', - default: 'Default', - copied: 'Copied', - copiedHint: 'Copied to clipboard', - clickToCopy: 'Click to copy this endpoint', - speedTest: 'Speed Test', - }, - allGroups: 'All Groups', - allStatus: 'All Status', - columnSettings: 'Column Settings', - columnAlwaysVisible: 'This column is always visible', - createKey: 'Create API Key', - editKey: 'Edit API Key', - deleteKey: 'Delete API Key', - deleteConfirmMessage: "Are you sure you want to delete '{name}'? This action cannot be undone.", - apiKey: 'API Key', - group: 'Group', - currentConcurrency: 'Current Concurrency', - noGroup: 'No group', - searchGroup: 'Search groups...', - noGroupFound: 'No groups found', - created: 'Created', - copyToClipboard: 'Copy to clipboard', - copied: 'Copied!', - importToCcSwitch: 'Import to CCS', - enable: 'Enable', - disable: 'Disable', - nameLabel: 'Name', - namePlaceholder: 'My API Key', - groupLabel: 'Group', - selectGroup: 'Select a group', - statusLabel: 'Status', - selectStatus: 'Select status', - saving: 'Saving...', - noKeysYet: 'No API keys yet', - createFirstKey: 'Create your first API key to get started with the API.', - keyCreatedSuccess: 'API key created successfully', - keyUpdatedSuccess: 'API key updated successfully', - keyDeletedSuccess: 'API key deleted successfully', - keyEnabledSuccess: 'API key enabled successfully', - keyDisabledSuccess: 'API key disabled successfully', - failedToLoad: 'Failed to load API keys', - failedToSave: 'Failed to save API key', - failedToDelete: 'Failed to delete API key', - failedToUpdateStatus: 'Failed to update API key status', - clickToChangeGroup: 'Click to change group', - groupChangedSuccess: 'Group changed successfully', - failedToChangeGroup: 'Failed to change group', - groupRequired: 'Please select a group', - usage: 'Usage', - today: 'Today', - total: 'Last 30d', - quota: 'Quota', - lastUsedAt: 'Last Used', - useKey: 'Use Key', - useKeyModal: { - title: 'Use API Key', - description: - 'Add the following environment variables to your terminal profile or run directly in terminal to configure API access.', - copy: 'Copy', - copied: 'Copied', - note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.', - noGroupTitle: 'Please assign a group first', - noGroupDescription: 'This API key has not been assigned to a group. Please click the group column in the key list to assign one before viewing the configuration.', - openai: { - description: 'Add the following configuration files to your Codex CLI config directory.', - configTomlHint: 'Make sure the following content is at the beginning of the config.toml file', - note: 'Make sure the config directory exists. macOS/Linux users can run mkdir -p ~/.codex to create it.', - noteWindows: 'Press Win+R and enter %userprofile%\\.codex to open the config directory. Create it manually if it does not exist.', - }, - cliTabs: { - claudeCode: 'Claude Code', - geminiCli: 'Gemini CLI', - codexCli: 'Codex CLI', - codexCliWs: 'Codex CLI (WebSocket)', - opencode: 'OpenCode', - }, - antigravity: { - description: 'Configure API access for Antigravity group. Select the configuration method based on your client.', - claudeCode: 'Claude Code', - geminiCli: 'Gemini CLI', - claudeNote: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.', - geminiNote: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.', - }, - gemini: { - description: 'Add the following environment variables to your terminal profile or run directly in terminal to configure Gemini CLI access.', - modelComment: 'If you have Gemini 3 access, you can use: gemini-3-pro-preview', - note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.', - }, - opencode: { - title: 'OpenCode Example', - subtitle: 'opencode.json', - hint: 'Config path: ~/.config/opencode/opencode.json (or opencode.jsonc), create if not exists. Use default providers (openai/anthropic/google) or custom provider_id. API Key can be configured directly or via /connect command. This is an example, adjust models and options as needed.', - }, - }, - customKeyLabel: 'Custom Key', - customKeyPlaceholder: 'Enter your custom key (min 16 chars)', - customKeyHint: 'Only letters, numbers, underscores and hyphens allowed. Minimum 16 characters.', - customKeyTooShort: 'Custom key must be at least 16 characters', - customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens', - customKeyRequired: 'Please enter a custom key', - ipRestriction: 'IP Restriction', - ipWhitelist: 'IP Whitelist', - ipWhitelistPlaceholder: '192.168.1.100\n10.0.0.0/8', - ipWhitelistHint: 'One IP or CIDR per line. Only these IPs can use this key when set.', - ipBlacklist: 'IP Blacklist', - ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16', - ipBlacklistHint: 'One IP or CIDR per line. These IPs will be blocked from using this key.', - ipRestrictionEnabled: 'IP restriction enabled', - ccSwitchNotInstalled: 'CC-Switch is not installed or the protocol handler is not registered. Please install CC-Switch first or manually copy the API key.', - ccsClientSelect: { - title: 'Select Client', - description: 'Please select the client type to import to CC-Switch:', - claudeCode: 'Claude Code', - claudeCodeDesc: 'Import as Claude Code configuration', - geminiCli: 'Gemini CLI', - geminiCliDesc: 'Import as Gemini CLI configuration', - }, - // Quota and expiration - quotaLimit: 'Quota Limit', - quotaAmount: 'Quota Amount (USD)', - quotaAmountPlaceholder: 'Enter quota limit in USD', - quotaAmountHint: 'Set the maximum amount this key can spend. 0 = unlimited.', - quotaUsed: 'Quota Used', - reset: 'Reset', - resetQuotaUsed: 'Reset used quota to 0', - resetQuotaTitle: 'Confirm Reset Quota', - resetQuotaConfirmMessage: 'Are you sure you want to reset the used quota (${used}) for key "{name}" to 0? This action cannot be undone.', - quotaResetSuccess: 'Quota reset successfully', - failedToResetQuota: 'Failed to reset quota', - rateLimitColumn: 'Rate Limit', - rateLimitSection: 'Rate Limit', - resetUsage: 'Reset', - rateLimit5h: '5-Hour Limit (USD)', - rateLimit1d: 'Daily Limit (USD)', - rateLimit7d: '7-Day Limit (USD)', - rateLimitHint: 'Set the maximum spending for this key within each time window. 0 = unlimited.', - rateLimitUsage: 'Rate Limit Usage', - resetRateLimitUsage: 'Reset Rate Limit Usage', - resetRateLimitTitle: 'Confirm Reset Rate Limit', - resetRateLimitConfirmMessage: 'Are you sure you want to reset the rate limit usage for key "{name}"? All time window usage will be reset to zero. This action cannot be undone.', - rateLimitResetSuccess: 'Rate limit usage reset successfully', - failedToResetRateLimit: 'Failed to reset rate limit usage', - resetNow: 'Resetting soon', - expiration: 'Expiration', - expiresInDays: '{days} days', - extendDays: '+{days} days', - customDate: 'Custom', - expirationDate: 'Expiration Date', - expirationDateHint: 'Select when this API key should expire.', - currentExpiration: 'Current expiration', - expiresAt: 'Expires', - noExpiration: 'Never', - status: { - active: 'Active', - inactive: 'Inactive', - quota_exhausted: 'Quota Exhausted', - expired: 'Expired', - }, - }, - - // Usage - usage: { - title: 'Usage Records', - description: 'View and analyze your API usage history', - costDetails: 'Cost Breakdown', - tokenDetails: 'Token Breakdown', - cacheTtlOverriddenHint: 'Cache TTL Override enabled', - cacheTtlOverriddenLabel: 'TTL Override', - cacheTtlOverridden5m: 'Billed as 5m', - cacheTtlOverridden1h: 'Billed as 1h', - totalRequests: 'Total Requests', - totalTokens: 'Total Tokens', - cacheTotal: 'Cache', - cacheBreakdown: 'Cache Token Breakdown', - cacheCreationTokensLabel: 'Cache Creation', - cacheReadTokensLabel: 'Cache Read', - totalCost: 'Total Cost', - standardCost: 'Standard', - actualCost: 'Actual', - accountCost: 'Cost', - userBilled: 'User billed', - accountBilled: 'Account billed', - resetNow: 'Now', - resetPending: 'Pending refresh', - accountMultiplier: 'Account rate', - avgDuration: 'Avg Duration', - inSelectedRange: 'in selected range', - perRequest: 'per request', - apiKeyFilter: 'API Key', - allApiKeys: 'All API Keys', - timeRange: 'Time Range', - exportCsv: 'Export CSV', - exportExcel: 'Export Excel', - exportingProgress: 'Exporting data...', - exportedCount: 'Exported {current}/{total} records', - estimatedTime: 'Estimated time remaining: {time}', - cancelExport: 'Cancel Export', - exportCancelled: 'Export cancelled', - exporting: 'Exporting...', - preparingExport: 'Preparing export...', - model: 'Model', - requestedModel: 'Requested', - upstreamModel: 'Upstream', - reasoningEffort: 'Reasoning Effort', - endpoint: 'Endpoint', - endpointDistribution: 'Endpoint Distribution', - inbound: 'Inbound', - upstream: 'Upstream', - mapping: 'Mapping', - path: 'Path', - inboundEndpoint: 'Inbound Endpoint', - upstreamEndpoint: 'Upstream Endpoint', - type: 'Type', - tokens: 'Tokens', - cost: 'Cost', - firstToken: 'First Token', - duration: 'Duration', - time: 'Time', - ws: 'WS', - stream: 'Stream', - sync: 'Sync', - cyber: 'Cyber', - unknown: 'Unknown', - in: 'In', - out: 'Out', - cacheHit: 'Cache hit', - cacheCreate: 'Cache create', - cacheHitRate: 'Cache hit rate', - inputTokenPrice: 'Input price', - outputTokenPrice: 'Output price', - perMillionTokens: '/ 1M tokens', - unitPrice: 'Per-request price', - imageUnitPrice: 'Per-image price', - imageTotalPrice: 'Image total price', - imageCount: 'Image count', - imageBillingSize: 'Billing size', - imageInputSize: 'Input size', - imageOutputSize: 'Output size', - imageOutputTokens: 'Image Output Tokens', - imageOutputTokenPrice: 'Image Output Price', - imageOutputCost: 'Image Output Cost', - imageSizeSource: 'Size source', - imageSizeBreakdown: 'Size breakdown', - imageSizeSourceOutput: 'Upstream output', - imageSizeSourceInput: 'Request input', - imageSizeSourceDefault: 'Default billing tier', - imageSizeSourceLegacy: 'Legacy record', - imageSizeSourceMissing: 'Not recorded', - imageSizeNotRecorded: 'not recorded', - imageSizeLegacyUnstandardized: 'legacy unstandardized', - imageSizeUnknown: 'unknown', - cacheRead: 'Read', - cacheWrite: 'Write', - serviceTier: 'Service tier', - serviceTierPriority: 'Fast', - serviceTierFlex: 'Flex', - serviceTierStandard: 'Standard', - rate: 'Rate', - original: 'Original', - billed: 'Billed', - noRecords: 'No usage records found. Try adjusting your filters.', - failedToLoad: 'Failed to load usage logs', - noDataToExport: 'No data to export', - exportSuccess: 'Usage data exported successfully', - exportFailed: 'Failed to export usage data', - exportExcelSuccess: 'Usage data exported successfully (Excel format)', - exportExcelFailed: 'Failed to export usage data', - imageUnit: ' images', - userAgent: 'User-Agent', - ipGeo: { - fetch: 'Fetch region', - fetching: 'Fetching...', - failed: 'Failed', - private: 'Private address', - refreshTitle: 'Refresh region info', - batchFetch: 'Batch fetch regions', - batchFetching: 'Fetching...', - pending: '{count} IPs pending', - batchFailed: 'Failed to batch fetch IP regions', - detailOrg: 'ISP', - detailTimezone: 'Timezone', - detailAccuracy: 'Accuracy', - detailCoordinates: 'Coordinates', - }, - tabs: { usage: 'Usage', errors: 'Error Requests' }, - errors: { - time: 'Time', model: 'Model', endpoint: 'Endpoint', status: 'Status', - category: 'Category', platform: 'Platform', message: 'Message', - keyName: 'Key Name', keyDeleted: 'Deleted', allKeys: 'All keys', - modelPlaceholder: 'Search model', allCategories: 'All categories', allStatuses: 'All status codes', - empty: 'No error requests', failedToLoad: 'Failed to load error requests', - categories: { - auth: 'Auth failed', rate_limit: 'Rate limited', quota: 'Balance/Subscription', - invalid_request: 'Invalid request', service_unavailable: 'Service unavailable', - upstream: 'Upstream error', internal: 'Platform error', other: 'Other', cyber: 'Cyber policy', - }, - detail: { - title: 'Error Request Detail', - responseBody: 'Response Body', - upstreamStatus: 'Upstream Status', - loadFailed: 'Failed to load detail, please try again', - }, - }, - }, - - // Shared keys for channel monitor (admin + user views) - monitorCommon: { - status: { - operational: 'Operational', - degraded: 'Degraded', - failed: 'Failed', - error: 'Error', - unknown: '-' - }, - providers: { - openai: 'OpenAI', - anthropic: 'Anthropic', - gemini: 'Gemini' - }, - extraModelsHeader: 'Extra Models', - extraModelsEmpty: 'No extra models', - latencyEmpty: '-', - availabilityPrefix: 'Availability', - dialogLatency: 'Dialog Latency', - endpointPing: 'Endpoint PING', - history60pts: 'HISTORY ({n} PTS)', - nextUpdateIn: 'NEXT UPDATE IN {n}s', - past: 'PAST', - now: 'NOW', - maintenancePaused: 'Maintenance · timeline paused', - extraModelsCount: '+ {n} models', - pollEvery: '{n}s polling', - updatedAt: 'Updated {time}', - relativeSecondsAgo: '{n}s ago', - relativeMinutesAgo: '{n}m ago', - relativeHoursAgo: '{n}h ago', - relativeDaysAgo: '{n}d ago' - }, - - // Channel Status (user-facing read-only view) - channelStatus: { - title: 'Channel Status', - description: 'Inspect channel availability, latency and recent status', - searchPlaceholder: 'Search channels...', - allProviders: 'All Providers', - loadError: 'Failed to load channel status', - detailLoadError: 'Failed to load channel detail', - detailTitle: 'Channel Detail', - closeDetail: 'Close', - windowTab: { - '7d': '7 days', - '15d': '15 days', - '30d': '30 days' - }, - overall: { - operational: 'OPERATIONAL', - degraded: 'DEGRADED', - unavailable: 'UNAVAILABLE' - }, - columns: { - name: 'Name', - provider: 'Provider', - groupName: 'Group', - primaryModel: 'Primary Model', - availability7d: '7d Availability', - latency: 'Latency (ms)' - }, - detailColumns: { - model: 'Model', - latestStatus: 'Latest Status', - latestLatency: 'Latest Latency (ms)', - availability7d: '7d Availability', - availability15d: '15d Availability', - availability30d: '30d Availability', - avgLatency7d: '7d Avg Latency (ms)' - }, - empty: { - title: 'No channels available', - description: 'No monitored channels have been configured yet.' - } - }, - - // Available Channels (user-facing) - availableChannels: { - title: 'Available Channels', - description: 'Channels you can access, along with their supported models and pricing', - searchPlaceholder: 'Search channels or models...', - empty: 'No available channels', - noModels: 'No models configured', - noPricing: 'Pricing not configured', - exclusive: 'Exclusive', - public: 'Public', - exclusiveTooltip: 'Exclusive groups granted to you by an admin', - publicTooltip: 'Groups open to all users', - columns: { - name: 'Channel', - description: 'Description', - platform: 'Platform', - groups: 'Your Accessible Groups', - supportedModels: 'Supported Models' - }, - pricing: { - billingMode: 'Billing Mode', - billingModeToken: 'Per Token', - billingModePerRequest: 'Per Request', - billingModeImage: 'Per Image', - inputPrice: 'Input', - outputPrice: 'Output', - cacheWritePrice: 'Cache Write', - cacheReadPrice: 'Cache Read', - imageOutputPrice: 'Image Output', - perRequestPrice: 'Per Request', - intervals: 'Tiered Pricing', - unitPerMillion: '/ 1M tokens', - unitPerRequest: '/ request' - } - }, - - affiliate: { - title: 'Affiliate Rebates', - description: 'Invite new users and convert your rebate quota into account balance', - yourCode: 'Your Affiliate Code', - inviteLink: 'Invite Link', - copyCode: 'Copy Code', - copyLink: 'Copy Link', - codeCopied: 'Affiliate code copied', - linkCopied: 'Invite link copied', - loadFailed: 'Failed to load affiliate data', - transferFailed: 'Failed to transfer affiliate quota', - stats: { - rebateRate: 'My Rebate Rate', - rebateRateHint: 'What you earn each time an invitee recharges', - invitedUsers: 'Invited Users', - availableQuota: 'Available Rebate Quota', - frozenQuota: 'Frozen', - frozenQuotaHint: 'Recently earned rebates pending release', - totalQuota: 'Historical Rebate Quota' - }, - transfer: { - title: 'Transfer Rebate Quota', - description: 'Move available rebate quota into your account balance', - button: 'Transfer to Balance', - transferring: 'Transferring...', - empty: 'No available rebate quota', - success: '{amount} has been transferred to your balance' - }, - invitees: { - title: 'Invited Users', - empty: 'No invited users yet', - columns: { - email: 'Email', - username: 'Username', - rebate: 'Rebate', - joinedAt: 'Joined At' - } - }, - tips: { - title: 'How It Works', - line1: 'Share your affiliate code or invite link with new users.', - line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.', - line3: 'Transfer rebate quota to balance at any time.', - line4: 'Newly earned rebates may have a waiting period before they can be transferred.' - } - }, - - // Redeem - redeem: { - title: 'Redeem Code', - description: 'Enter your redeem code to add balance or increase concurrency', - currentBalance: 'Current Balance', - concurrency: 'Concurrency', - requests: 'requests', - redeemCodeLabel: 'Redeem Code', - redeemCodePlaceholder: 'Enter your redeem code', - redeemCodeHint: 'Redeem codes are case-sensitive', - redeeming: 'Redeeming...', - redeemButton: 'Redeem Code', - redeemSuccess: 'Code Redeemed Successfully!', - redeemFailed: 'Redemption Failed', - added: 'Added', - concurrentRequests: 'concurrent requests', - newBalance: 'New Balance', - newConcurrency: 'New Concurrency', - aboutCodes: 'About Redeem Codes', - codeRule1: 'Each code can only be used once', - codeRule2: 'Codes may add balance, increase concurrency, or grant trial access', - codeRule3: 'Contact support if you have issues redeeming a code', - codeRule4: 'Balance and concurrency updates are immediate', - recentActivity: 'Recent Activity', - historyWillAppear: 'Your redemption history will appear here', - balanceAddedRedeem: 'Balance Added (Redeem)', - balanceAddedAffiliate: 'Balance Added (Affiliate Transfer)', - balanceAddedAdmin: 'Balance Added (Admin)', - balanceDeductedAdmin: 'Balance Deducted (Admin)', - concurrencyAddedRedeem: 'Concurrency Added (Redeem)', - concurrencyAddedAdmin: 'Concurrency Added (Admin)', - concurrencyReducedAdmin: 'Concurrency Reduced (Admin)', - adminAdjustment: 'Admin Adjustment', - subscriptionAssigned: 'Subscription Assigned', - subscriptionAssignedDesc: 'You have been granted access to {groupName}', - subscriptionDays: '{days} days', - days: ' days', - codeRedeemSuccess: 'Code redeemed successfully!', - failedToRedeem: 'Failed to redeem code. Please check the code and try again.', - subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.', - pleaseEnterCode: 'Please enter a redeem code' - }, - - // Profile - profile: { - title: 'Profile Settings', - description: 'Manage your account information and settings', - accountBalance: 'Account Balance', - concurrencyLimit: 'Concurrency Limit', - rpmLimit: 'RPM Limit', - rpmUnlimited: 'Unlimited', - memberSince: 'Member Since', - overviewTitle: 'Account Overview', - overviewDescription: 'Check account status, profile sources, and common actions at a glance.', - basicsTitle: 'Profile & Avatar', - basicsDescription: 'Keep your public profile details and avatar aligned.', - linkedProfileSources: 'Profile Sources', - linkedProfileSourcesDescription: 'Some profile details may stay synced from third-party sign-in methods.', - securityTitle: 'Security Settings', - securityDescription: 'Password, two-factor authentication, and alerts live in the right rail.', - administrator: 'Administrator', - user: 'User', - username: 'Username', - email: 'Email', - status: 'Status', - role: 'Role', - enterUsername: 'Enter username', - editProfile: 'Edit Profile', - updateProfile: 'Update Profile', - updating: 'Updating...', - updateSuccess: 'Profile updated successfully', - updateFailed: 'Failed to update profile', - usernameRequired: 'Username is required', - changePassword: 'Change Password', - currentPassword: 'Current Password', - newPassword: 'New Password', - confirmNewPassword: 'Confirm New Password', - passwordHint: 'Password must be at least 8 characters long', - changingPassword: 'Changing...', - changePasswordButton: 'Change Password', - passwordsNotMatch: 'New passwords do not match', - passwordTooShort: 'Password must be at least 8 characters long', - passwordChangeSuccess: 'Password changed successfully', - passwordChangeFailed: 'Failed to change password', - // TOTP 2FA - totp: { - title: 'Two-Factor Authentication (2FA)', - description: 'Enhance account security with Google Authenticator or similar apps', - enabled: 'Enabled', - enabledAt: 'Enabled at', - notEnabled: 'Not Enabled', - notEnabledHint: 'Enable two-factor authentication to enhance account security', - enable: 'Enable', - disable: 'Disable', - featureDisabled: 'Feature Unavailable', - featureDisabledHint: 'Two-factor authentication has not been enabled by the administrator', - setupTitle: 'Set Up Two-Factor Authentication', - setupStep1: 'Scan the QR code below with your authenticator app', - setupStep2: 'Enter the 6-digit code from your app', - manualEntry: "Can't scan? Enter the key manually:", - enterCode: 'Enter 6-digit code', - verify: 'Verify', - setupFailed: 'Failed to get setup information', - verifyFailed: 'Invalid code, please try again', - enableSuccess: 'Two-factor authentication enabled', - disableTitle: 'Disable Two-Factor Authentication', - disableWarning: 'After disabling, you will no longer need a verification code to log in. This may reduce your account security.', - enterPassword: 'Enter your current password to confirm', - confirmDisable: 'Confirm Disable', - disableSuccess: 'Two-factor authentication disabled', - disableFailed: 'Failed to disable, please check your password', - loginTitle: 'Two-Factor Authentication', - loginHint: 'Enter the 6-digit code from your authenticator app', - loginFailed: 'Verification failed, please try again', - // New translations for email verification - verifyEmailFirst: 'Please verify your email first', - verifyPasswordFirst: 'Please verify your identity first', - emailCode: 'Email Verification Code', - enterEmailCode: 'Enter 6-digit code', - sendCode: 'Send Code', - codeSent: 'Verification code sent to your email', - sendCodeFailed: 'Failed to send verification code' - }, - balanceNotify: { - title: 'Balance Low Notification', - description: 'Send email alert when account balance falls below threshold', - enabled: 'Enable Balance Low Notification', - threshold: 'Custom Threshold', - thresholdHint: 'Leave empty to use system default', - thresholdPlaceholder: 'Enter amount', - systemDefault: 'System Default', - extraEmails: 'Notification Emails', - extraEmailsHint: 'You must add and verify an email address to receive low balance alerts', - primaryEmail: 'Primary', - noExtraEmails: 'No extra notification emails', - enterEmail: 'Enter email address', - addEmail: 'Add Email', - emailPlaceholder: 'Enter email address', - sendCode: 'Send Code', - resend: 'Resend', - codeSent: 'Verification code sent', - codeSentTo: 'Code sent to {email}', - enterCode: 'Enter verification code', - codePlaceholder: '6-digit code', - verify: 'Verify', - emailAdded: 'Email added', - emailRemoved: 'Email removed', - verifySuccess: 'Email added successfully', - removeEmail: 'Remove', - removeSuccess: 'Email removed', - emailDuplicate: 'This email already exists', - maxEmailsReached: 'Maximum number of notification emails reached', - unverified: 'Unverified', - verified: 'Verified', - }, - avatar: { - title: 'Profile Avatar', - description: 'Upload an avatar image. Static uploads are compressed to 20KB before saving.', - uploadAction: 'Upload image', - uploadHint: 'Static uploads are compressed to 20KB when possible. GIF uploads must already be within 20KB.', - uploadRequired: 'Upload an avatar image first', - saveSuccess: 'Avatar updated', - deleteSuccess: 'Avatar removed', - invalidType: 'Please choose an image file', - gifTooLarge: 'GIF avatars must already be 20KB or smaller', - compressTooLarge: 'Unable to compress this image below 20KB. Try a smaller image.', - compressFailed: 'Failed to compress the selected image.', - readFailed: 'Failed to read the selected image.', - emptyDeleteHint: 'Avatar is already empty', - }, - authBindings: { - title: 'Connected Sign-In Methods', - description: 'View current bindings and connect another provider to this account.', - bindAction: 'Bind {providerName}', - bindSuccess: 'Account linked successfully', - emailPlaceholder: 'Enter email address', - codePlaceholder: 'Enter verification code', - passwordPlaceholder: 'Set a login password', - replaceEmailPasswordPlaceholder: 'Enter current password', - sendCodeAction: 'Send code', - manageEmailAction: 'Manage email', - hideEmailFormAction: 'Hide email form', - confirmEmailBindAction: 'Bind email', - confirmEmailReplaceAction: 'Replace primary email', - codeSentTo: 'Code sent to {email}', - replaceSuccess: 'Primary email updated', - unbindAction: 'Unbind', - unbindSuccess: '{providerName} unbound', - boundCount: '{count} linked records', - status: { - bound: 'Bound', - notBound: 'Not bound', - }, - providers: { - email: 'Email', - linuxdo: 'LinuxDo', - dingtalk: 'DingTalk', - oidc: '{providerName}', - wechat: 'WeChat', - }, - notes: { - emailManagedFromProfile: 'Primary email is managed in the profile form', - canUnbind: 'You can unbind this sign-in method', - bindAnotherBeforeUnbind: 'Bind another sign-in method before unbinding', - }, - source: { - avatar: 'Avatar is currently synced from {providerName}', - username: 'Nickname is currently synced from {providerName}', - }, - } - }, - - // Empty States - empty: { - noData: 'No data found' - }, - - // Table - table: { - expandActions: 'Expand More Actions', - collapseActions: 'Collapse Actions' - }, - - // Pagination - pagination: { - showing: 'Showing', - to: 'to', - of: 'of', - results: 'results', - page: 'Page', - pageOf: 'Page {page} of {total}', - previous: 'Previous', - next: 'Next', - perPage: 'Per page', - goToPage: 'Go to page {page}', - jumpTo: 'Jump to', - jumpPlaceholder: 'Page', - jumpAction: 'Go' - }, - - // Errors - errors: { - somethingWentWrong: 'Something went wrong', - pageNotFound: 'Page not found', - unauthorized: 'Unauthorized', - forbidden: 'Forbidden', - serverError: 'Server error', - networkError: 'Network error', - timeout: 'Request timeout', - tryAgain: 'Please try again' - }, - - // Dates - dates: { - today: 'Today', - yesterday: 'Yesterday', - thisWeek: 'This Week', - lastWeek: 'Last Week', - thisMonth: 'This Month', - lastMonth: 'Last Month', - last24Hours: 'Last 24 Hours', - last7Days: 'Last 7 Days', - last14Days: 'Last 14 Days', - last30Days: 'Last 30 Days', - custom: 'Custom', - startDate: 'Start Date', - endDate: 'End Date', - apply: 'Apply', - selectDateRange: 'Select date range' - }, - - // Admin - admin: { - // Dashboard - dashboard: { - title: 'Admin Dashboard', - description: 'System overview and real-time statistics', - apiKeys: 'API Keys', - accounts: 'Accounts', - users: 'Users', - todayRequests: 'Today Requests', - newUsersToday: 'New Users Today', - todayTokens: 'Today Tokens', - totalTokens: 'Total Tokens', - cacheToday: 'Cache (Today)', - performance: 'Performance', - avgResponse: 'Avg Response', - active: 'active', - ok: 'ok', - err: 'err', - activeUsers: 'active users', - create: 'Create', - timeRange: 'Time Range', - granularity: 'Granularity', - day: 'Day', - hour: 'Hour', - modelDistribution: 'Model Distribution', - groupDistribution: 'Group Usage Distribution', - metricTokens: 'By Tokens', - metricActualCost: 'By Actual Cost', - tokenUsageTrend: 'Token Usage Trend', - userUsageTrend: 'User Usage Trend (Top 12)', - model: 'Model', - group: 'Group', - noGroup: 'No Group', - requests: 'Requests', - tokens: 'Tokens', - actual: 'Actual', - standard: 'Standard', - accountCost: 'Cost', - noDataAvailable: 'No data available', - recentUsage: 'Recent Usage', - viewModelDistribution: 'Model Distribution', - viewSpendingRanking: 'User Spending Ranking', - spendingRankingTitle: 'User Spending Ranking', - spendingRankingUser: 'User', - spendingRankingRequests: 'Requests', - spendingRankingTokens: 'Tokens', - spendingRankingSpend: 'Spend', - spendingRankingOther: 'Others', - spendingRankingUsage: 'Usage', - spendShort: 'Spend', - requestsShort: 'Req', - tokensShort: 'Tok', - quickActions: 'Quick Actions', - batchImage: 'Batch Image', - batchImageDesc: 'Submit jobs and copy agent instructions', - groupPricing: 'Group Pricing', - groupPricingDesc: 'Configure batch discount and hold ratio', - failedToLoad: 'Failed to load dashboard statistics' - }, - - backup: { - title: 'Database Backup', - description: 'Full database backup to S3-compatible storage with scheduled backup and restore', - s3: { - title: 'S3 Storage Configuration', - description: 'Configure S3-compatible storage (supports Cloudflare R2)', - descriptionPrefix: 'Configure S3-compatible storage (supports', - descriptionSuffix: ')', - enabled: 'Enable S3 Storage', - endpoint: 'Endpoint', - region: 'Region', - bucket: 'Bucket', - prefix: 'Key Prefix', - accessKeyId: 'Access Key ID', - secretAccessKey: 'Secret Access Key', - secretConfigured: 'Already configured, leave empty to keep', - forcePathStyle: 'Force Path Style', - testConnection: 'Test Connection', - testSuccess: 'S3 connection test successful', - testFailed: 'S3 connection test failed', - saved: 'S3 configuration saved' - }, - schedule: { - title: 'Scheduled Backup', - description: 'Configure automatic scheduled backups', - enabled: 'Enable Scheduled Backup', - cronExpr: 'Cron Expression', - cronHint: 'e.g. "0 2 * * *" means every day at 2:00 AM', - retainDays: 'Backup Expire Days', - retainDaysHint: 'Backup files auto-delete after this many days, 0 = never expire', - retainCount: 'Max Retain Count', - retainCountHint: 'Maximum number of backups to keep, 0 = unlimited', - saved: 'Schedule configuration saved' - }, - operations: { - title: 'Backup Records', - description: 'Create manual backups and manage existing backup records', - createBackup: 'Create Backup', - backing: 'Backing up...', - backupCreated: 'Backup created successfully', - expireDays: 'Expire Days', - alreadyInProgress: 'A backup is already in progress', - backupRunning: 'Backup in progress...', - backupFailed: 'Backup failed', - restoreRunning: 'Restore in progress...', - restoreFailed: 'Restore failed', - }, - columns: { - status: 'Status', - fileName: 'File Name', - size: 'Size', - expiresAt: 'Expires At', - triggeredBy: 'Triggered By', - startedAt: 'Started At', - actions: 'Actions' - }, - status: { - pending: 'Pending', - running: 'Running', - completed: 'Completed', - failed: 'Failed' - }, - progress: { - pending: 'Preparing', - dumping: 'Dumping database', - uploading: 'Uploading', - }, - trigger: { - manual: 'Manual', - scheduled: 'Scheduled' - }, - neverExpire: 'Never', - empty: 'No backup records', - actions: { - download: 'Download', - restore: 'Restore', - restoreConfirm: 'Are you sure you want to restore from this backup? This will overwrite the current database!', - restorePasswordPrompt: 'Please enter your admin password to confirm the restore operation', - restoreSuccess: 'Database restored successfully', - deleteConfirm: 'Are you sure you want to delete this backup?', - deleted: 'Backup deleted' - }, - r2Guide: { - title: 'Cloudflare R2 Setup Guide', - intro: 'Cloudflare R2 provides S3-compatible object storage with a free tier of 10GB storage + 1M Class A requests/month, ideal for database backups.', - step1: { - title: 'Create an R2 Bucket', - line1: 'Log in to the Cloudflare Dashboard (dash.cloudflare.com), select "R2 Object Storage" from the sidebar', - line2: 'Click "Create bucket", enter a name (e.g. sub2api-backups), choose a region', - line3: 'Click create to finish' - }, - step2: { - title: 'Create an API Token', - line1: 'On the R2 page, click "Manage R2 API Tokens" in the top right', - line2: 'Click "Create API token", set permission to "Object Read & Write"', - line3: 'Recommended: restrict to specific bucket for better security', - line4: 'After creation, you will see the Access Key ID and Secret Access Key', - warning: 'The Secret Access Key is only shown once — copy and save it immediately!' - }, - step3: { - title: 'Get the S3 Endpoint', - desc: 'Find your Account ID on the R2 overview page (in the URL or the right panel). The endpoint format is:', - accountId: 'your_account_id' - }, - step4: { - title: 'Fill in the Configuration', - checkEnabled: 'Checked', - bucketValue: 'Your bucket name', - fromStep2: 'Value from Step 2', - unchecked: 'Unchecked' - }, - freeTier: 'R2 Free Tier: 10GB storage + 1M Class A requests + 10M Class B requests per month — more than enough for database backups.' - } - }, - - dataManagement: { - title: 'Data Management', - description: 'Manage data management agent status, object storage settings, and backup jobs in one place', - agent: { - title: 'Data Management Agent Status', - description: 'The system probes a fixed Unix socket and enables data management only when reachable.', - enabled: 'Data management agent is ready. Data management operations are available.', - disabled: 'Data management agent is unavailable. Only diagnostic information is available now.', - socketPath: 'Socket Path', - version: 'Version', - status: 'Status', - uptime: 'Uptime', - reasonLabel: 'Unavailable Reason', - reason: { - DATA_MANAGEMENT_AGENT_SOCKET_MISSING: 'Data management socket file is missing', - DATA_MANAGEMENT_AGENT_UNAVAILABLE: 'Data management agent is unreachable', - BACKUP_AGENT_SOCKET_MISSING: 'Backup socket file is missing', - BACKUP_AGENT_UNAVAILABLE: 'Backup agent is unreachable', - UNKNOWN: 'Unknown reason' - } - }, - sections: { - config: { - title: 'Backup Configuration', - description: 'Configure backup source, retention policy, and S3 settings.' - }, - s3: { - title: 'S3 Object Storage', - description: 'Configure and test uploads of backup artifacts to a standard S3-compatible storage.' - }, - backup: { - title: 'Backup Operations', - description: 'Trigger PostgreSQL, Redis, and full backup jobs.' - }, - history: { - title: 'Backup History', - description: 'Review backup job status, errors, and artifact metadata.' - } - }, - form: { - sourceMode: 'Source Mode', - backupRoot: 'Backup Root', - activePostgresProfile: 'Active PostgreSQL Profile', - activeRedisProfile: 'Active Redis Profile', - activeS3Profile: 'Active S3 Profile', - retentionDays: 'Retention Days', - keepLast: 'Keep Last Jobs', - uploadToS3: 'Upload to S3', - useActivePostgresProfile: 'Use Active PostgreSQL Profile', - useActiveRedisProfile: 'Use Active Redis Profile', - useActiveS3Profile: 'Use Active Profile', - idempotencyKey: 'Idempotency Key (Optional)', - secretConfigured: 'Configured already, leave empty to keep unchanged', - source: { - profileID: 'Profile ID (Unique)', - profileName: 'Profile Name', - setActive: 'Set as active after creation' - }, - postgres: { - title: 'PostgreSQL', - host: 'Host', - port: 'Port', - user: 'User', - password: 'Password', - database: 'Database', - sslMode: 'SSL Mode', - containerName: 'Container Name (docker_exec mode)' - }, - redis: { - title: 'Redis', - addr: 'Address (host:port)', - username: 'Username', - password: 'Password', - db: 'Database Index', - containerName: 'Container Name (docker_exec mode)' - }, - s3: { - enabled: 'Enable S3 Upload', - profileID: 'Profile ID (Unique)', - profileName: 'Profile Name', - endpoint: 'Endpoint (Optional)', - region: 'Region', - bucket: 'Bucket', - accessKeyID: 'Access Key ID', - secretAccessKey: 'Secret Access Key', - prefix: 'Object Prefix', - forcePathStyle: 'Force Path Style', - useSSL: 'Use SSL', - setActive: 'Set as active after creation' - } - }, - sourceProfiles: { - createTitle: 'Create Source Profile', - editTitle: 'Edit Source Profile', - empty: 'No source profiles yet, create one first', - deleteConfirm: 'Delete source profile {profileID}?', - columns: { - profile: 'Profile', - active: 'Active', - connection: 'Connection', - database: 'Database', - updatedAt: 'Updated At', - actions: 'Actions' - } - }, - s3Profiles: { - createTitle: 'Create S3 Profile', - editTitle: 'Edit S3 Profile', - empty: 'No S3 profiles yet, create one first', - editHint: 'Click "Edit" to modify profile details in the right drawer.', - deleteConfirm: 'Delete S3 profile {profileID}?', - columns: { - profile: 'Profile', - active: 'Active', - storage: 'Storage', - updatedAt: 'Updated At', - actions: 'Actions' - } - }, - history: { - total: '{count} jobs', - empty: 'No backup jobs yet', - columns: { - jobID: 'Job ID', - type: 'Type', - status: 'Status', - triggeredBy: 'Triggered By', - pgProfile: 'PostgreSQL Profile', - redisProfile: 'Redis Profile', - s3Profile: 'S3 Profile', - finishedAt: 'Finished At', - artifact: 'Artifact', - error: 'Error' - }, - status: { - queued: 'Queued', - running: 'Running', - succeeded: 'Succeeded', - failed: 'Failed', - partial_succeeded: 'Partial Succeeded' - } - }, - actions: { - refresh: 'Refresh Status', - disabledHint: 'Start datamanagementd first and ensure the socket is reachable.', - reloadConfig: 'Reload Config', - reloadSourceProfiles: 'Reload Source Profiles', - reloadProfiles: 'Reload Profiles', - newSourceProfile: 'New Source Profile', - saveConfig: 'Save Config', - configSaved: 'Configuration saved', - testS3: 'Test S3 Connection', - s3TestOK: 'S3 connection test succeeded', - s3TestFailed: 'S3 connection test failed', - newProfile: 'New Profile', - saveProfile: 'Save Profile', - activateProfile: 'Activate', - profileIDRequired: 'Profile ID is required', - profileNameRequired: 'Profile name is required', - profileSelectRequired: 'Select a profile to edit first', - profileCreated: 'S3 profile created', - profileSaved: 'S3 profile saved', - profileActivated: 'S3 profile activated', - profileDeleted: 'S3 profile deleted', - sourceProfileCreated: 'Source profile created', - sourceProfileSaved: 'Source profile saved', - sourceProfileActivated: 'Source profile activated', - sourceProfileDeleted: 'Source profile deleted', - createBackup: 'Create Backup Job', - jobCreated: 'Backup job created: {jobID} ({status})', - refreshJobs: 'Refresh Jobs', - loadMore: 'Load More' - } - }, - - affiliates: { - invitesDescription: 'View site-wide inviter and invitee relationships', - rebatesDescription: 'View recharge orders that generated affiliate rebates', - transfersDescription: 'View affiliate quota transfers into account balance', - errors: { - loadFailed: 'Failed to load affiliate records' - }, - records: { - search: 'Search', - searchPlaceholder: 'Email, username, user ID, or order number', - startAt: 'Start date', - endAt: 'End date', - inviter: 'Inviter', - invitee: 'Invitee', - user: 'User', - affCode: 'Invite Code', - order: 'Order', - totalRebate: 'Total Rebate', - orderAmount: 'Top-up Amount', - payAmount: 'Paid Amount', - rebateAmount: 'Rebate Amount', - paymentType: 'Payment Method', - orderStatus: 'Order Status', - transferAmount: 'Transfer Amount', - balanceAfter: 'Balance After', - availableQuotaAfter: 'Available After', - frozenQuotaAfter: 'Frozen After', - historyQuotaAfter: 'Historical Rebate After', - invitedAt: 'Invited At', - rebatedAt: 'Rebated At', - transferredAt: 'Transferred At' - }, - overview: { - title: 'Affiliate User Overview', - affCode: 'Invite Code', - rebateRate: 'Rebate Rate', - invitedCount: 'Invited Users', - rebatedInviteeCount: 'Rebated Invitees', - availableQuota: 'Available Quota', - historyQuota: 'Historical Rebate' - } - }, - - // Users - users: { - title: 'User Management', - description: 'Manage users and their permissions', - createUser: 'Create User', - editUser: 'Edit User', - deleteUser: 'Delete User', - searchUsers: 'Search by email, username, notes, or API key...', - allRoles: 'All Roles', - allStatus: 'All Status', - allGroups: 'All Groups', - searchGroups: 'Search groups...', - fuzzySearch: 'Fuzzy search', - apiKeyGroupFilter: 'API Key Group', - apiKeyGroupExclusive: 'Exclusive Groups', - apiKeyGroupPublic: 'Public Groups', - apiKeyGroupSubscription: 'Subscription Groups', - apiKeyGroupDisabled: 'Disabled Groups', - authorizedGroupFilter: 'Authorized Group', - allAuthorizedGroups: 'All Authorized Groups', - searchAuthorizedGroups: 'Search authorized groups...', - allApiKeyGroups: 'All API Key Groups', - searchApiKeyGroups: 'Search API Key groups...', - admin: 'Admin', - user: 'User', - disabled: 'Disabled', - email: 'Email', - password: 'Password', - username: 'Username', - notes: 'Notes', - enterEmail: 'Enter email', - enterPassword: 'Enter password', - enterUsername: 'Enter username (optional)', - enterNotes: 'Enter notes (admin only)', - notesHint: 'This note is only visible to administrators', - enterNewPassword: 'Enter new password (optional)', - leaveEmptyToKeep: 'Leave empty to keep current password', - generatePassword: 'Generate random password', - copyPassword: 'Copy password', - creating: 'Creating...', - updating: 'Updating...', - form: { - rpmLimit: 'Requests Per Minute (RPM)', - rpmLimitPlaceholder: '0 = unlimited', - rpmLimitHint: 'Max requests per minute for this user; 0 = unlimited. Acts as a fallback only when the group has no rpm_limit set.' - }, - columns: { - user: 'User', - id: 'ID', - email: 'Email', - username: 'Username', - notes: 'Notes', - role: 'Role', - groups: 'Groups', - subscriptions: 'Subscriptions', - balance: 'Balance', - balancePlatformQuota: 'Balance (Platform Quota)', - usage: 'Usage', - usageAnthropic: 'Usage (Claude)', - usageOpenAI: 'Usage (OpenAI)', - usageGemini: 'Usage (Gemini)', - usageAntigravity: 'Usage (Antigravity)', - concurrency: 'Concurrency', - status: 'Status', - lastActive: 'Last Active', - lastUsed: 'Last Used', - created: 'Created', - actions: 'Actions' - }, - today: 'Today', - total: 'Last 30d', - sortBy: 'Sort By', - sortCurrentPageOnly: 'Sorts current page only', - noSubscription: 'No subscription', - publicGroupCount: '+{count} public', - exclusiveLabel: 'exclusive', - publicLabel: 'public', - daysRemaining: '{days}d', - expired: 'Expired', - disable: 'Disable', - enable: 'Enable', - disableUser: 'Disable User', - enableUser: 'Enable User', - viewApiKeys: 'View API Keys', - groups: 'Groups', - apiKeys: 'API Keys', - userApiKeys: 'User API Keys', - noApiKeys: 'This user has no API keys', - group: 'Group', - none: 'None', - groupChangedSuccess: 'Group updated successfully', - groupChangedWithGrant: 'Group updated. User auto-granted access to "{group}"', - groupChangeFailed: 'Failed to update group', - noUsersYet: 'No users yet', - createFirstUser: 'Create your first user to get started.', - userCreated: 'User created successfully', - userUpdated: 'User updated successfully', - userDeleted: 'User deleted successfully', - userEnabled: 'User enabled successfully', - userDisabled: 'User disabled successfully', - failedToLoad: 'Failed to load users', - failedToCreate: 'Failed to create user', - failedToUpdate: 'Failed to update user', - failedToDelete: 'Failed to delete user', - failedToToggle: 'Failed to update user status', - failedToLoadApiKeys: 'Failed to load user API keys', - emailRequired: 'Please enter email', - concurrencyMin: 'Concurrency must be at least 1', - soraStorageQuota: 'Sora Storage Quota', - soraStorageQuotaHint: 'In GB, 0 means use group or system default quota', - amountRequired: 'Please enter a valid amount', - insufficientBalance: 'Insufficient balance', - deleteConfirm: "Are you sure you want to delete '{email}'? This action cannot be undone.", - setAllowedGroups: 'Set Allowed Groups', - allowedGroupsHint: - 'Select which standard groups this user can use. Subscription groups are managed separately.', - noStandardGroups: 'No standard groups available', - allowAllGroups: 'Allow All Groups', - allowAllGroupsHint: 'User can use any non-exclusive group', - allowedGroupsUpdated: 'Allowed groups updated successfully', - failedToLoadGroups: 'Failed to load groups', - failedToUpdateAllowedGroups: 'Failed to update allowed groups', - // User Group Configuration - groupConfig: 'User Group Configuration', - groupConfigHint: 'Configure custom rate multipliers for user {email} (overrides group defaults)', - exclusiveGroups: 'Exclusive Groups', - publicGroups: 'Public Groups (Default Available)', - defaultRate: 'Default Rate', - customRate: 'Custom Rate', - useDefaultRate: 'Use Default', - customRatePlaceholder: 'Leave empty for default', - groupConfigUpdated: 'Group configuration updated successfully', - replaceGroup: 'Replace Group', - clickToReplace: 'Click to replace', - replaceGroupTitle: 'Replace Exclusive Group', - replaceGroupHint: 'Select a new group to replace "{old}". Keys will be migrated and permissions updated automatically.', - replaceGroupConfirm: 'Confirm Replace', - replaceGroupSuccess: 'Group replaced successfully, {count} key(s) migrated', - selectNewGroup: 'Select target group', - noOtherGroups: 'No other exclusive groups available', - deposit: 'Deposit', - withdraw: 'Withdraw', - depositAmount: 'Deposit Amount', - withdrawAmount: 'Withdraw Amount', - withdrawAll: 'All', - currentBalance: 'Current Balance', - depositNotesPlaceholder: - 'e.g., New user registration bonus, promotional credit, compensation, etc.', - withdrawNotesPlaceholder: - 'e.g., Service issue refund, incorrect charge reversal, account closure refund, etc.', - notesOptional: 'Notes are optional but helpful for record keeping', - amountHint: 'Please enter a positive amount', - newBalance: 'New Balance', - depositing: 'Depositing...', - withdrawing: 'Withdrawing...', - confirmDeposit: 'Confirm Deposit', - confirmWithdraw: 'Confirm Withdraw', - depositSuccess: 'Deposit successful', - withdrawSuccess: 'Withdraw successful', - failedToDeposit: 'Failed to deposit', - failedToWithdraw: 'Failed to withdraw', - useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance', - // Balance History - balanceHistory: 'Recharge History', - balanceHistoryTip: 'Click to open recharge history', - columnAlwaysVisible: 'This column is always visible', - // Per-platform usage breakdown (hover tooltip) - platformBreakdown: 'Per-platform breakdown', - platformBreakdownEmpty: 'No platform usage yet', - platformBreakdownHint: 'Hover for per-platform usage', - platformOther: 'Other', - balanceHistoryTitle: 'User Recharge & Concurrency History', - noBalanceHistory: 'No records found for this user', - allTypes: 'All Types', - typeBalance: 'Balance (Redeem)', - typeAffiliateBalance: 'Balance (Affiliate Transfer)', - typeAdminBalance: 'Balance (Admin)', - typeConcurrency: 'Concurrency (Redeem)', - typeAdminConcurrency: 'Concurrency (Admin)', - typeSubscription: 'Subscription', - failedToLoadBalanceHistory: 'Failed to load balance history', - createdAt: 'Created', - totalRecharged: 'Total Recharged', - roles: { - admin: 'Admin', - user: 'User' - }, - // Settings Dropdowns - filterSettings: 'Filter Settings', - columnSettings: 'Column Settings', - filterValue: 'Enter value', - // User Attributes - attributes: { - title: 'User Attributes', - description: 'Configure custom user attribute fields', - configButton: 'Attributes', - addAttribute: 'Add Attribute', - editAttribute: 'Edit Attribute', - deleteAttribute: 'Delete Attribute', - deleteConfirm: "Are you sure you want to delete attribute '{name}'? All user values for this attribute will be deleted.", - noAttributes: 'No custom attributes', - noAttributesHint: 'Click the button above to add custom attributes', - key: 'Attribute Key', - keyHint: 'For programmatic reference, only letters, numbers and underscores', - name: 'Display Name', - nameHint: 'Name shown in forms', - type: 'Attribute Type', - fieldDescription: 'Description', - fieldDescriptionHint: 'Description text for the attribute', - placeholder: 'Placeholder', - placeholderHint: 'Placeholder text for input field', - required: 'Required', - enabled: 'Enabled', - options: 'Options', - optionsHint: 'For select/multi-select types', - addOption: 'Add Option', - optionValue: 'Option Value', - optionLabel: 'Display Text', - validation: 'Validation Rules', - minLength: 'Min Length', - maxLength: 'Max Length', - min: 'Min Value', - max: 'Max Value', - pattern: 'Regex Pattern', - patternMessage: 'Validation Error Message', - types: { - text: 'Text', - textarea: 'Textarea', - number: 'Number', - email: 'Email', - url: 'URL', - date: 'Date', - select: 'Select', - multi_select: 'Multi-Select' - }, - created: 'Attribute created successfully', - updated: 'Attribute updated successfully', - deleted: 'Attribute deleted successfully', - reordered: 'Attribute order updated successfully', - failedToLoad: 'Failed to load attributes', - failedToCreate: 'Failed to create attribute', - failedToUpdate: 'Failed to update attribute', - keyRequired: 'Please enter attribute key', - nameRequired: 'Please enter display name', - optionsRequired: 'Please add at least one option', - failedToDelete: 'Failed to delete attribute', - failedToReorder: 'Failed to update order', - keyExists: 'Attribute key already exists', - dragToReorder: 'Drag to reorder' - }, - platformQuota: { - menuItem: 'Platform Quotas', - title: 'Platform Quotas', - subtitle: 'Configure daily / weekly / monthly USD usage limits for each upstream platform for user {email}', - columns: { - platform: 'Platform', - daily: 'Daily (USD)', - weekly: 'Weekly (USD)', - monthly: 'Monthly (USD, 30-day rolling)', - usage: 'Current Usage', - }, - placeholder: 'unlimited', - save: 'Save', - saving: 'Saving...', - cancel: 'Cancel', - clearAll: 'Clear All (remove all limits)', - clearAllConfirm: 'Clear daily / weekly / monthly limits for ALL platforms? All platforms will become "unlimited" with no local undo — you must manually re-enter values before saving.', - reset: { - button: 'Reset window', - confirm: 'Reset the {window} usage for {platform} for this user? This is effective immediately.', - success: 'Reset {platform} {window} usage', - failed: 'Reset failed', - }, - updateSuccess: 'Platform quotas updated', - updateFailed: 'Save failed', - loadFailed: 'Load failed', - hint: 'Empty = no limit for that window.', - windowDaily: 'daily', - windowWeekly: 'weekly', - windowMonthly: 'monthly', - cellNotConfigured: 'Not configured', - cellColumnTooltip: 'Only platforms with a limit are shown', - subscriptionWarning: 'This user has an active subscription. Platform quotas only apply to balance (standard) mode requests; subscription mode requests are not subject to these limits.', - invalidNumber: 'The following fields contain invalid numbers. Please fix them before saving: {fields}', - } - }, - - // Groups - groups: { - title: 'Group Management', - description: 'Manage API key groups and rate multipliers', - searchGroups: 'Search groups...', - createGroup: 'Create Group', - editGroup: 'Edit Group', - deleteGroup: 'Delete Group', - sortOrder: 'Sort', - columnSettings: 'Column Settings', - sortOrderHint: 'Drag groups to adjust display order, groups at the top will be displayed first', - sortOrderUpdated: 'Sort order updated', - failedToUpdateSortOrder: 'Failed to update sort order', - allPlatforms: 'All Platforms', - allStatus: 'All Status', - allGroups: 'All Groups', - exclusive: 'Exclusive', - nonExclusive: 'Non-Exclusive', - public: 'Public', - columns: { - name: 'Name', - platform: 'Platform', - rateMultiplier: 'Rate Multiplier', - rpmOverride: 'RPM Override', - rpmOverrideHint: 'Per-user RPM cap in this group; empty = group default; 0 = unlimited', - rateDefault: 'default', - rpmDefault: 'default', - type: 'Type', - accounts: 'Accounts', - capacity: 'Capacity', - usage: 'Usage', - status: 'Status', - actions: 'Actions', - billingType: 'Billing Type', - userName: 'Username', - userEmail: 'Email', - userNotes: 'Notes', - userStatus: 'Status' - }, - usageToday: 'Today', - usageTotal: 'Total', - accountsAvailable: 'Avail:', - accountsRateLimited: 'Limited:', - accountsTotal: 'Total:', - accountsUnit: '', - rateAndAccounts: '{rate}x rate · {count} accounts', - accountsCount: '{count} accounts', - rateLabel: 'rate', - accountFilters: { - title: 'Account Filter Controls', - oauthOnly: 'Only allow OAuth accounts', - oauthOnlyEnabled: 'Enabled — API Key accounts will be excluded', - privacySetOnly: 'Only allow accounts with privacy protection set', - privacySetOnlyEnabled: 'Enabled — accounts with unset Privacy will be excluded', - disabled: 'Disabled' - }, - form: { - name: 'Name', - description: 'Description', - platform: 'Platform', - rateMultiplier: 'Rate Multiplier', - status: 'Status', - exclusive: 'Exclusive Group', - rpmLimit: 'Requests Per Minute (RPM)', - rpmLimitPlaceholder: '0 = unlimited', - rpmLimitHint: 'Max requests per minute for each user in this group; 0 = unlimited. Once set, it takes over per-user rate limiting in this group (overrides the user-level rpm_limit fallback).' - }, - enterGroupName: 'Enter group name', - optionalDescription: 'Optional description', - platformHint: 'Select the platform this group is associated with', - platformNotEditable: 'Platform cannot be changed after creation', - rateMultiplierHint: 'Cost multiplier for this group (e.g., 1.5 = 150% of base cost)', - exclusiveHint: 'Exclusive group, manually assign to specific users', - exclusiveTooltip: { - title: 'What is an exclusive group?', - description: 'When enabled, users cannot see this group when creating API Keys. Only after an admin manually assigns a user to this group can they use it.', - example: 'Use case:', - exampleContent: 'Public group rate is 0.8. Create an exclusive group with 0.7 rate, manually assign VIP users to give them better pricing.' - }, - noGroupsYet: 'No groups yet', - createFirstGroup: 'Create your first group to organize API keys.', - creating: 'Creating...', - updating: 'Updating...', - limitDay: 'd', - limitWeek: 'w', - limitMonth: 'mo', - groupCreated: 'Group created successfully', - groupUpdated: 'Group updated successfully', - groupDeleted: 'Group deleted successfully', - failedToLoad: 'Failed to load groups', - failedToCreate: 'Failed to create group', - failedToUpdate: 'Failed to update group', - failedToDelete: 'Failed to delete group', - nameRequired: 'Please enter group name', - rateMultipliers: 'Rate Multipliers', - rateMultipliersTitle: 'Group Rate Multipliers', - addUserRate: 'Add User Rate Multiplier', - rpmOverrides: 'RPM Overrides', - rpmOverridesTitle: 'Group RPM Overrides', - addUserRpm: 'Add User RPM Override', - noRpmOverrides: 'No users have an RPM override yet', - rpmSaved: 'RPM overrides saved', - groupRpmDefault: 'Group default RPM', - searchUserPlaceholder: 'Search user email...', - noRateMultipliers: 'No user rate multipliers configured', - rateUpdated: 'Rate multiplier updated', - rateDeleted: 'Rate multiplier removed', - rateAdded: 'Rate multiplier added', - clearAll: 'Clear All', - confirmClearAll: 'Are you sure you want to clear all rate multiplier settings for this group? This cannot be undone.', - rateCleared: 'All rate multipliers cleared', - batchAdjust: 'Batch Adjust Rates', - multiplierFactor: 'Factor', - applyMultiplier: 'Apply', - rateAdjusted: 'Rates adjusted successfully', - rateSaved: 'Rate multipliers saved', - finalRate: 'Final Rate', - unsavedChanges: 'Unsaved changes', - revertChanges: 'Revert', - userInfo: 'User Info', - platforms: { - all: 'All Platforms', - anthropic: 'Anthropic', - openai: 'OpenAI', - gemini: 'Gemini', - antigravity: 'Antigravity', - grok: 'Grok', - }, - deleteConfirm: - "Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.", - deleteConfirmSubscription: - "Are you sure you want to delete subscription group '{name}'? This will invalidate all API keys bound to this subscription and delete all related subscription records. This action cannot be undone.", - subscription: { - title: 'Subscription Settings', - type: 'Billing Type', - typeHint: - 'Standard billing deducts from user balance. Subscription mode uses quota limits instead.', - typeNotEditable: 'Billing type cannot be changed after group creation.', - standard: 'Standard (Balance)', - subscription: 'Subscription (Quota)', - dailyLimit: 'Daily Limit (USD)', - weeklyLimit: 'Weekly Limit (USD)', - monthlyLimit: 'Monthly Limit (USD)', - defaultValidityDays: 'Default Validity (Days)', - validityHint: 'Number of days the subscription is valid when assigned to a user', - noLimit: 'No limit' - }, - imagePricing: { - title: 'Image Generation Pricing', - description: 'Configure image generation access and base image prices. Leave empty to use default prices.', - allowImageGeneration: 'Allow image generation for this group', - allowBatchImageGeneration: 'Allow batch image generation for this group', - independentMultiplier: 'Use independent image multiplier', - imageMultiplier: 'Image multiplier', - batchDiscountMultiplier: 'Batch image discount', - batchHoldMultiplier: 'Batch hold price ratio', - batchSectionHint: 'Batch image settings only apply to batch jobs: settlement applies the batch discount, and the upfront hold is normal image price × batch hold price ratio. Reference images also create upstream input-token usage, so a batch image discount above 0.5 is recommended.', - batchDisabledHint: 'Enable image generation for this group before enabling batch image generation.', - batchGeminiOnlyHint: 'Batch image generation is currently available only for Gemini groups.', - modeHint: 'By default, image billing uses image price × current effective group multiplier. Independent mode uses image price × image multiplier.', - finalPricePreview: 'Final per-image price preview', - notConfigured: 'Not configured' - }, - peakRate: { - enable: 'Enable peak rate multiplier', - peakStart: 'Peak start', - peakEnd: 'Peak end', - peakMultiplier: 'Peak multiplier', - multiplierHint: 'Applies to token billing multiplier; image tokens in token billing are also affected. 0 means peak token requests are billed at 0x.' - }, - modelsList: { - title: 'Custom /v1/models Model List', - hint: 'Only changes the /v1/models response. Whitelist model calls and account routing are unchanged.', - loading: 'Loading model list...', - empty: 'No displayable models', - selectedSummary: 'Selected {selected} / {total}', - selectAll: 'Select all', - invertSelection: 'Invert' - }, - claudeCode: { - title: 'Claude Code Client Restriction', - tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.', - enabled: 'Claude Code Only', - disabled: 'Allow All Clients', - fallbackGroup: 'Fallback Group', - fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.', - noFallback: 'No Fallback (Reject)' - }, - openaiMessages: { - title: 'OpenAI Messages Dispatch', - allowDispatch: 'Allow /v1/messages dispatch', - allowDispatchHint: 'When enabled, API keys in this OpenAI group can dispatch requests through /v1/messages endpoint', - familyMappingTitle: 'Family Default Mapping', - familyMappingHint: 'Requests that match the Opus, Sonnet, or Haiku families will prefer the target model configured here.', - opusModel: 'Opus Target Model', - opusModelPlaceholder: 'e.g., gpt-5.4', - sonnetModel: 'Sonnet Target Model', - sonnetModelPlaceholder: 'e.g., gpt-5.3-codex', - haikuModel: 'Haiku Target Model', - haikuModelPlaceholder: 'e.g., gpt-5.4-mini', - exactMappingTitle: 'Exact Model Overrides', - exactMappingHint: 'Exact Claude model overrides take priority over the family defaults and can route a specific Claude model to a different target model.', - noExactMappings: 'No exact model overrides yet', - addExactMapping: 'Add Exact Mapping', - claudeModel: 'Claude Model', - claudeModelPlaceholder: 'e.g., claude-sonnet-4-5-20250929', - targetModel: 'Target Model', - targetModelPlaceholder: 'e.g., gpt-5.4', - removeExactMapping: 'Remove Exact Mapping' - }, - invalidRequestFallback: { - title: 'Invalid Request Fallback Group', - hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.', - noFallback: 'No Fallback' - }, - copyAccounts: { - title: 'Copy Accounts from Groups', - tooltip: 'Select one or more groups of the same platform. After creation, all accounts from these groups will be automatically bound to the new group (deduplicated).', - tooltipEdit: 'Select one or more groups of the same platform. After saving, current group accounts will be replaced with accounts from these groups (deduplicated).', - selectPlaceholder: 'Select groups to copy accounts from...', - hint: 'Multiple groups can be selected, accounts will be deduplicated', - hintEdit: '⚠️ Warning: This will replace all existing account bindings' - }, - modelRouting: { - title: 'Model Routing', - tooltip: 'Configure specific model requests to be routed to designated accounts. Supports wildcard matching, e.g., claude-opus-* matches all opus models.', - enabled: 'Enabled', - disabled: 'Disabled', - disabledHint: 'Routing rules will only take effect when enabled', - addRule: 'Add Routing Rule', - modelPattern: 'Model Pattern', - modelPatternPlaceholder: 'claude-opus-*', - modelPatternHint: 'Supports * wildcard, e.g., claude-opus-* matches all opus models', - accounts: 'Priority Accounts', - selectAccounts: 'Select accounts', - noAccounts: 'No accounts in this group', - loadingAccounts: 'Loading accounts...', - removeRule: 'Remove Rule', - noRules: 'No routing rules', - noRulesHint: 'Add routing rules to route specific model requests to designated accounts', - searchAccountPlaceholder: 'Search accounts...', - accountsHint: 'Select accounts to prioritize for this model pattern' - }, - mcpXml: { - title: 'MCP XML Protocol Injection', - tooltip: 'When enabled, if the request contains MCP tools, an XML format call protocol prompt will be injected into the system prompt. Disable this to avoid interference with certain clients.', - enabled: 'Enabled', - disabled: 'Disabled' - }, - claudeMaxSimulation: { - title: 'Claude Max Usage Simulation', - tooltip: - 'When enabled, for Claude models without upstream cache-write usage, the system deterministically maps tokens to a small input plus 1h cache creation while keeping total tokens unchanged.', - enabled: 'Enabled (simulate 1h cache)', - disabled: 'Disabled', - hint: 'Only token categories in usage billing logs are adjusted. No per-request mapping state is persisted.' - }, - supportedScopes: { - title: 'Supported Model Families', - tooltip: 'Select the model families this group supports. Unchecked families will not be routed to this group.', - claude: 'Claude', - geminiText: 'Gemini Text', - geminiImage: 'Gemini Image', - hint: 'Select at least one model family' - } - }, - - // Available Channels (aggregated read-only view) - availableChannels: { - title: 'Available Channels', - description: 'Aggregated view: each channel with its linked groups and supported models (wildcards expanded)', - searchPlaceholder: 'Search channels or models...', - columns: { - name: 'Channel', - status: 'Status', - billingSource: 'Billing Model Source', - groups: 'Linked Groups', - supportedModels: 'Supported Models' - }, - empty: 'No data', - noGroups: 'No linked groups', - noModels: 'No model mapping configured', - noPricing: 'Pricing not configured', - statusActive: 'Active', - statusDisabled: 'Disabled', - billingSource: { - requested: 'Requested model', - upstream: 'Upstream model', - channel_mapped: 'Channel-mapped model' - }, - pricing: { - billingMode: 'Billing Mode', - billingModeToken: 'Per Token', - billingModePerRequest: 'Per Request', - billingModeImage: 'Per Image', - inputPrice: 'Input', - outputPrice: 'Output', - cacheWritePrice: 'Cache Write', - cacheReadPrice: 'Cache Read', - imageOutputPrice: 'Image Output', - perRequestPrice: 'Per Request', - intervals: 'Tiered Pricing', - unitPerMillion: '/ 1M tokens', - unitPerRequest: '/ request' - } - }, - - // Channel Management - channels: { - title: 'Channel Management', - description: 'Manage channels and custom model pricing', - searchChannels: 'Search channels...', - createChannel: 'Create Channel', - editChannel: 'Edit Channel', - deleteChannel: 'Delete Channel', - statusActive: 'Active', - statusDisabled: 'Disabled', - allStatus: 'All Status', - groupsUnit: 'groups', - pricingUnit: 'pricing rules', - noChannelsYet: 'No Channels Yet', - createFirstChannel: 'Create your first channel to manage model pricing', - loadError: 'Failed to load channels', - createSuccess: 'Channel created', - updateSuccess: 'Channel updated', - deleteSuccess: 'Channel deleted', - createError: 'Failed to create channel', - updateError: 'Failed to update channel', - deleteError: 'Failed to delete channel', - nameRequired: 'Please enter a channel name', - duplicateModels: 'Model "{0}" appears in multiple pricing entries', - modelConflict: "Model patterns '{model1}' and '{model2}' conflict: overlapping match range. Model names are matched case-insensitively, so an existing entry already covers all case variants — no need to add the variant separately.", - mappingConflict: "Mapping source patterns '{model1}' and '{model2}' conflict: overlapping match range. Source patterns are matched case-insensitively, so an existing entry already covers all case variants.", - intervalValidation: { - negativeMin: 'Interval #{index}: minimum token count ({value}) cannot be negative', - maxPositive: 'Interval #{index}: maximum token count ({value}) must be greater than 0', - maxGreaterThanMin: 'Interval #{index}: maximum token count ({max}) must be greater than minimum token count ({min})', - negativePrice: 'Interval #{index}: {field} cannot be negative', - unboundedLast: 'Interval #{index}: an unbounded interval (empty maximum token count) must be last', - overlap: 'Intervals #{previousIndex} and #{currentIndex} overlap: previous upper bound ({previousMax}) is greater than current lower bound ({currentMin})', - price: { - inputPrice: 'input price', - outputPrice: 'output price', - cacheWritePrice: 'cache write price', - cacheReadPrice: 'cache read price', - perRequestPrice: 'per-request price' - } - }, - deleteConfirm: 'Are you sure you want to delete channel "{name}"? This cannot be undone.', - columns: { - name: 'Name', - description: 'Description', - status: 'Status', - groups: 'Groups', - pricing: 'Pricing', - createdAt: 'Created', - actions: 'Actions' - }, - billingMode: { - token: 'Token', - perRequest: 'Per Request', - image: 'Image (Per Request)' - }, - form: { - name: 'Name', - namePlaceholder: 'Enter channel name', - description: 'Description', - descriptionPlaceholder: 'Optional description', - status: 'Status', - groups: 'Associated Groups', - noGroupsAvailable: 'No groups available', - inOtherChannel: 'In "{name}"', - modelPricing: 'Model Pricing', - models: 'Models', - modelsPlaceholder: 'Type full model name and press Enter', - modelInputHint: 'Press Enter to add, supports paste for batch import.', - billingMode: 'Billing Mode', - defaultPrices: 'Default prices (fallback when no interval matches)', - inputPrice: 'Input', - outputPrice: 'Output', - cacheWritePrice: 'Cache Write', - cacheReadPrice: 'Cache Read', - cacheWritePriceShort: 'Cache W', - cacheReadPriceShort: 'Cache R', - imageTokenPrice: 'Image Output', - imageOutputPrice: 'Image Output Price', - pricePlaceholder: 'Default', - intervals: 'Context Intervals (optional)', - minTokens: 'Min', - maxTokens: 'Max', - inclusive: '(inclusive)', - addInterval: 'Add Interval', - requestTiers: 'Request Tiers', - imageTiers: 'Image Tiers (Per Request)', - addTier: 'Add Tier', - noTiersYet: 'No tiers yet. Click add to configure per-request pricing.', - noPricingRules: 'No pricing rules yet. Click "Add" to create one.', - perRequestPrice: 'Price per Request', - perRequestPriceRequired: 'Per-request price or billing tiers required for per-request/image billing mode', - tierLabel: 'Tier', - resolution: 'Resolution', - modelMapping: 'Model Mapping', - modelMappingHint: 'Map request model names to actual model names. Runs before account-level mapping.', - noMappingRules: 'No mapping rules. Click "Add" to create one.', - mappingSource: 'Source model', - mappingTarget: 'Target model', - billingModelSource: 'Billing Model', - billingModelSourceChannelMapped: 'Bill by channel-mapped model', - billingModelSourceRequested: 'Bill by requested model', - billingModelSourceUpstream: 'Bill by final upstream model', - billingModelSourceHint: 'Controls which model name is used for pricing lookup', - selectedCount: '{count} selected', - searchGroups: 'Search groups...', - noGroupsMatch: 'No groups match your search', - restrictModels: 'Restrict Models', - restrictModelsHint: 'When enabled, only models in the pricing list are allowed. Others will be rejected.', - defaultPerRequestPrice: 'Default per-request price (fallback when no tier matches)', - defaultImagePrice: 'Default image price (fallback when no tier matches)', - platformConfig: 'Platform Configuration', - webSearchEmulation: 'Web Search Emulation', - webSearchEmulationHint: '⚠️ When enabled, all accounts in this channel\'s Anthropic groups will intercept web_search requests. Use with caution.', - webSearchEmulationGlobalDisabled: 'Please enable the global switch first in Settings → Gateway → Web Search Emulation', - codexImageGenerationBridge: 'Codex Image Generation Bridge', - codexImageGenerationBridgeHint: 'When enabled, Codex /responses text requests in OpenAI groups may be automatically given the image_generation tool. Keep off unless the routed accounts support image generation.', - bedrockCCCompat: 'Bedrock CC Compatibility', - bedrockCCCompatHint: '⚠️ When enabled, requests to Bedrock accounts in this channel will be transformed for Claude Code compatibility (thinking type conversion, tool_use ID sanitization).', - basicSettings: 'Basic Settings', - addPlatform: 'Add Platform', - noPlatforms: 'Click "Add Platform" to start configuring the channel', - mappingCount: 'mappings', - pricingEntry: 'Pricing Entry', - noModels: 'No models added', - applyPricingToAccountStats: 'Apply Pricing to Account Stats', - applyPricingToAccountStatsDesc: 'When enabled, requests not matched by custom rules will use standard model pricing for account stats calculation', - accountStatsPricingRules: 'Custom Account Stats Pricing Rules', - addRule: 'Add Rule', - noRulesConfigured: 'No custom rules configured. Channel model pricing above will be used.', - ruleName: 'Rule name (optional)', - ruleGroups: 'Groups', - ruleAccounts: 'Accounts', - searchAccountPlaceholder: 'Search accounts...', - ruleAccountsHint: 'Leave empty to match all accounts', - ruleModelPricing: 'Model Pricing', - noGroupsInChannel: 'No groups selected in platform tabs above', - unnamed: 'Unnamed', - syncLatestModels: 'Sync Latest Models', - syncingModels: 'Syncing...', - syncModelsSuccess: 'Synced {count} new model(s)', - syncModelsAlreadyUpToDate: 'Models already up to date', - syncModelsError: 'Failed to sync models' - } - }, - - riskControl: { - title: 'Risk Control', - description: 'Configure content moderation and review audit records', - loadFailed: 'Failed to load risk control', - saveFailed: 'Failed to save content moderation config', - logsFailed: 'Failed to load audit records', - saved: 'Content moderation config saved', - refresh: 'Refresh', - config: 'Content Moderation Config', - configHint: 'Use OpenAI Moderations to score request content and handle threshold hits by mode.', - openSettings: 'Moderation Settings', - settingsTitle: 'Content Moderation Settings', - refreshStatus: 'Refresh Status', - records: 'Audit Records', - recordsHint: 'Shows hits, blocks, errors, and sampled records.', - saveConfig: 'Save Moderation Config', - statusFailed: 'Failed to load runtime status', - enabled: 'Enable Content Moderation', - enabledHint: 'When off, gateway requests are not moderated even if the menu is enabled.', - mode: 'Global Mode', - modePreBlock: 'Pre-Block', - modePreBlockDesc: 'Synchronously reviews the latest user input before every request and rejects hits immediately.', - modeObserve: 'Observe Only', - modeObserveDesc: 'Requests pass through while the latest user input is queued for async review; hits are recorded, notified, and counted.', - modeOff: 'Off', - modeOffDesc: 'Content moderation is disabled and no audit records are written.', - baseUrl: 'OpenAI Base URL', - model: 'Model', - apiKey: 'OpenAI API Key', - apiKeys: 'OpenAI API Keys', - apiKeyCount: '{count} keys', - apiKeyPlaceholder: 'Enter API Key', - apiKeysPlaceholder: 'Add API Keys, one per line. They will be appended on save.', - apiKeysPlaceholderReplace: 'Replace API Keys, one per line. Stored keys will be replaced on save.', - apiKeysPlaceholderKeep: 'Add API Keys, one per line. They will be appended on save.', - apiKeysHint: '{count} keys are currently stored. This input only adds keys; save appends and de-duplicates them.', - apiKeysWriteMode: 'Write mode', - apiKeysModeAppend: 'Add', - apiKeysModeReplace: 'Replace', - apiKeysModeAppendHint: 'Default: save appends input keys and keeps stored keys.', - apiKeysModeReplaceHint: 'Replace mode: save replaces all stored keys with input keys.', - apiKeysReplaceWarning: 'Replace mode', - apiKeysReplaceNoInput: 'Replace mode requires at least 1 API Key', - apiKeyPlaceholderKeep: 'Leave empty to keep current key', - apiKeyWillClear: 'Configured key will be cleared on save', - apiKeyConfigured: 'Configured', - apiKeyTemporary: 'Pending', - apiKeyPendingDelete: 'Pending delete', - apiKeyPendingDeleteCount: '{count} keys pending deletion', - deleteApiKey: 'Delete this key', - undoDeleteApiKey: 'Undo delete', - inputApiKeyCount: '{count} keys in input', - storedApiKeyCount: '{count} stored keys', - testInputApiKeys: 'Test input keys', - testStoredApiKeys: 'Test stored keys', - testContentWithStoredApiKey: 'Test content with stored key', - testingApiKeys: 'Testing', - apiKeyTestNoInput: 'Enter OpenAI API Keys to test first', - apiKeyTestDone: 'Key test completed for {count} keys', - apiKeyTestFailed: 'Failed to test OpenAI API Keys', - apiKeyHealth: 'Key Availability', - apiKeyFreezeRule: '400 does not freeze; 401/403 freeze for 10 minutes; 429/529 freeze for 1 minute; other HTTP errors freeze for 10 seconds.', - apiKeyRows: '{count} keys', - apiKeyRowsCollapsed: '{count} keys hidden', - apiKeyRowsExpanded: 'Showing all {count} keys', - expandApiKeyRows: 'Expand', - collapseApiKeyRows: 'Collapse', - apiKeyHealthEmpty: 'No key status yet', - apiKeyHealthEmptyHint: 'Save keys or test input keys to see availability.', - apiKeyStatusOk: 'Available', - apiKeyStatusError: 'Error', - apiKeyStatusFrozen: 'Frozen', - apiKeyStatusUnknown: 'Untested', - apiKeyFailureCount: '{count} failures', - apiKeyLatency: '{ms} ms', - apiKeyHTTPStatus: 'HTTP {status}', - apiKeyFrozenUntil: 'Frozen until {time}', - apiKeyLastChecked: 'Checked at {time}', - apiKeyNotTested: 'Not tested', - auditTestInput: 'Audit Test Input', - auditTestInputHint: 'Enter a prompt and upload or paste images; images are sent as base64 and are not stored.', - auditTestPromptPlaceholder: 'Enter a user prompt to test; leave empty to only test key availability.', - auditTestImages: 'Test Images', - auditTestImagesHint: 'Upload, drag, or paste images. Up to 1 image, 8MB each.', - addAuditTestImage: 'Add image', - clearAuditTest: 'Clear test', - auditTestImageLimit: 'You can add up to {count} test images', - auditTestImageTooLarge: 'Each test image must be 8MB or smaller', - auditTestImageReadFailed: 'Failed to read test image', - auditTestResult: 'Audit Test Result', - auditTestHighest: 'Top category {category}, score {score}', - auditTestComposite: 'Composite score', - auditTestFlagged: 'Threshold hit', - auditTestPassed: 'Pass', - notConfigured: 'Not configured', - clearApiKey: 'Clear stored key', - keepApiKey: 'Keep stored key', - timeoutMs: 'HTTP Timeout (ms)', - retryCount: 'Retry Count', - sampleRate: 'Sample Rate', - recordNonHits: 'Record Non-Hits', - recordNonHitsHint: 'When enabled, sampled non-hit request summaries are redacted before storage.', - preHashCheck: 'Enable Pre-Hash Check', - preHashCheckHint: 'Hashes from async hits are blocked before moderation; this does not send email or increment ban counters.', - flaggedHashCount: 'Current hash collection size: {count}', - flaggedHashHint: 'Hashes are stored permanently in Redis; paste a full 64-character hash to remove a false block, or clear all stored hashes.', - flaggedHashPlaceholder: 'Paste full 64-character input hash', - deleteFlaggedHash: 'Delete hash', - clearFlaggedHashes: 'Clear all', - clearFlaggedHashesConfirm: 'Clear all risk input hashes? This does not delete audit records, but removes all historical hash blocks.', - flaggedHashDeleted: 'Risk hash deleted', - flaggedHashNotFound: 'Risk hash not found', - flaggedHashDeleteFailed: 'Failed to delete risk hash', - flaggedHashesCleared: 'Cleared {count} risk hashes', - flaggedHashesClearFailed: 'Failed to clear risk hashes', - workerCount: 'Worker Count', - queueSize: 'Async Queue Size', - blockStatus: 'Block HTTP Status', - blockMessage: 'Custom Block Message', - defaultBlockMessage: 'Content audit matched a risk rule. Please adjust your input and try again.', - emailOnHit: 'Email on Hit', - emailOnHitHint: 'When enabled, send a risk-control email on every hit; auto-ban notices are always sent.', - autoBan: 'Auto Ban User', - autoBanHint: 'Disable the user, invalidate auth cache, and send a ban notice after the hit threshold is reached.', - cyberPolicyExcludeBan: 'Exclude Cyber Policy Hits from Ban Count', - cyberPolicyExcludeBanHint: 'When enabled, cyber_policy hits no longer count toward auto-ban violations: no ban judgment on the hit itself, and history rows are excluded from the rolling count. Logs and notice emails are unaffected.', - violationNotCounted: 'Not counted', - banThreshold: 'Ban Threshold', - violationWindowHours: 'Count Window (hours)', - hitRetentionDays: 'Hit Record Retention (days)', - nonHitRetentionDays: 'Non-Hit Record Retention (days, max 3)', - violationCount: '{count} hits', - emailSent: 'Email sent', - emailNotSent: 'No email', - autoBanned: 'Banned', - unbanUser: 'Unban', - unbanSuccess: 'User has been unbanned', - unbanFailed: 'Failed to unban user', - inputDetailTitle: 'Input Summary Detail', - inputDetailContent: 'Full Content', - matchedKeyword: 'Matched Keyword', - queueDelay: 'Queued {ms} ms', - allGroups: 'All Groups', - allGroupsHint: 'Auditing all groups', - selectedGroupsHint: 'Auditing selected groups', - groupScope: 'Audit Groups', - groupScopeHint: 'Switch on for all groups, or turn off to choose specific groups.', - selectedGroups: 'Selected Groups', - searchGroups: 'Search group name or platform', - noGroups: 'No groups available', - modelFilter: 'Model scope', - modelFilterHint: 'Moderate by the client-requested model name; channel model mappings do not change this match.', - modelFilterAll: 'All models', - modelFilterAllDesc: 'All model requests go through content moderation.', - modelFilterInclude: 'Only selected', - modelFilterIncludeDesc: 'Only listed models go through content moderation.', - modelFilterExclude: 'Exclude selected', - modelFilterExcludeDesc: 'Listed models skip content moderation; other models are moderated.', - modelFilterModels: 'Model list', - modelFilterModelCount: '{count} models configured', - modelFilterModelsRequired: 'This model scope requires at least 1 model', - modelFilterAllSummary: 'Applies to all models', - modelFilterIncludeSummary: 'Applies to {count} models', - modelFilterExcludeSummary: 'Excludes {count} models', - emptyLogs: 'No audit records', - preBlockSyncStatus: 'Pre-Block Sync Status', - preBlockSyncHint: 'Live counters for the synchronous moderation path, excluding async record tasks.', - preBlockActive: 'Sync Processing', - preBlockActiveHint: 'Currently checking', - preBlockChecked: 'Checked', - preBlockCheckedHint: 'Entered pre-block path', - preBlockAllowed: 'Allowed', - preBlockAllowedHint: 'No block triggered', - preBlockBlocked: 'Blocked', - preBlockBlockedHint: 'Rejected after hit', - preBlockErrors: 'Audit Errors', - preBlockErrorsHint: 'Failed or no usable key', - preBlockAvgLatency: 'Avg Latency', - preBlockAvgLatencyHint: 'Synchronous path average', - preBlockAPIKeyLoad: 'Audit Key Load', - preBlockAPIKeyLoadHint: 'Synchronous pre-block checks round-robin usable audit keys directly.', - preBlockAPIKeyLoadSummary: 'Sync active {active} / usable keys {available}, {total} total, worker: {workerActive} / {workerTotal}', - preBlockAPIKeyTotals: 'Total {total}, success {success}, errors {errors}', - preBlockAPIKeyLoadEmpty: 'No audit key load data yet', - preBlockKeyActiveShort: 'Active', - preBlockKeyTotalShort: 'Total', - preBlockKeyAvgShort: 'Avg', - preBlockKeyLastShort: 'Last', - workerStatus: 'Worker Runtime', - workerStatusHint: 'Queue and worker pool status for async audit tasks and pre-block record tasks, excluding synchronous pre-block checks.', - workerPool: 'Worker Pool', - workerPoolMeta: '{active} processing, {idle} idle and ready, {total} total', - queueUsage: 'Queue Usage', - activeWorkers: 'Processing', - idleWorkers: 'Idle Ready', - workerActive: 'Processing an async audit or record task', - workerIdle: 'Started, idle and ready', - workerDisabled: 'Risk control or content audit is disabled', - processed: 'Processed', - droppedErrors: 'Dropped / Errors', - autoRefresh: 'Auto refresh every 15s', - lastCleanup: 'Last cleanup: {time}', - cleanupStats: 'Last cleanup deleted {hit} hits and {nonHit} non-hits', - riskSwitchOff: 'System switch off', - riskThresholds: 'Risk Thresholds', - riskThresholdsHint: 'Adjust hit thresholds by OpenAI Moderations category. Scores greater than or equal to the threshold count as hits.', - riskThresholdDefault: 'Default {value}', - riskThresholdReset: 'Restore defaults', - riskThresholdPercent: 'Threshold percentage', - tabs: { - basic: 'Basic', - scope: 'Scope', - runtime: 'Runtime', - response: 'Hit Notice', - riskThresholds: 'Risk Thresholds', - keywords: 'Keyword Block', - retention: 'Retention', - }, - blockedKeywords: 'Blocked keywords', - blockedKeywordsPlaceholder: 'One keyword per line, e.g.:\nbadword1\nbadword2', - blockedKeywordsDescription: 'Matching is case-insensitive. Whether the upstream moderation API is invoked after a hit depends on the strategy below.', - blockedKeywordsPreBlockHint: 'Keyword blocking only takes effect in "Pre-block" mode.', - blockedKeywordsModeWarning: 'Current mode is "{mode}". Keyword blocking will not run until you switch to "Pre-block" mode.', - blockedKeywordCount: '{count} keywords configured', - blockedKeywordsLimit: 'Up to {max} keywords, each no longer than 200 characters. Duplicates are removed automatically.', - keywordBlockingMode: 'Moderation strategy', - keywordModeKeywordAndApi: 'Keyword + API', - keywordModeKeywordAndApiDesc: 'Block on keyword hit; otherwise fall through to the upstream moderation API.', - keywordModeKeywordOnly: 'Keyword only', - keywordModeKeywordOnlyDesc: 'Decide using keywords only; misses are allowed without calling the API, saving upstream cost.', - keywordModeKeywordOnlyNotice: 'Keyword-only strategy: requests that do not match any keyword are allowed without calling the upstream moderation API.', - keywordModeApiOnly: 'API only', - keywordModeApiOnlyDesc: 'Use the upstream moderation API only; the keyword list configured here is not consulted.', - keywordModeApiOnlyNotice: 'API-only strategy: the keyword list is not consulted; all requests go through the upstream moderation API.', - overview: { - status: 'Status', - enabled: 'Enabled', - disabled: 'Disabled', - apiKey: 'API Key', - groupScope: 'Scope', - logs: 'Audit Records', - currentFilter: 'Current filter', - }, - filters: { - search: 'Search user/key/summary', - from: 'From', - to: 'To', - allGroups: 'All Groups', - allEndpoints: 'All Endpoints', - }, - table: { - time: 'Time', - group: 'Group', - user: 'User', - apiKey: 'API Key', - endpoint: 'Endpoint', - result: 'Result', - highest: 'Highest', - actionMeta: 'Action', - latency: 'Latency', - input: 'Input Summary', - }, - result: { - all: 'All Results', - hit: 'Hit', - blocked: 'Blocked', - pass: 'Pass', - error: 'Error', - }, - action: { - block: 'Blocked', - keywordBlock: 'Keyword Blocked', - cyberPolicy: 'Cyber policy', - error: 'Error', - }, - }, - - // Channel Monitor - channelMonitor: { - title: 'Channel Monitor', - description: 'Monitor channel availability, latency and status', - searchPlaceholder: 'Search monitor name...', - allProviders: 'All Providers', - allStatus: 'All Status', - enabledFilter: 'Enabled', - onlyEnabled: 'Enabled only', - onlyDisabled: 'Disabled only', - createButton: 'Create Monitor', - createTitle: 'Create Channel Monitor', - editTitle: 'Edit Channel Monitor', - runNow: 'Run Now', - runSuccess: 'Check completed', - runFailed: 'Check failed', - apiKeyDecryptFailed: 'API Key decryption failed. Please re-edit this monitor with a fresh key.', - createSuccess: 'Monitor created', - updateSuccess: 'Monitor updated', - deleteSuccess: 'Monitor deleted', - loadError: 'Failed to load monitors', - deleteConfirm: 'Are you sure you want to delete monitor "{name}"? This action cannot be undone.', - nameRequired: 'Please enter a monitor name', - primaryModelRequired: 'Please enter a primary model', - columns: { - name: 'Name', - provider: 'Provider', - primaryModel: 'Primary Model', - availability7d: '7d Availability', - latency: 'Latency (ms)', - enabled: 'Enabled', - actions: 'Actions' - }, - form: { - name: 'Name', - namePlaceholder: 'Enter monitor name', - provider: 'Platform', - apiMode: 'OpenAI protocol', - apiModeChatCompletions: 'OpenAI Compatible', - apiModeChatCompletionsHint: 'Use /v1/chat/completions with messages; works for most compatible providers.', - apiModeResponses: 'Responses API', - apiModeResponsesHint: 'Use /v1/responses with default instructions + input; best for self-check/Codex paths.', - endpoint: 'Endpoint', - endpointPlaceholder: 'https://api.example.com', - useCurrentDomain: 'Use current service', - apiKey: 'API Key', - apiKeyPlaceholder: 'Enter API Key', - apiKeyEditPlaceholder: 'Leave blank to keep current key', - useMyKey: 'Use my key', - selectKeyTitle: 'Select my API Key', - selectKeyHint: 'Only your active, non-expired keys are listed.', - noActiveKey: 'No active API keys available', - primaryModel: 'Primary Model', - primaryModelPlaceholder: 'gpt-4o-mini', - extraModels: 'Extra Models', - extraModelsPlaceholder: 'Press Enter to add extra model', - groupName: 'Group Name', - groupNamePlaceholder: 'Optional, used to group rows in user view', - intervalSeconds: 'Interval (seconds)', - intervalSecondsHint: 'Range: 15 - 3600 seconds', - jitterSeconds: 'Random Jitter (± seconds)', - jitterSecondsHint: 'Each check fires at interval ± a random offset within this value; 0 means fixed interval. Interval minus jitter must be ≥ 15s', - enabled: 'Enable monitor', - kindRequired: 'Please select a provider' - }, - runResultTitle: 'Check Result', - noMonitorsYet: 'No monitors yet', - createFirstMonitor: 'Create your first monitor to track channel availability', - advanced: { - section: 'Advanced (optional)', - sectionHint: 'Customize request headers and body to bypass upstream client-detection (e.g. "only Claude Code clients allowed").', - headers: 'Custom request headers', - headersPlaceholder: 'User-Agent: claude-cli/1.0.83 (external, cli)\nx-app: cli\nanthropic-beta: claude-code-20250219', - headerNamePlaceholder: 'Header name', - headerValuePlaceholder: 'Value', - headerAddRow: 'Add header', - headerNameInvalid: 'Header name cannot contain whitespace or colon: {name}', - headersHint: 'Merged on top of adapter defaults (user wins). Hop-by-hop headers (Host / Content-Length / ...) are ignored.', - headersParseError: 'Cannot parse line: {line}', - bodyMode: 'Body handling', - bodyModeOff: 'Default', - bodyModeMerge: 'Merge', - bodyModeReplace: 'Replace', - bodyModeHintOff: 'Use the adapter default body (includes challenge validation).', - bodyModeHintMerge: 'Shallow-merge with the default body; user fields win but model / messages / contents are protected (use Replace to change those).', - bodyModeHintReplace: 'Use the JSON below as the complete body. Challenge validation is skipped; HTTP 2xx + non-empty response text is treated as operational.', - bodyJson: 'Body JSON', - bodyJsonFormat: 'Format', - bodyJsonHint: 'Parsed on blur. Empty means no override.', - bodyJsonError: 'JSON parse failed', - bodyJsonObjectError: 'Body must be a JSON object (no arrays or primitives)' - }, - templateField: { - label: 'Request template', - none: 'No template', - placeholder: 'Pick a template (filtered by current provider)', - applyHint: 'Picking a template copies its headers and body to this monitor (snapshot). Later template edits are not auto-synced.' - }, - template: { - manageButton: 'Templates', - managerTitle: 'Request template manager', - createButton: 'New template', - emptyState: 'No templates for this provider yet', - missingName: 'Template name is required', - createSuccess: 'Template created', - updateSuccess: 'Template updated', - deleteSuccess: 'Template deleted', - applyButton: 'Apply to monitors', - applyTooltip: 'Overwrite snapshot fields on associated monitors', - applyTitle: 'Apply template', - applyConfirm: 'Apply', - applyConfirmMessage: 'Overwrite {n} associated monitor(s) with the current configuration of "{name}"? Any local customizations on those monitors will be discarded.', - applySuccess: 'Applied to {n} monitor(s)', - applyPickerTitle: 'Apply template "{name}"', - applyPickerHint: 'Select which monitors to overwrite (all selected by default). Any local customizations will be discarded.', - applyPickerEmpty: 'No monitors are currently associated to this template', - applyPickerConfirm: 'Apply to {n} monitor(s)', - selectNone: 'Select none', - selectedCount: 'Selected {n} / {total}', - deleteConfirm: 'Delete template "{name}"? {n} associated monitor(s) will be disassociated but keep their current snapshot and continue running.', - associatedCount: '{n} associated monitor(s)', - headersSummary: '{n} custom header(s)', - form: { - name: 'Template name', - namePlaceholder: 'e.g. Claude Code mimicry', - description: 'Description', - descriptionPlaceholder: 'Optional: what this template is for, capture date, etc.' - } - } - }, - - // Subscriptions - subscriptions: { - title: 'Subscription Management', - description: 'Manage user subscriptions and quota limits', - assignSubscription: 'Assign Subscription', - adjustSubscription: 'Adjust Subscription', - revokeSubscription: 'Revoke Subscription', - restoreSubscription: 'Restore Subscription', - allStatus: 'All Status', - allGroups: 'All Groups', - allPlatforms: 'All Platforms', - daily: 'Daily', - weekly: 'Weekly', - monthly: 'Monthly', - noLimits: 'No limits configured', - unlimited: 'Unlimited', - resetNow: 'Resetting soon', - windowNotActive: 'Window not active', - resetInMinutes: 'Resets in {minutes}m', - resetInHoursMinutes: 'Resets in {hours}h {minutes}m', - resetInDaysHours: 'Resets in {days}d {hours}h', - quotaEndsInMinutes: 'Quota ends in {minutes}m', - quotaEndsInHoursMinutes: 'Quota ends in {hours}h {minutes}m', - quotaEndsInDaysHours: 'Quota ends in {days}d {hours}h', - daysRemaining: 'days remaining', - remainingDays: 'Remaining days', - noExpiration: 'No expiration', - status: { - active: 'Active', - expired: 'Expired', - revoked: 'Revoked', - suspended: 'Suspended' - }, - columns: { - user: 'User', - group: 'Group', - usage: 'Usage', - expires: 'Expires', - status: 'Status', - actions: 'Actions' - }, - form: { - user: 'User', - group: 'Subscription Group', - validityDays: 'Validity (Days)', - adjustDays: 'Adjust by (Days)' - }, - selectUser: 'Select a user', - selectGroup: 'Select a subscription group', - groupHint: 'Only groups with subscription billing type are shown', - validityHint: 'Number of days the subscription will be valid', - adjustingFor: 'Adjusting subscription for', - currentExpiration: 'Current expiration', - adjustDaysPlaceholder: 'Positive to extend, negative to shorten', - adjustHint: 'Enter positive number to extend, negative to shorten (remaining days must be > 0)', - assign: 'Assign', - assigning: 'Assigning...', - adjust: 'Adjust', - adjusting: 'Adjusting...', - revoke: 'Revoke', - restore: 'Restore', - resetQuota: 'Reset Quota', - resetQuotaTitle: 'Reset Usage Quota', - resetQuotaConfirm: "Reset the daily, weekly, and monthly usage quota for '{user}'? Usage will be zeroed and windows restarted from today.", - quotaResetSuccess: 'Quota reset successfully', - failedToResetQuota: 'Failed to reset quota', - noSubscriptionsYet: 'No subscriptions yet', - assignFirstSubscription: 'Assign a subscription to get started.', - subscriptionAssigned: 'Subscription assigned successfully', - subscriptionAdjusted: 'Subscription adjusted successfully', - subscriptionRevoked: 'Subscription revoked successfully', - subscriptionRestored: 'Subscription restored successfully', - failedToLoad: 'Failed to load subscriptions', - failedToAssign: 'Failed to assign subscription', - failedToAdjust: 'Failed to adjust subscription', - failedToRevoke: 'Failed to revoke subscription', - failedToRestore: 'Failed to restore subscription', - adjustWouldExpire: 'Remaining days after adjustment must be greater than 0', - adjustOutOfRange: 'Adjustment days must be between -36500 and 36500', - pleaseSelectUser: 'Please select a user', - pleaseSelectGroup: 'Please select a group', - validityDaysRequired: 'Please enter a valid number of days (at least 1)', - revokeConfirm: - "Are you sure you want to revoke the subscription for '{user}'? You can restore it later from the revoked list.", - restoreConfirm: - "Restore the subscription for '{user}'? If the original subscription has expired, it will be restored as expired.", - guide: { - title: 'Subscription Management Guide', - subtitle: 'Subscription mode lets you assign time-based usage quotas to users, with daily/weekly/monthly limits. Follow these steps to get started.', - showGuide: 'Usage Guide', - step1: { - title: 'Create a Subscription Group', - line1: 'Go to "Group Management" page, click "Create Group"', - line2: 'Set billing type to "Subscription", configure daily/weekly/monthly quota limits', - line3: 'Save the group and ensure its status is "Active"', - link: 'Go to Group Management' - }, - step2: { - title: 'Assign Subscription to User', - line1: 'Click the "Assign Subscription" button in the top right', - line2: 'Search for a user by email and select them', - line3: 'Choose a subscription group, set validity days, then click "Assign"' - }, - step3: { - title: 'Manage Existing Subscriptions' - }, - actions: { - adjust: 'Adjust', - adjustDesc: 'Extend or shorten the subscription validity period', - resetQuota: 'Reset Quota', - resetQuotaDesc: 'Reset daily/weekly/monthly usage to zero', - revoke: 'Revoke', - revokeDesc: 'Immediately terminate the subscription (restorable from the revoked list)' - }, - tip: 'Tip: Only groups with billing type "Subscription" and status "Active" appear in the group dropdown. If no options are available, create one in Group Management first.' - } - }, - - // Accounts - accounts: { - title: 'Account Management', - description: 'Manage AI platform accounts and credentials', - createAccount: 'Create Account', - autoRefresh: 'Auto Refresh', - enableAutoRefresh: 'Enable auto refresh', - refreshInterval5s: '5 seconds', - refreshInterval10s: '10 seconds', - refreshInterval15s: '15 seconds', - refreshInterval30s: '30 seconds', - autoRefreshCountdown: 'Auto refresh: {seconds}s', - listPendingSyncHint: 'List changes are pending sync. Click sync to load latest rows.', - listPendingSyncAction: 'Sync now', - syncFromCrs: 'Sync from CRS', - dataExport: 'Export', - dataExportSelected: 'Export Selected', - dataExportIncludeProxies: 'Include proxies linked to the exported accounts', - dataImport: 'Import', - moreActions: 'More Actions', - dataActions: 'Data', - toolActions: 'Tools', - viewColumns: 'Columns', - selectedCount: '{count} selected', - dataExportConfirmMessage: 'The exported data contains sensitive account and proxy information. Store it securely.', - dataExportConfirm: 'Confirm Export', - dataExported: 'Data exported successfully', - dataExportedSkippedShadows: 'Data exported. Skipped {count} spark shadow account(s): their scheduling config is not included in the backup; recreate and re-tune them after restore.', - dataExportFailed: 'Failed to export data', - dataImportTitle: 'Import Data', - dataImportHint: 'Upload the exported JSON file to import accounts and proxies.', - dataImportWarning: 'Import will create new accounts/proxies; groups must be bound manually. Ensure existing data does not conflict.', - dataImportFile: 'Data file', - dataImportButton: 'Start Import', - dataImporting: 'Importing...', - dataImportSelectFile: 'Please select a data file', - dataImportParseFailed: 'Failed to parse data file', - dataImportParseFailedFile: 'Failed to parse {name}', - dataImportInvalidFile: '{name} is not a supported data export file', - dataImportIgnoredFiles: 'Ignored {count} non-JSON file(s)', - dataImportFailed: 'Data import failed', - dataImportResult: 'Import Result', - dataImportResultSummary: 'Proxies created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}; Accounts created {account_created}, failed {account_failed}', - dataImportErrors: 'Error Details', - dataImportSuccess: 'Import completed: accounts {account_created}, failed {account_failed}', - dataImportCompletedWithErrors: 'Import completed with errors: account failed {account_failed}, proxy failed {proxy_failed}', - syncFromCrsTitle: 'Sync Accounts from CRS', - syncFromCrsDesc: - 'Sync accounts from claude-relay-service (CRS) into this system (CRS is called server-to-server).', - crsVersionRequirement: '⚠️ Note: CRS version must be ≥ v1.1.240 to support this feature', - crsBaseUrl: 'CRS Base URL', - crsBaseUrlPlaceholder: 'e.g. http://127.0.0.1:3000', - crsUsername: 'Username', - crsPassword: 'Password', - syncProxies: 'Also sync proxies (match by host/port/auth or create)', - syncNow: 'Sync Now', - syncing: 'Syncing...', - syncMissingFields: 'Please fill base URL, username and password', - syncResult: 'Sync Result', - syncResultSummary: 'Created {created}, updated {updated}, skipped {skipped}, failed {failed}', - syncErrors: 'Errors / Skipped Details', - syncCompleted: 'Sync completed: created {created}, updated {updated}, skipped {skipped}', - syncCompletedWithErrors: - 'Sync completed with errors: failed {failed} (created {created}, updated {updated}, skipped {skipped})', - syncFailed: 'Sync failed', - crsPreview: 'Preview', - crsPreviewing: 'Previewing...', - crsPreviewFailed: 'Preview failed', - crsExistingAccounts: 'Existing accounts (will be updated)', - crsNewAccounts: 'New accounts (select to sync)', - crsSelectAll: 'Select all', - crsSelectNone: 'Select none', - crsNoNewAccounts: 'All CRS accounts are already synced.', - crsWillUpdate: 'Will update {count} existing accounts.', - crsSelectedCount: '{count} new accounts selected', - crsUpdateBehaviorNote: - 'Existing accounts only sync fields returned by CRS; missing fields keep their current values. Credentials are merged by key — keys not returned by CRS are preserved. Proxies are kept when "Sync proxies" is unchecked.', - crsBack: 'Back', - editAccount: 'Edit Account', - deleteAccount: 'Delete Account', - searchAccounts: 'Search accounts...', - notes: 'Notes', - notesPlaceholder: 'Enter notes', - notesHint: 'Notes are optional', - allPlatforms: 'All Platforms', - allTypes: 'All Types', - allStatus: 'All Status', - allGroups: 'All Groups', - ungroupedGroup: 'Ungrouped', - oauthType: 'OAuth', - setupToken: 'Setup Token', - apiKey: 'API Key', - // Schedulable toggle - schedulable: 'Schedulable', - schedulableHint: 'Enable to include this account in API request scheduling', - schedulableEnabled: 'Scheduling enabled', - schedulableDisabled: 'Scheduling disabled', - failedToToggleSchedulable: 'Failed to toggle scheduling status', - groupCountTotal: '{count} groups total', - platforms: { - anthropic: 'Anthropic', - claude: 'Claude', - openai: 'OpenAI', - gemini: 'Gemini', - antigravity: 'Antigravity', - grok: 'Grok', - }, - types: { - oauth: 'OAuth', - chatgptOauth: 'ChatGPT OAuth', - responsesApi: 'Responses API', - googleOauth: 'Google OAuth', - codeAssist: 'Code Assist', - antigravityOauth: 'Antigravity OAuth', - grokOauth: 'Grok OAuth', - antigravityApikey: 'Connect via Base URL + API Key', - upstream: 'Upstream', - upstreamDesc: 'Connect via Base URL + API Key' - }, - antigravityProjectIdLabel: 'GCP Project ID (optional)', - antigravityProjectIdPlaceholder: 'your-gcp-project-id', - antigravityProjectIdHint: - 'Antigravity standard-tier accounts that do not receive an automatic project_id need a user-owned GCP project.', - status: { - active: 'Active', - inactive: 'Inactive', - error: 'Error', - cooldown: 'Cooldown', - paused: 'Paused', - limited: 'Limited', - rateLimited: 'Rate Limited', - overloaded: 'Overloaded', - tempUnschedulable: 'Temp Unschedulable', - quotaExceeded: 'Quota Exceeded', - unschedulable: 'Unschedulable', - rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}', - rateLimitedAutoResume: 'Auto resumes in {time}', - modelRateLimitedUntil: '{model} rate limited until {time}', - modelCreditOveragesUntil: '{model} using AI Credits until {time}', - creditsExhausted: 'Credits Exhausted', - creditsExhaustedUntil: 'AI Credits exhausted, expected recovery at {time}', - overloadedUntil: 'Overloaded until {time}', - viewTempUnschedDetails: 'View temp unschedulable details' - }, - columns: { - name: 'Name', - id: 'Account ID', - platformType: 'Platform/Type', - platform: 'Platform', - type: 'Type', - capacity: 'Capacity', - notes: 'Notes', - priority: 'Priority', - billingRateMultiplier: 'Billing Rate', - weight: 'Weight', - schedulerScore: 'Scheduler Score', - status: 'Status', - schedulable: 'Schedulable', - todayStats: 'Today Stats', - groups: 'Groups', - usageWindows: 'Usage Windows', - proxy: 'Proxy', - lastUsed: 'Last Used', - createdAt: 'Created', - expiresAt: 'Expires At', - actions: 'Actions' - }, - schedulerScore: { - baseShort: 'Base', - stickyShort: 'Sticky', - ungrouped: 'Ungrouped', - hint: 'Displayed as "group / base score / sticky bonus". The base score is computed within the current filtered candidate set and includes priority, load, queue depth, error rate, first-token latency, reset window, quota headroom, and related factors. The sticky bonus applies only when sticky weighting is enabled for previous_response_id or session_hash. Higher scores are preferred.' - }, - usageWindowsHint: '"5h / 7d" are the upstream account\'s official rolling usage windows (e.g. OpenAI ChatGPT, Claude). They are imposed by the upstream provider on the account itself — not configured by sub2api, and unrelated to the models you map. Usage resets automatically once each window rolls over, and the limit cannot be lifted from within sub2api.', - allPrivacyModes: 'All Privacy States', - privacyUnset: 'Unset', - privacyTrainingOff: 'Training data sharing disabled', - privacyCfBlocked: 'Blocked by Cloudflare, training may still be on', - privacyFailed: 'Failed to disable training', - privacyAntigravitySet: 'Telemetry and marketing emails disabled', - privacyAntigravityFailed: 'Privacy setting failed', - setPrivacy: 'Set Privacy', - subscriptionAbnormal: 'Abnormal', - subscriptionExpires: 'Expires', - // Capacity status tooltips - capacity: { - windowCost: { - blocked: '5h window cost exceeded, account scheduling paused', - stickyOnly: '5h window cost at threshold, only sticky sessions allowed', - normal: '5h window cost normal' - }, - sessions: { - full: 'Active sessions full, new sessions must wait (idle timeout: {idle} min)', - normal: 'Active sessions normal (idle timeout: {idle} min)' - }, - rpm: { - full: 'RPM limit reached', - warning: 'RPM approaching limit', - normal: 'RPM normal', - tieredNormal: 'RPM limit (Tiered) - Normal', - tieredWarning: 'RPM limit (Tiered) - Approaching limit', - tieredStickyOnly: 'RPM limit (Tiered) - Sticky only | Buffer: {buffer}', - tieredBlocked: 'RPM limit (Tiered) - Blocked | Buffer: {buffer}', - stickyExemptNormal: 'RPM limit (Sticky Exempt) - Normal', - stickyExemptWarning: 'RPM limit (Sticky Exempt) - Approaching limit', - stickyExemptOver: 'RPM limit (Sticky Exempt) - Over limit, sticky only' - }, - quota: { - exceeded: 'Quota exceeded, account paused', - normal: 'Quota normal' - }, - }, - tempUnschedulable: { - title: 'Temp Unschedulable', - statusTitle: 'Temp Unschedulable Status', - hint: 'Disable accounts temporarily when error code and keyword both match.', - notice: 'Rules are evaluated in order and require both error code and keyword match.', - addRule: 'Add Rule', - ruleOrder: 'Rule Order', - ruleIndex: 'Rule #{index}', - errorCode: 'Error Code', - errorCodePlaceholder: 'e.g. 429', - durationMinutes: 'Duration (minutes)', - durationPlaceholder: 'e.g. 30', - keywords: 'Keywords', - keywordsPlaceholder: 'e.g. overloaded, too many requests', - keywordsHint: 'Separate keywords with commas; any keyword match will trigger.', - description: 'Description', - descriptionPlaceholder: 'Optional note for this rule', - rulesInvalid: 'Add at least one rule with error code, keywords, and duration.', - viewDetails: 'View temp unschedulable details', - accountName: 'Account', - triggeredAt: 'Triggered At', - until: 'Until', - remaining: 'Remaining', - matchedKeyword: 'Matched Keyword', - errorMessage: 'Error Details', - reset: 'Recover State', - resetSuccess: 'Account state recovered successfully', - resetFailed: 'Failed to recover account state', - failedToLoad: 'Failed to load temp unschedulable status', - notActive: 'This account is not temporarily unschedulable.', - expired: 'Expired', - remainingMinutes: 'About {minutes} minutes', - remainingHours: 'About {hours} hours', - remainingHoursMinutes: 'About {hours} hours {minutes} minutes', - presets: { - overloadLabel: '529 Overloaded', - overloadDesc: 'Overloaded - pause 60 minutes', - rateLimitLabel: '429 Rate Limit', - rateLimitDesc: 'Rate limited - pause 10 minutes', - unavailableLabel: '503 Unavailable', - unavailableDesc: 'Unavailable - pause 30 minutes' - } - }, - clearRateLimit: 'Clear Rate Limit', - resetQuota: 'Reset Quota', - quotaLimit: 'Quota Limit', - quotaLimitPlaceholder: '0 means unlimited', - quotaLimitHint: 'Set daily/weekly/total spending limits (USD). Anthropic API key accounts can also configure client affinity. Changing limits won\'t reset usage.', - quotaLimitToggle: 'Enable Quota Limit', - quotaLimitToggleHint: 'When enabled, account will be paused when usage reaches the set limit', - quotaDailyLimit: 'Daily Limit', - quotaDailyLimitHint: 'Automatically resets every 24 hours from first usage.', - quotaWeeklyLimit: 'Weekly Limit', - quotaWeeklyLimitHint: 'Automatically resets every 7 days from first usage.', - quotaTotalLimit: 'Total Limit', - quotaTotalLimitHint: 'Cumulative spending limit. Does not auto-reset — use "Reset Quota" to clear.', - quotaResetMode: 'Reset Mode', - quotaResetModeRolling: 'Rolling Window', - quotaResetModeFixed: 'Fixed Time', - quotaResetHour: 'Reset Hour', - quotaWeeklyResetDay: 'Reset Day', - quotaResetTimezone: 'Reset Timezone', - quotaDailyLimitHintFixed: 'Resets daily at {hour}:00 ({timezone}).', - quotaWeeklyLimitHintFixed: 'Resets every {day} at {hour}:00 ({timezone}).', - dayOfWeek: { - monday: 'Monday', - tuesday: 'Tuesday', - wednesday: 'Wednesday', - thursday: 'Thursday', - friday: 'Friday', - saturday: 'Saturday', - sunday: 'Sunday', - }, - quotaLimitAmount: 'Total Limit', - quotaLimitAmountHint: 'Cumulative spending limit. Does not auto-reset.', - quotaNotify: { - alert: 'Alert', - enabled: 'Enable Alert', - threshold: 'Alert Amount', - thresholdPlaceholder: 'Enter percentage', - }, - testConnection: 'Test Connection', - reAuthorize: 'Re-Authorize', - refreshToken: 'Refresh Token', - noAccountsYet: 'No accounts yet', - createFirstAccount: 'Create your first account to start using AI services.', - tokenRefreshed: 'Token refreshed successfully', - accountDeleted: 'Account deleted successfully', - rateLimitCleared: 'Rate limit cleared successfully', - bulkSchedulableEnabled: 'Successfully enabled scheduling for {count} account(s)', - bulkSchedulableDisabled: 'Successfully disabled scheduling for {count} account(s)', - bulkSchedulablePartial: 'Scheduling updated partially: {success} succeeded, {failed} failed', - bulkSchedulableResultUnknown: 'Bulk scheduling result incomplete. Please retry or refresh.', - bulkActions: { - selected: '{count} account(s) selected', - selectCurrentPage: 'Select this page', - clear: 'Clear selection', - edit: 'Bulk Edit', - delete: 'Bulk Delete', - enableScheduling: 'Enable Scheduling', - disableScheduling: 'Disable Scheduling', - resetStatus: 'Reset Status', - refreshToken: 'Refresh Token', - resetStatusSuccess: 'Successfully reset {count} account(s) status', - refreshTokenSuccess: 'Successfully refreshed {count} account(s) token', - partialSuccess: 'Partially completed: {success} succeeded, {failed} failed' - }, - bulkEdit: { - title: 'Bulk Edit Accounts', - selectionInfo: - '{count} account(s) selected. Only checked or filled fields will be updated; others stay unchanged.', - baseUrlPlaceholder: 'https://api.anthropic.com or https://api.openai.com', - baseUrlNotice: 'Applies to API Key accounts only; leave empty to keep existing value', - submit: 'Update Accounts', - updating: 'Updating...', - success: 'Updated {count} account(s)', - partialSuccess: 'Partially updated: {success} succeeded, {failed} failed', - failed: 'Bulk update failed', - noSelection: 'Please select accounts to edit', - noFieldsSelected: 'Select at least one field to update', - mixedPlatformWarning: 'Selected accounts span multiple platforms ({platforms}). Model mapping presets shown are combined — ensure mappings are appropriate for each platform.' - }, - bulkDeleteTitle: 'Bulk Delete Accounts', - bulkDeleteConfirm: 'Delete the selected {count} account(s)? This action cannot be undone.', - bulkDeleteSuccess: 'Deleted {count} account(s)', - bulkDeletePartial: 'Partially deleted: {success} succeeded, {failed} failed', - bulkDeleteFailed: 'Bulk delete failed', - recoverState: 'Recover State', - recoverStateHint: 'Used to recover error, rate-limit, and temporary unschedulable runtime state.', - recoverStateSuccess: 'Account state recovered successfully', - recoverStateFailed: 'Failed to recover account state', - fallbackActive: 'Fallback', - fallbackActiveTip: 'Origin proxy {origin} expired', - revertProxy: 'Revert proxy', - revertProxySuccess: 'Successfully reverted to original proxy', - revertProxyFailed: 'Failed to revert proxy', - createSparkShadow: 'Create Spark Shadow', - createSparkShadowConfirm: 'Create a spark shadow account linked to "{name}"? It shares the parent\'s credentials and serves only spark models.', - createSparkShadowSuccess: 'Spark shadow account created', - createSparkShadowFailed: 'Failed to create spark shadow account', - resetStatus: 'Reset Status', - statusReset: 'Account status reset successfully', - failedToResetStatus: 'Failed to reset account status', - failedToLoad: 'Failed to load accounts', - failedToRefresh: 'Failed to refresh token', - failedToDelete: 'Failed to delete account', - failedToClearRateLimit: 'Failed to clear rate limit', - deleteConfirm: "Are you sure you want to delete '{name}'? This action cannot be undone.", - // Create/Edit Account Modal - platform: 'Platform', - accountName: 'Account Name', - enterAccountName: 'Enter account name', - accountType: 'Account Type', - claudeCode: 'Claude Code', - claudeConsole: 'Claude Console', - bedrockLabel: 'AWS Bedrock', - bedrockDesc: 'SigV4 / API Key', - vertexLabel: 'Vertex', - vertexDesc: 'Service Account', - vertexAnthropicHint: 'Use a Google Cloud Service Account JSON to call Anthropic Claude via Vertex AI. It is recommended to configure model mapping to map client Claude model names to Vertex model IDs.', - vertexGeminiHint: 'Use a Google Cloud Service Account JSON to access Vertex AI Gemini. It is recommended to place Vertex accounts in a separate group to avoid mixing with AI Studio/Gemini OAuth on the same models.', - vertexSaJsonLabel: 'Service Account JSON', - vertexSaJsonLoaded: 'Service Account JSON loaded', - vertexSaJsonDrop: 'Drop Service Account JSON here', - vertexSaJsonKeyHidden: 'Key content is not displayed in the form.', - vertexSaJsonDropHint: 'Drag a .json file here, or click the button to select one.', - vertexSaJsonSelectBtn: 'Select JSON', - vertexSaJsonUploadHint: 'After uploading or dropping a JSON file, the project_id will be auto-extracted. Key content is only used for account creation.', - vertexSaJsonEditHint: 'Service Account JSON is not shown on the edit page; to change the JSON, delete the account and recreate it.', - vertexProjectIdPlaceholder: 'Auto-extracted from JSON', - vertexLocationHint: 'Available locations vary by Vertex model. Select the default endpoint location for this account.', - vertexLocationRequired: 'Please enter a Vertex location', - vertexSaJsonMissingFields: 'Service Account JSON is missing project_id, client_email, or private_key', - vertexSaJsonMissingProjectId: 'Service Account JSON is missing project_id', - vertexSaJsonMissingClientEmail: 'Service Account JSON is missing client_email', - vertexSaJsonInvalid: 'Service Account JSON format is invalid', - vertexSaJsonRequired: 'Please upload a Service Account JSON', - oauthSetupToken: 'OAuth / Setup Token', - addMethod: 'Add Method', - setupTokenLongLived: 'Setup Token (Long-lived)', - baseUrl: 'Base URL', - baseUrlHint: 'Leave default for official Anthropic API', - apiKeyRequired: 'API Key *', - apiKeyPlaceholder: 'sk-ant-api03-...', - apiKeyHint: 'Your Claude Console API Key', - // OpenAI specific hints - openai: { - baseUrlHint: 'Leave default for official OpenAI API', - apiKeyHint: 'Your OpenAI API Key', - oauthPassthrough: 'Auto passthrough (auth only)', - oauthPassthroughDesc: - 'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.', - responsesWebsocketsV2: 'Responses WebSocket v2', - responsesWebsocketsV2Desc: - 'Disabled by default. Enable to allow responses_websockets_v2 capability (still gated by global and account-type switches).', - wsMode: 'WS mode', - wsModeDesc: 'Only applies to the current OpenAI account type.', - wsModeOff: 'Off (off)', - wsModeCtxPool: 'Context Pool (ctx_pool)', - wsModePassthrough: 'Passthrough (passthrough)', - wsModeHttpBridge: 'HTTP Bridge (http_bridge)', - wsModeShared: 'Shared (shared)', - wsModeDedicated: 'Dedicated (dedicated)', - wsModeConcurrencyHint: - 'When WS mode is enabled, account concurrency becomes the WS connection pool limit for this account.', - wsModePassthroughHint: 'Passthrough mode does not use the WS connection pool.', - oauthResponsesWebsocketsV2: 'OAuth WebSocket Mode', - oauthResponsesWebsocketsV2Desc: - 'Only applies to OpenAI OAuth. This account can use OpenAI WebSocket Mode only when enabled.', - apiKeyResponsesWebsocketsV2: 'API Key WebSocket Mode', - apiKeyResponsesWebsocketsV2Desc: - 'Only applies to OpenAI API Key. This account can use OpenAI WebSocket Mode only when enabled.', - responsesWebsocketsV2PassthroughHint: - 'Automatic passthrough is currently enabled: it only affects HTTP passthrough and does not disable WS mode.', - responsesMode: 'Responses API support', - responsesModeDesc: - 'Only applies to the OpenAI API Key text forwarding path. Auto follows probe results; force modes override probing.', - responsesModeAuto: 'Auto', - responsesModeForceResponses: 'Force Responses', - responsesModeForceChatCompletions: 'Force Chat Completions', - responsesModeTextDisabledHint: - 'Not applicable when the Responses / Chat Completions endpoint is not enabled.', - endpointCapabilities: 'Endpoint capabilities', - endpointCapabilitiesDesc: - 'Used by account routing. The text endpoint follows the Responses API support setting above and is shown as Responses, Chat Completions, or auto mode; Embeddings independently controls /v1/embeddings.', - capabilityResponses: 'Responses', - capabilityTextAuto: 'Responses / Chat Completions (Auto)', - capabilityResponsesAuto: 'Responses (auto probe)', - capabilityChatCompletions: 'Chat Completions', - capabilityChatCompletionsAuto: 'Chat Completions (auto probe)', - capabilityEmbeddings: 'Embeddings', - responsesStatusAutoSupported: 'Auto probe: Responses', - responsesStatusAutoUnsupported: 'Auto probe: Chat Completions', - responsesStatusAutoUnknown: 'Auto probe: unknown', - responsesStatusForcedResponses: 'Forced Responses', - responsesStatusForcedChatCompletions: 'Forced Chat Completions', - codexCLIOnly: 'Codex official clients only', - codexCLIOnlyDesc: - 'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.', - codexCLIOnlyAppServer: 'Allow Codex app-server clients', - codexCLIOnlyAppServerDesc: - "Effective only when the switch above is on. When enabled, this account also allows third-party clients that embed the Codex engine over the app-server protocol (e.g. Claude Code's codex plugin); they still pass the global engine-fingerprint gate. OR-combined with the global app-server toggle.", - codexImageTool: 'Codex image tool', - codexImageToolDesc: - 'One policy for the image_generation tool on Codex /responses text requests: whether it is auto-injected, and whether client-provided tools pass through. Account policy takes precedence over channel and global settings; standalone image-generation endpoints are unaffected.', - codexImageToolInherit: 'Follow channel', - codexImageToolInheritDesc: 'No account override; injection follows the channel or global policy, and client-provided image tools pass through.', - codexImageToolEnabled: 'Force inject', - codexImageToolEnabledDesc: 'Always inject the image tool for Codex /responses requests.', - codexImageToolDisabled: 'No injection', - codexImageToolDisabledDesc: 'Never auto-inject; client-provided image tools still pass through.', - codexImageToolBlock: 'Block all', - codexImageToolBlockDesc: 'No injection, and client-provided image tools plus matching tool_choice are removed.', - codexImageToolBadgeInherit: 'Channel policy', - codexImageToolBadgeEnabled: 'Force inject', - codexImageToolBadgeDisabled: 'No injection', - codexImageToolBadgeBlock: 'Blocked', - compactMode: 'Compact mode', - compactModeDesc: - 'Controls how this account participates in /responses/compact routing. Auto follows probe results, Force On always allows, Force Off always excludes.', - compactModeAuto: 'Auto', - compactModeForceOn: 'Force On', - compactModeForceOff: 'Force Off', - compactModelMapping: 'Compact-only model mapping', - compactModelMappingDesc: - 'Only applies to /responses/compact. Use this when the upstream compact endpoint requires a special compact model.', - compactSupported: 'Compact supported', - compactUnsupported: 'Compact unsupported', - compactAuto: 'Compact Auto', - compactUnknown: 'Compact Auto', - compactLastChecked: 'Last compact probe', - testMode: 'Test mode', - testModeDefault: 'Default request', - testModeCompact: 'Compact probe', - modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.', - }, - grok: { - baseUrlHint: 'Grok OAuth accounts forward to the official xAI API base URL.', - apiKeyHint: 'Grok subscription support uses OAuth refresh tokens; API keys are out of scope for this account type.' - }, - anthropic: { - apiKeyPassthrough: 'Auto passthrough (auth only)', - apiKeyPassthroughDesc: - 'Only applies to Anthropic API Key accounts. When enabled, messages/count_tokens are forwarded in passthrough mode with auth replacement only, while billing/concurrency/audit and safety filtering are preserved. Disable to roll back immediately.', - apiKeyAuthScheme: 'Upstream auth scheme', - apiKeyAuthSchemeDesc: 'Choose the API key auth header used when forwarding to an Anthropic-compatible upstream. Ollama Cloud uses Authorization: Bearer.', - apiKeyAuthSchemeXApiKey: 'x-api-key', - apiKeyAuthSchemeBearer: 'Authorization: Bearer', - webSearchEmulation: 'Web Search Emulation', - webSearchEmulationDesc: - 'Enable web search emulation for this API Key account. When a pure web_search request is detected, the gateway calls a third-party search API and constructs the response locally. Default follows channel config.', - webSearchDefault: 'Default', - webSearchEnabled: 'Enabled', - webSearchDisabled: 'Disabled', - }, - modelRestriction: 'Model Restriction (Optional)', - modelWhitelist: 'Model Whitelist', - modelMapping: 'Model Mapping', - selectAllowedModels: 'Select allowed models. Leave empty to support all models.', - mapRequestModels: - 'Map request models to actual models. Left is the requested model, right is the actual model sent to API.', - selectedModels: 'Selected {count} model(s)', - supportsAllModels: '(supports all models)', - requestModel: 'Request model', - actualModel: 'Actual model', - addMapping: 'Add Mapping', - mappingExists: 'Mapping for {model} already exists', - wildcardOnlyAtEnd: 'Wildcard * can only be at the end', - targetNoWildcard: 'Target model cannot contain wildcard *', - searchModels: 'Search models...', - noMatchingModels: 'No matching models', - fillRelatedModels: 'Sync latest supported models', - syncUpstreamModels: 'Sync upstream supported models', - syncUpstreamModelsLoading: 'Syncing upstream...', - syncUpstreamModelsSuccess: 'Synced {count} new model(s) from upstream ({total} upstream total)', - syncUpstreamModelsNoChanges: 'All {count} upstream model(s) are already in the whitelist', - syncUpstreamModelsEmpty: 'Upstream returned no models to sync', - syncUpstreamModelsFailed: 'Failed to sync upstream models', - syncUpstreamModelsError: 'Failed to sync upstream models: {message}', - clearAllModels: 'Clear all models', - customModelName: 'Custom model name', - enterCustomModelName: 'Enter custom model name', - addModel: 'Add', - modelExists: 'Model already exists', - modelCount: '{count} models', - poolMode: 'Pool Mode', - poolModeHint: 'Enable when upstream is an account pool; errors won\'t mark local account status', - poolModeInfo: - 'When enabled, upstream 429/403/401 errors will auto-retry without marking the account as rate-limited or errored. Suitable for upstream pointing to another sub2api instance.', - poolModeRetryCount: 'Same-Account Retries', - poolModeRetryCountHint: - 'Only applies in pool mode. Use 0 to disable in-place retry. Default {default}, maximum {max}.', - poolModeRetryStatusCodes: 'Retry Status Codes', - poolModeRetryStatusCodesHint: - 'Comma-separated HTTP status codes (100-599) that trigger same-account retry in pool mode. Leave blank to use defaults ({default}).', - customErrorCodes: 'Custom Error Codes', - customErrorCodesHint: 'Only stop scheduling for selected error codes', - customErrorCodesWarning: - 'Only selected error codes will stop scheduling. Other errors will return 500.', - customErrorCodes429Warning: - '429 already has built-in rate limit handling. Adding it to custom error codes will disable the account instead of temporary rate limiting. Are you sure?', - customErrorCodes529Warning: - '529 already has built-in overload handling. Adding it to custom error codes will disable the account instead of temporary overload marking. Are you sure?', - selectedErrorCodes: 'Selected', - noneSelectedUsesDefault: 'None selected (uses default policy)', - enterErrorCode: 'Enter error code (100-599)', - invalidErrorCode: 'Please enter a valid HTTP error code (100-599)', - errorCodeExists: 'This error code is already selected', - interceptWarmupRequests: 'Intercept Warmup Requests', - interceptWarmupRequestsDesc: - 'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens', - headerOverride: { - title: 'Header Override', - hint: 'Override same-named request headers on forwarding (case-insensitive)', - info: 'Applies to outbound requests of this account only: configured headers override client/gateway-generated headers of the same name before forwarding. Auth headers (authorization, x-api-key) and connection-control headers cannot be overridden.', - namePlaceholder: 'Header name (e.g. user-agent)', - valuePlaceholder: 'Override value (leave empty to skip)', - addRow: 'Add Header', - fillTemplate: 'Fill Template', - emptyValueHint: 'Rows with an empty value are placeholders and do not override anything.', - bulkDisableHint: 'Saving will disable header override and clear existing configuration on the selected accounts.', - bulkReplaceHint: 'Saving will replace the existing header override configuration on all selected accounts with the rows below.', - bulkEmptyRows: 'Add at least one header row before saving, or turn the toggle off to clear existing configuration.', - invalidName: 'Invalid header name (only letters, digits and !#$%&\'*+-.^_`|~ are allowed)', - blockedName: 'This header cannot be overridden (auth and connection-control headers are managed by the system)', - duplicateName: 'Duplicate header name (matching is case-insensitive)', - invalidValue: 'Invalid header value (control characters are not allowed; max length 8192)', - tooManyEntries: 'Too many header override entries (max 64)' - }, - autoPauseOnExpired: 'Auto Pause On Expired', - autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires', - autoPause5hThreshold: '5h Usage Threshold (%)', - autoPause7dThreshold: '7d Usage Threshold (%)', - autoPauseThresholdHint: 'Leave empty or set 0 to use the global default threshold (configured in Ops settings); set a value to override the global default. Reaching the threshold only skips the account during scheduling and does not modify schedulable.', - autoPause5hDisabled: 'Disable 5h auto-pause', - autoPause7dDisabled: 'Disable 7d auto-pause', - autoPauseDisabledHint: 'When enabled, this account is never auto-paused (even if a global default threshold is configured).', - // Quota control (Anthropic OAuth/SetupToken only) - quotaControl: { - title: 'Quota Control', - hint: 'Configure cost window, session limits, client affinity and other scheduling controls.', - windowCost: { - label: '5h Window Cost Limit', - hint: 'Limit account cost usage within the 5-hour window', - limit: 'Cost Threshold', - limitPlaceholder: '50', - limitHint: 'Account will not participate in new scheduling after reaching threshold', - stickyReserve: 'Sticky Reserve', - stickyReservePlaceholder: '10', - stickyReserveHint: 'Additional reserve for sticky sessions' - }, - sessionLimit: { - label: 'Session Count Limit', - hint: 'Limit the number of active concurrent sessions', - maxSessions: 'Max Sessions', - maxSessionsPlaceholder: '3', - maxSessionsHint: 'Maximum number of active concurrent sessions', - idleTimeout: 'Idle Timeout', - idleTimeoutPlaceholder: '5', - idleTimeoutHint: 'Sessions will be released after idle timeout' - }, - rpmLimit: { - label: 'RPM Limit', - hint: 'Limit requests per minute to protect upstream accounts', - baseRpm: 'Base RPM', - baseRpmPlaceholder: '15', - baseRpmHint: 'Max requests per minute, 0 or empty means no limit', - strategy: 'RPM Strategy', - strategyTiered: 'Tiered Model', - strategyStickyExempt: 'Sticky Exempt', - strategyTieredHint: 'Green → Yellow → Sticky only → Blocked, progressive throttling', - strategyStickyExemptHint: 'Only sticky sessions allowed when over limit', - strategyHint: 'Tiered: gradually restrict when exceeded; Sticky Exempt: existing sessions unrestricted', - stickyBuffer: 'Sticky Buffer', - stickyBufferPlaceholder: 'Default: 20% of base RPM', - stickyBufferHint: 'Extra requests allowed for sticky sessions after exceeding base RPM. Leave empty to use default (20% of base RPM, min 1)', - userMsgQueue: 'User Message Rate Control', - userMsgQueueHint: 'Rate-limit user messages to avoid triggering upstream RPM limits', - umqModeOff: 'Off', - umqModeThrottle: 'Throttle', - umqModeSerialize: 'Serialize', - }, - tlsFingerprint: { - label: 'TLS Fingerprint Simulation', - hint: 'Simulate Node.js/Claude Code client TLS fingerprint', - defaultProfile: 'Built-in Default', - randomProfile: 'Random' - }, - sessionIdMasking: { - label: 'Session ID Masking', - hint: 'When enabled, fixes the session ID in metadata.user_id for 15 minutes, making upstream think requests come from the same session' - }, - cacheTTLOverride: { - label: 'Cache TTL Override', - hint: 'Force all cache creation tokens to be billed as the selected TTL tier (5m or 1h)', - target: 'Target TTL', - targetHint: 'Select the TTL tier for billing' - }, - customBaseUrl: { - label: 'Custom Relay URL', - hint: 'Forward requests to a custom relay service. Proxy URL will be passed as a query parameter.', - urlHint: 'Relay service URL (e.g., https://relay.example.com)', - }, - clientAffinity: { - label: 'Client Affinity Scheduling', - hint: 'When enabled, new sessions prefer accounts previously used by this client to reduce account switching' - } - }, - affinityNoClients: 'No affinity clients', - affinityClients: '{count} affinity clients:', - affinitySection: 'Client Affinity', - affinitySectionHint: 'Control how clients are distributed across accounts. Configure zone thresholds to balance load.', - affinityToggle: 'Enable Client Affinity', - affinityToggleHint: 'New sessions prefer accounts previously used by this client', - affinityBase: 'Base Limit (Green Zone)', - affinityBasePlaceholder: 'Empty = no limit', - affinityBaseHint: 'Max clients in green zone (full priority scheduling)', - affinityBaseOffHint: 'No green zone limit. All clients receive full priority scheduling.', - affinityBuffer: 'Buffer (Yellow Zone)', - affinityBufferPlaceholder: 'e.g. 3', - affinityBufferHint: 'Additional clients allowed in the yellow zone (degraded priority)', - affinityBufferInfinite: 'Unlimited', - expired: 'Expired', - proxy: 'Proxy', - noProxy: 'No Proxy', - concurrency: 'Concurrency', - loadFactor: 'Load Factor', - loadFactorHint: 'Higher load factor increases scheduling frequency', - priority: 'Priority', - priorityHint: 'Lower value accounts are used first', - billingRateMultiplier: 'Billing Rate Multiplier', - billingRateMultiplierHint: '0 = free, affects account billing only', - expiresAt: 'Expires At', - expiresAtHint: 'Leave empty for no expiration', - higherPriorityFirst: 'Lower value means higher priority', - mixedScheduling: 'Use in /v1/messages', - mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling', - mixedSchedulingTooltip: - '!! WARNING !! Antigravity Claude and Anthropic Claude cannot be used in the same context. If you have both Anthropic and Antigravity accounts, enabling this option will cause frequent 400 errors. When enabled, please use the group feature to isolate Antigravity accounts from Anthropic accounts. Make sure you understand this before enabling!!', - aiCreditsBalance: 'AI Credits', - allowOverages: 'Allow Overages (AI Credits)', - allowOveragesTooltip: - 'Only use AI Credits after free quota is explicitly exhausted. Ordinary concurrent 429 rate limits will not switch to overages.', - creating: 'Creating...', - updating: 'Updating...', - accountCreated: 'Account created successfully', - accountUpdated: 'Account updated successfully', - failedToCreate: 'Failed to create account', - failedToUpdate: 'Failed to update account', - pleaseSelectStatus: 'Please select a valid account status', - mixedChannelWarningTitle: 'Mixed Channel Warning', - mixedChannelWarning: 'Warning: Group "{groupName}" contains both {currentPlatform} and {otherPlatform} accounts. Mixing different channels may cause thinking block signature validation issues, which will fallback to non-thinking mode. Are you sure you want to continue?', - pleaseEnterAccountName: 'Please enter account name', - pleaseEnterApiKey: 'Please enter API Key', - bedrockAccessKeyId: 'AWS Access Key ID', - bedrockSecretAccessKey: 'AWS Secret Access Key', - bedrockSessionToken: 'AWS Session Token', - bedrockRegion: 'AWS Region', - bedrockRegionHint: 'e.g. us-east-1, us-west-2, eu-west-1', - bedrockForceGlobal: 'Force Global cross-region inference', - bedrockForceGlobalHint: 'When enabled, model IDs use the global. prefix (e.g. global.anthropic.claude-...), routing requests to any supported region worldwide for higher availability', - bedrockAccessKeyIdRequired: 'Please enter AWS Access Key ID', - bedrockSecretAccessKeyRequired: 'Please enter AWS Secret Access Key', - bedrockRegionRequired: 'Please select AWS Region', - bedrockSessionTokenHint: 'Optional, for temporary credentials', - bedrockSecretKeyLeaveEmpty: 'Leave empty to keep current key', - bedrockAuthMode: 'Authentication Mode', - bedrockAuthModeSigv4: 'SigV4 Signing', - bedrockAuthModeApikey: 'Bedrock API Key', - bedrockApiKeyLabel: 'Bedrock API Key', - bedrockApiKeyDesc: 'Bearer Token', - bedrockApiKeyInput: 'API Key', - bedrockApiKeyRequired: 'Please enter Bedrock API Key', - bedrockApiKeyLeaveEmpty: 'Leave empty to keep current key', - apiKeyIsRequired: 'API Key is required', - leaveEmptyToKeep: 'Leave empty to keep current key', - // Upstream type - upstream: { - baseUrl: 'Upstream Base URL', - baseUrlHint: 'The address of the upstream Antigravity service, e.g., https://cloudcode-pa.googleapis.com', - apiKey: 'Upstream API Key', - apiKeyHint: 'API Key for the upstream service', - pleaseEnterBaseUrl: 'Please enter upstream Base URL', - pleaseEnterApiKey: 'Please enter upstream API Key' - }, - // OAuth flow - oauth: { - title: 'Claude Account Authorization', - authMethod: 'Authorization Method', - manualAuth: 'Manual Authorization', - cookieAutoAuth: 'Cookie Auto-Auth', - cookieAutoAuthDesc: - 'Use claude.ai sessionKey to automatically complete OAuth authorization without manually opening browser.', - sessionKey: 'sessionKey', - keysCount: '{count} keys', - batchCreateAccounts: 'Will batch create {count} accounts', - sessionKeyPlaceholder: - 'One sessionKey per line, e.g.:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...', - sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...', - howToGetSessionKey: 'How to get sessionKey', - step1: 'Login to claude.ai in your browser', - step2: 'Press F12 to open Developer Tools', - step3: 'Go to Application tab', - step4: 'Find Cookies → https://claude.ai', - step5: 'Find the row with key sessionKey', - step6: 'Copy the Value', - sessionKeyFormat: 'sessionKey usually starts with sk-ant-sid01-', - startAutoAuth: 'Start Auto-Auth', - authorizing: 'Authorizing...', - followSteps: 'Follow these steps to authorize your Claude account:', - step1GenerateUrl: 'Click the button below to generate the authorization URL', - generateAuthUrl: 'Generate Auth URL', - generating: 'Generating...', - regenerate: 'Regenerate', - step2OpenUrl: 'Open the URL in your browser and complete authorization', - openUrlDesc: - 'Open the authorization URL in a new tab, log in to your Claude account and authorize.', - proxyWarning: - 'Note: If you configured a proxy, make sure your browser uses the same proxy to access the authorization page.', - step3EnterCode: 'Enter the Authorization Code', - authCodeDesc: - 'After authorization is complete, the page will display an Authorization Code. Copy and paste it below:', - authCode: 'Authorization Code', - authCodePlaceholder: 'Paste the Authorization Code from Claude page...', - authCodeHint: 'Paste the Authorization Code copied from the Claude page', - completeAuth: 'Complete Authorization', - verifying: 'Verifying...', - pleaseEnterSessionKey: 'Please enter at least one valid sessionKey', - authFailed: 'Authorization failed', - cookieAuthFailed: 'Cookie authorization failed', - keyAuthFailed: 'Key {index}: {error}', - successCreated: 'Successfully created {count} account(s)', - batchSuccess: 'Successfully created {count} account(s)', - batchPartialSuccess: 'Partial success: {success} succeeded, {failed} failed', - batchFailed: 'Batch creation failed', - // OpenAI specific - openai: { - title: 'OpenAI Account Authorization', - followSteps: 'Follow these steps to complete OpenAI account authorization:', - step1GenerateUrl: 'Click the button below to generate the authorization URL', - generateAuthUrl: 'Generate Auth URL', - step2OpenUrl: 'Open the URL in your browser and complete authorization', - openUrlDesc: - 'Open the authorization URL in a new tab, log in to your OpenAI account and authorize.', - importantNotice: - 'Important: The page may take a while to load after authorization. Please wait patiently. When the browser address bar changes to http://localhost..., the authorization is complete.', - step3EnterCode: 'Enter Authorization URL or Code', - authCodeDesc: - 'After authorization is complete, when the page URL becomes http://localhost:xxx/auth/callback?code=...:', - authCode: 'Authorization URL or Code', - authCodePlaceholder: - 'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value', - authCodeHint: - 'You can copy the entire URL or just the code parameter value, the system will auto-detect', - failedToGenerateUrl: 'Failed to generate OpenAI auth URL', - failedToExchangeCode: 'Failed to exchange OpenAI auth code', - failedToValidateRT: 'Failed to validate refresh token', - errors: { - OPENAI_OAUTH_PROXY_REQUIRED: - 'No proxy is configured and this server could not reach OpenAI directly, so the OpenAI OAuth request failed. Select a proxy that can access OpenAI and retry; if the authorization code has expired, regenerate the authorization URL.' - }, - // Refresh Token auth - refreshTokenAuth: 'Manual RT Input', - refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', - refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line', - codexSessionAuth: 'Codex JSON / AT Batch Input', - codexSessionDesc: 'Paste Codex JSON or an accessToken. Accounts use the step 1 settings.', - codexSessionInputLabel: 'Codex JSON or accessToken', - codexSessionPlaceholder: 'Multiple lines supported, one token or JSON per line', - codexSessionHint: 'sessionToken will not be saved as refresh_token. Without refresh_token, the account expires with the accessToken expiry; import is rejected if the expiry cannot be parsed and step 1 has no expiration.', - codexSessionImportAndCreate: 'Import & Create Account', - codexSessionEmpty: 'Please enter Codex JSON or accessToken', - codexSessionImportFailed: 'Failed to import Codex account', - codexSessionImportSuccess: 'Import completed: created {created}, updated {updated}, skipped {skipped}', - codexSessionImportPartial: 'Partial success: created {created}, updated {updated}, skipped {skipped}, failed {failed}', - codexPatAuth: 'Codex Personal Access Token', - codexPatDesc: 'Enter a Codex at- personal access token. The system validates it with OpenAI whoami before creating the account.', - codexPatInputLabel: 'Codex PAT', - codexPatPlaceholder: 'at-...', - codexPatHint: 'This is a separate auth mode. It does not save refresh_token or write an OAuth access_token expiration.', - codexPatImportAndCreate: 'Validate & Create Codex PAT Account', - codexPatEmpty: 'Please enter a Codex personal access token', - codexPatImportFailed: 'Failed to create Codex PAT account', - sessionTokenAuth: 'Manual ST Input', - sessionTokenDesc: 'Enter your existing Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', - sessionTokenPlaceholder: 'Paste your Session Token...\nSupports multiple, one per line', - sessionTokenRawLabel: 'Raw Input', - sessionTokenRawPlaceholder: 'Paste /api/auth/session raw payload or Session Token...', - sessionTokenRawHint: 'You can paste full JSON. The system will auto-parse ST and AT.', - openSessionUrl: 'Open Fetch URL', - copySessionUrl: 'Copy URL', - sessionUrlHint: 'This URL usually returns AT. If sessionToken is absent, copy __Secure-next-auth.session-token from browser cookies as ST.', - parsedSessionTokensLabel: 'Parsed ST', - parsedSessionTokensEmpty: 'No ST parsed. Please check your input.', - parsedAccessTokensLabel: 'Parsed AT', - validating: 'Validating...', - validateAndCreate: 'Validate & Create Account', - pleaseEnterRefreshToken: 'Please enter Refresh Token', - pleaseEnterSessionToken: 'Please enter Session Token' - }, - grok: { - title: 'Grok Account Authorization', - followSteps: 'Follow these steps to authorize your xAI/Grok account:', - step1GenerateUrl: 'Generate the xAI authorization URL', - generateAuthUrl: 'Generate Auth URL', - step2OpenUrl: 'Open the URL in your browser and complete authorization', - openUrlDesc: 'Open the authorization URL in a new tab, sign in to xAI, and authorize API access.', - importantNotice: 'When the browser reaches the local callback URL, copy the full URL or the code query parameter back here.', - step3EnterCode: 'Enter Authorization URL or Code', - authCodeDesc: 'After authorization, paste the callback URL, query string, or authorization code:', - authCode: 'Authorization URL or Code', - authCodePlaceholder: 'Paste the full callback URL, ?code=... query string, or code value', - authCodeHint: 'Full callback URLs, query strings, and bare codes are accepted.', - refreshTokenAuth: 'Manual RT Input', - refreshTokenDesc: 'Enter existing xAI refresh token(s). Supports batch input, one per line.', - refreshTokenPlaceholder: 'Paste your xAI refresh token...\nSupports multiple, one per line', - validating: 'Validating...', - validateAndCreate: 'Validate & Create Account', - pleaseEnterRefreshToken: 'Please enter Refresh Token', - failedToGenerateUrl: 'Failed to generate Grok auth URL', - missingExchangeParams: 'Missing authorization code, state, or OAuth session', - failedToExchangeCode: 'Failed to exchange Grok authorization code', - failedToValidateRT: 'Failed to validate Grok refresh token', - oauthOnlyHint: 'Initial Grok support is OAuth subscription-backed Responses API text and reasoning traffic only.' - }, - // Gemini specific - gemini: { - title: 'Gemini Account Authorization', - followSteps: 'Follow these steps to authorize your Gemini account:', - step1GenerateUrl: 'Generate the authorization URL', - generateAuthUrl: 'Generate Auth URL', - projectIdLabel: 'Project ID (optional)', - projectIdPlaceholder: 'e.g. my-gcp-project or cloud-ai-companion-xxxxx', - projectIdHint: - 'Leave empty to auto-detect after code exchange. If auto-detection fails, fill it in and re-generate the auth URL to try again.', - howToGetProjectId: 'How to get', - step2OpenUrl: 'Open the URL in your browser and complete authorization', - openUrlDesc: - 'Open the authorization URL in a new tab, log in to your Google account and authorize.', - step3EnterCode: 'Enter Authorization URL or Code', - authCodeDesc: - 'After authorization, copy the callback URL (recommended) or just the code and paste it below.', - authCode: 'Callback URL or Code', - authCodePlaceholder: - 'Option 1 (recommended): Paste the callback URL\nOption 2: Paste only the code value', - authCodeHint: 'The system will auto-extract code/state from the URL.', - redirectUri: 'Redirect URI', - redirectUriHint: - 'This must be configured in your Google OAuth client and must match exactly.', - confirmRedirectUri: - 'I have configured this Redirect URI in the Google OAuth client (must match exactly)', - invalidRedirectUri: 'Redirect URI must be a valid http(s) URL', - redirectUriNotConfirmed: 'Please confirm the Redirect URI is configured correctly', - missingRedirectUri: 'Missing redirect URI', - failedToGenerateUrl: 'Failed to generate Gemini auth URL', - missingExchangeParams: 'Missing auth code, session ID, or state', - failedToExchangeCode: 'Failed to exchange Gemini auth code', - missingProjectId: 'GCP Project ID retrieval failed: Your Google account is not linked to an active GCP project. Please activate GCP and bind a credit card in Google Cloud Console, or manually enter the Project ID during authorization.', - modelPassthrough: 'Gemini Model Passthrough', - modelPassthroughDesc: - 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', - stateWarningTitle: 'Note', - stateWarningDesc: 'Recommended: paste the full callback URL (includes code & state).', - oauthTypeLabel: 'OAuth Type', - needsProjectId: 'Built-in OAuth (Code Assist)', - needsProjectIdDesc: 'Requires GCP project and Project ID', - noProjectIdNeeded: 'Custom OAuth (AI Studio)', - noProjectIdNeededDesc: 'Requires admin-configured OAuth client', - aiStudioNotConfiguredShort: 'Not configured', - aiStudioNotConfiguredTip: - 'AI Studio OAuth is not configured: set GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and add Redirect URI: http://localhost:1455/auth/callback (Consent screen scopes must include https://www.googleapis.com/auth/generative-language.retriever)', - aiStudioNotConfigured: - 'AI Studio OAuth is not configured: set GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and add Redirect URI: http://localhost:1455/auth/callback' - }, - // Antigravity specific - antigravity: { - title: 'Antigravity Account Authorization', - followSteps: 'Follow these steps to authorize your Antigravity account:', - step1GenerateUrl: 'Generate the authorization URL', - generateAuthUrl: 'Generate Auth URL', - step2OpenUrl: 'Open the URL in your browser and complete authorization', - openUrlDesc: 'Open the authorization URL in a new tab, log in to your Google account and authorize.', - importantNotice: - 'Important: The page may take a while to load after authorization. Please wait patiently. When the browser address bar shows http://localhost..., authorization is complete.', - step3EnterCode: 'Enter Authorization URL or Code', - authCodeDesc: - 'After authorization, when the page URL becomes http://localhost:xxx/auth/callback?code=...:', - authCode: 'Authorization URL or Code', - authCodePlaceholder: - 'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value', - authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect', - failedToGenerateUrl: 'Failed to generate Antigravity auth URL', - missingExchangeParams: 'Missing code, session ID, or state', - failedToExchangeCode: 'Failed to exchange Antigravity auth code', - // Refresh Token auth - refreshTokenAuth: 'Manual RT', - refreshTokenDesc: 'Enter your existing Antigravity Refresh Token. Supports batch input (one per line). The system will automatically validate and create accounts.', - refreshTokenPlaceholder: 'Paste your Antigravity Refresh Token...\nSupports multiple tokens, one per line', - validating: 'Validating...', - validateAndCreate: 'Validate & Create', - pleaseEnterRefreshToken: 'Please enter Refresh Token', - failedToValidateRT: 'Failed to validate Refresh Token' - } - }, // Gemini specific (platform-wide) - gemini: { - helpButton: 'Help', - helpDialog: { - title: 'Gemini Usage Guide', - apiKeySection: 'API Key Links' - }, - modelPassthrough: 'Gemini Model Passthrough', - modelPassthroughDesc: - 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', - baseUrlHint: 'Leave default for official Gemini API', - apiKeyHint: 'Your Gemini API Key (starts with AIza)', - tier: { - label: 'Account Tier', - hint: 'Tip: The system will try to auto-detect the tier first; if auto-detection is unavailable or fails, your selected tier is used as a fallback (simulated quota).', - aiStudioHint: - 'AI Studio quotas are per-model (Pro/Flash are limited independently). If billing is enabled, choose Pay-as-you-go.', - googleOne: { - free: 'Google One Free', - pro: 'Google One Pro', - ultra: 'Google One Ultra' - }, - gcp: { - standard: 'GCP Standard', - enterprise: 'GCP Enterprise' - }, - aiStudio: { - free: 'Google AI Free', - paid: 'Google AI Pay-as-you-go' - } - }, - accountType: { - oauthTitle: 'OAuth (Gemini)', - oauthDesc: 'Authorize with your Google account and choose an OAuth type.', - apiKeyTitle: 'API Key (AI Studio)', - apiKeyDesc: 'Fastest setup. Use an AIza API key.', - apiKeyNote: - 'Best for light testing. Free tier has strict rate limits and data may be used for training.', - apiKeyLink: 'Get API Key', - quotaLink: 'Quota guide' - }, - oauthType: { - builtInTitle: 'Built-in OAuth (Gemini CLI / Code Assist)', - builtInDesc: 'Uses Google built-in client ID. No admin configuration required.', - builtInRequirement: 'Requires a GCP project and Project ID.', - googleOneDesc: 'Personal account with Google One subscription quota', - codeAssistDesc: 'Enterprise-grade, requires a GCP project', - codeAssistRequirement: 'Requires an active GCP project with billing enabled', - showAdvanced: 'Show advanced options (custom OAuth Client)', - hideAdvanced: 'Hide advanced options (custom OAuth Client)', - gcpProjectLink: 'Create project', - customTitle: 'Custom OAuth (AI Studio OAuth)', - customDesc: 'Uses admin-configured OAuth client for org management.', - customRequirement: 'Admin must configure Client ID and add you as a test user.', - badges: { - recommended: 'Recommended', - highConcurrency: 'High concurrency', - individuals: 'Recommended for individuals', - noGcp: 'No GCP required', - enterprise: 'Enterprise users', - noAdmin: 'No admin setup', - orgManaged: 'Org managed', - adminRequired: 'Admin required' - } - }, - setupGuide: { - title: 'Gemini Setup Checklist', - checklistTitle: 'Checklist', - checklistItems: { - usIp: 'Use a US IP and ensure your account country is set to US.', - age: 'Account must be 18+.' - }, - activationTitle: 'One-click Activation', - activationItems: { - geminiWeb: 'Activate Gemini Web to avoid User not initialized.', - gcpProject: 'Activate a GCP project and get the Project ID for Code Assist.' - }, - links: { - countryCheck: 'Check country association', - countryChange: 'Change country association', - geminiWebActivation: 'Activate Gemini Web', - gcpProject: 'Open GCP Console' - } - }, - quotaPolicy: { - title: 'Gemini Quota & Limit Policy (Reference)', - note: 'Note: Gemini does not provide an official quota inquiry API. The "Daily Quota" shown here is an estimate simulated by the system based on account tiers for scheduling reference only. Please refer to official Google errors for actual limits.', - columns: { - channel: 'Auth Channel', - account: 'Account Status', - limits: 'Limit Policy', - docs: 'Official Docs' - }, - docs: { - codeAssist: 'Code Assist Quotas', - aiStudio: 'AI Studio Pricing', - vertex: 'Vertex AI Quotas' - }, - simulatedNote: 'Simulated quota, for reference only', - rows: { - googleOne: { - channel: 'Google One OAuth (Individuals / Code Assist for Individuals)', - limitsFree: 'Shared pool: 1000 RPD / 60 RPM', - limitsPro: 'Shared pool: 1500 RPD / 120 RPM', - limitsUltra: 'Shared pool: 2000 RPD / 120 RPM' - }, - gcp: { - channel: 'GCP Code Assist OAuth (Enterprise)', - limitsStandard: 'Shared pool: 1500 RPD / 120 RPM', - limitsEnterprise: 'Shared pool: 2000 RPD / 120 RPM' - }, - cli: { - channel: 'Gemini CLI (Official Google Login / Code Assist)', - free: 'Free Google Account', - premium: 'Google One AI Premium', - limitsFree: 'RPD ~1000; RPM ~60 (soft)', - limitsPremium: 'RPD ~1500+; RPM ~60+ (priority queue)' - }, - gcloud: { - channel: 'GCP Code Assist (gcloud auth)', - account: 'No Code Assist subscription', - limits: 'RPD ~1000; RPM ~60 (preview)' - }, - aiStudio: { - channel: 'AI Studio API Key / OAuth', - free: 'No billing (free tier)', - paid: 'Billing enabled (pay-as-you-go)', - limitsFree: 'RPD 50; RPM 2 (Pro) / 15 (Flash)', - limitsPaid: 'RPD unlimited; RPM 1000 (Pro) / 2000 (Flash) (per model)' - }, - customOAuth: { - channel: 'Custom OAuth Client (GCP)', - free: 'Project not billed', - paid: 'Project billed', - limitsFree: 'RPD 50; RPM 2 (project quota)', - limitsPaid: 'RPD unlimited; RPM 1000+ (project quota)' - } - } - }, - rateLimit: { - ok: 'Not rate limited', - unlimited: 'Unlimited', - limited: 'Rate limited {time}', - now: 'now' - } - }, - // Re-Auth Modal - reAuthorizeAccount: 'Re-Authorize Account', - claudeCodeAccount: 'Claude Code Account', - openaiAccount: 'OpenAI Account', - geminiAccount: 'Gemini Account', - antigravityAccount: 'Antigravity Account', - grokAccount: 'Grok Account', - inputMethod: 'Input Method', - reAuthorizedSuccess: 'Account re-authorized successfully', - // Test Modal - testAccountConnection: 'Test Account Connection', - account: 'Account', - readyToTest: 'Ready to test. Click "Start Test" to begin...', - connectingToApi: 'Connecting to API...', - testCompleted: 'Test completed successfully!', - testFailed: 'Test failed', - connectedToApi: 'Connected to API', - usingModel: 'Using model: {model}', - sendingTestMessage: 'Sending test message: "hi"', - sendingImageRequest: 'Sending image generation test request...', - response: 'Response:', - startTest: 'Start Test', - testing: 'Testing...', - retry: 'Retry', - copyOutput: 'Copy output', - outputCopied: 'Output copied', - startingTestForAccount: 'Starting test for account: {name}', - testAccountTypeLabel: 'Account type: {type}', - selectTestModel: 'Select Test Model', - testModel: 'Test model', - testPrompt: 'Prompt: "hi"', - imagePromptLabel: 'Image prompt', - imagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.', - imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.', - imageTestHint: 'When an image model is selected, this test sends a real image-generation request and previews the returned image below.', - imageTestMode: 'Mode: Image generation test', - imagePreview: 'Generated images:', - imageReceived: 'Received test image #{count}', - // Stats Modal - viewStats: 'View Stats', - usageStatistics: 'Usage Statistics', - last30DaysUsage: 'Last 30 days usage statistics (based on actual usage days)', - stats: { - totalCost: '30-Day Total Cost', - accumulatedCost: 'Accumulated cost', - standardCost: 'Standard', - totalRequests: '30-Day Total Requests', - totalCalls: 'Total API calls', - avgDailyCost: 'Daily Avg Cost', - basedOnActualDays: 'Based on {days} actual usage days', - avgDailyRequests: 'Daily Avg Requests', - avgDailyUsage: 'Average daily usage', - todayOverview: 'Today Overview', - cost: 'Cost', - requests: 'Requests', - tokens: 'Tokens', - highestCostDay: 'Highest Cost Day', - highestRequestDay: 'Highest Request Day', - date: 'Date', - accumulatedTokens: 'Accumulated Tokens', - totalTokens: '30-Day Total', - dailyAvgTokens: 'Daily Average', - performance: 'Performance', - avgResponseTime: 'Avg Response', - daysActive: 'Days Active', - recentActivity: 'Recent Activity', - todayRequests: 'Today Requests', - todayTokens: 'Today Tokens', - todayCost: 'Today Cost', - usageTrend: '30-Day Cost & Request Trend', - noData: 'No usage data available for this account' - }, - usageWindow: { - statsTitle: '5-Hour Window Usage Statistics', - statsTitleDaily: 'Daily Usage Statistics', - geminiProDaily: 'Pro', - geminiFlashDaily: 'Flash', - gemini3Pro: 'G3P', - gemini3Flash: 'G3F', - gemini3Image: 'G31FI', - claude: 'Claude', - grokRequests: 'Req', - grokTokens: 'Tok', - grokUnknown: 'Grok quota is unknown until the first upstream response includes xAI rate-limit headers.', - grokRetryAfter: 'Retry after {time}', - grokProbe: 'Probe', - grokProbeTooltip: 'Send a minimal xAI Responses probe and read quota headers', - grokResetUnsupported: 'Reset unsupported', - grokResetUnsupportedTooltip: 'xAI does not expose reset credits for Grok OAuth accounts', - grokNoHeaders: 'No quota headers observed', - grokLastStatus: 'Status {status}', - grokLastProbe: 'Probe {time}', - grokLastHeadersSeen: 'Headers {time}', - passiveSampled: 'Passive', - activeQuery: 'Query' - }, - openaiQuotaReset: { - count: 'Credits', - reset: 'Reset', - countTooltipLoad: 'Click to load the available reset-credit count', - countTooltipRefresh: 'Click to refresh the available reset-credit count', - resetTooltipReady: 'Consume 1 reset credit to immediately restore the window', - resetTooltipNeedQuery: 'Click Credits first to load the available count', - resetTooltipNoCredits: 'No reset credits available', - resetTooltipShadow: 'Spark shadow accounts cannot reset credits; reset on the parent account', - expiresAt: 'Expires {time}', - expiresAtFull: 'Reset credit expires at {time}', - expandExpirations: 'Expand the other {count} reset credit expiration(s)', - collapseExpirations: 'Collapse reset credit expirations', - expirationDetails: 'Reset credit expiration details', - noCreditsAvailable: 'No reset credits available', - resetSuccess: 'Reset {windows} window(s)', - confirmTitle: 'Confirm Weekly Limit Reset', - confirmMessage: 'This will consume 1 reset credit to immediately restore the current window ({count} remaining). This action cannot be undone. Continue?' - }, - tier: { - free: 'Free', - pro: 'Pro', - ultra: 'Ultra', - aiPremium: 'AI Premium', - standard: 'Standard', - basic: 'Basic', - personal: 'Personal', - unlimited: 'Unlimited' - }, - ineligibleWarning: - 'This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.', - forbidden: 'Forbidden', - forbiddenValidation: 'Verification Required', - forbiddenViolation: 'Violation Ban', - openVerification: 'Open Verification Link', - copyLink: 'Copy Link', - linkCopied: 'Link Copied', - needsReauth: 'Re-auth Required', - rateLimited: 'Rate Limited', - usageError: 'Fetch Error' - }, - - // Scheduled Tests - scheduledTests: { - title: 'Scheduled Tests', - addPlan: 'Add Plan', - editPlan: 'Edit Plan', - deletePlan: 'Delete Plan', - model: 'Model', - cronExpression: 'Cron Expression', - enabled: 'Enabled', - lastRun: 'Last Run', - nextRun: 'Next Run', - maxResults: 'Max Results', - noPlans: 'No scheduled test plans', - confirmDelete: 'Are you sure you want to delete this plan?', - createSuccess: 'Plan created successfully', - updateSuccess: 'Plan updated successfully', - deleteSuccess: 'Plan deleted successfully', - results: 'Test Results', - noResults: 'No test results yet', - responseText: 'Response', - errorMessage: 'Error', - success: 'Success', - failed: 'Failed', - running: 'Running', - schedule: 'Schedule', - cronHelp: 'Standard 5-field cron expression (e.g., */30 * * * *)', - cronTooltipTitle: 'Cron expression examples:', - cronTooltipMeaning: 'Defines when the test runs automatically. The 5 fields are: minute, hour, day, month, and weekday.', - cronTooltipExampleEvery30Min: '*/30 * * * *: run every 30 minutes', - cronTooltipExampleHourly: '0 * * * *: run at the start of every hour', - cronTooltipExampleDaily: '0 9 * * *: run every day at 09:00', - cronTooltipExampleWeekly: '0 9 * * 1: run every Monday at 09:00', - cronTooltipRange: 'Recommended range: use standard 5-field cron. For health checks, start with a moderate frequency such as every 30 minutes, every hour, or once a day instead of running too often.', - maxResultsTooltipTitle: 'What Max Results means:', - maxResultsTooltipMeaning: 'Sets how many historical test results are kept for a single plan so the result list does not grow without limit.', - maxResultsTooltipBody: 'Only the newest test results are kept. Once the number of saved results exceeds this value, older records are pruned automatically so the history list and storage stay under control.', - maxResultsTooltipExample: 'For example, 100 means keeping at most the latest 100 test results. When the 101st result is saved, the oldest one is removed.', - maxResultsTooltipRange: 'Recommended range: usually 20 to 200. Use 20-50 when you only care about recent health status, or 100-200 if you want a longer trend history.', - autoRecover: 'Auto Recover', - autoRecoverHelp: 'Automatically recover account from error/rate-limited state on successful test' - }, - - // Proxies - proxies: { - title: 'Proxy Management', - description: 'Manage proxy servers for accounts', - createProxy: 'Create Proxy', - editProxy: 'Edit Proxy', - deleteProxy: 'Delete Proxy', - ad: { - inline: 'Need proxy IP?' - }, - dataImport: 'Import', - dataExportSelected: 'Export Selected', - dataImportTitle: 'Import Proxies', - dataImportHint: 'Upload the exported proxy JSON file to import proxies in bulk.', - dataImportWarning: 'Import will create or reuse proxies, keep their status, and trigger latency checks after completion.', - dataImportFile: 'Data File', - dataImportButton: 'Start Import', - dataImporting: 'Importing...', - dataImportSelectFile: 'Please select a data file', - dataImportParseFailed: 'Failed to parse data', - dataImportFailed: 'Failed to import data', - dataImportResult: 'Import Result', - dataImportResultSummary: 'Created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}', - dataImportErrors: 'Failure Details', - dataImportSuccess: 'Import completed: created {proxy_created}, reused {proxy_reused}', - dataImportCompletedWithErrors: 'Import completed with errors: failed {proxy_failed}', - dataExport: 'Export', - dataExportConfirmMessage: 'The exported data contains sensitive proxy information. Store it securely.', - dataExportConfirm: 'Confirm Export', - dataExported: 'Data exported successfully', - dataExportFailed: 'Failed to export data', - copyProxyUrl: 'Copy Proxy URL', - urlCopied: 'Proxy URL copied', - searchProxies: 'Search proxies...', - allProtocols: 'All Protocols', - allStatus: 'All Status', - protocols: { - http: 'HTTP', - https: 'HTTPS', - socks5: 'SOCKS5', - socks5h: 'SOCKS5H (Remote DNS)' - }, - columns: { - name: 'Name', - protocol: 'Protocol', - address: 'Address', - auth: 'Auth', - location: 'Location', - status: 'Status', - accounts: 'Accounts', - latency: 'Latency', - expiry: 'Validity', - createdAt: 'Created', - actions: 'Actions' - }, - testConnection: 'Test Connection', - qualityCheck: 'Quality Check', - batchQualityCheck: 'Batch Quality Check', - batchTest: 'Test All Proxies', - testFailed: 'Failed', - latencyFailed: 'Connection failed', - batchTestEmpty: 'No proxies available for testing', - batchTestDone: 'Batch test completed for {count} proxies', - batchTestFailed: 'Batch test failed', - batchDeleteAction: 'Delete', - batchDelete: 'Batch delete', - batchDeleteConfirm: 'Delete {count} selected proxies? In-use ones will be skipped.', - batchDeleteDone: 'Deleted {deleted} proxies, skipped {skipped}', - batchDeleteSkipped: 'Skipped {skipped} proxies', - batchDeleteFailed: 'Batch delete failed', - deleteBlockedInUse: 'This proxy is in use and cannot be deleted', - accountsTitle: 'Accounts using this IP', - accountsEmpty: 'No accounts are using this proxy', - accountsFailed: 'Failed to load accounts list', - accountName: 'Account', - accountPlatform: 'Platform', - accountNotes: 'Notes', - name: 'Name', - protocol: 'Protocol', - host: 'Host', - port: 'Port', - username: 'Username (Optional)', - password: 'Password (Optional)', - status: 'Status', - enterProxyName: 'Enter proxy name', - leaveEmptyToKeep: 'Leave empty to keep current', - optionalAuth: 'Optional authentication', - form: { - hostPlaceholder: 'proxy.example.com', - portPlaceholder: '8080' - }, - noProxiesYet: 'No proxies yet', - createFirstProxy: 'Create your first proxy to route traffic through it.', - // Batch import - standardAdd: 'Standard Add', - batchAdd: 'Quick Add', - batchInput: 'Proxy List', - batchInputPlaceholder: - "Enter one proxy per line in the following formats:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443", - batchInputHint: - "Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port", - parsedCount: '{count} valid', - invalidCount: '{count} invalid', - duplicateCount: '{count} duplicate', - importing: 'Importing...', - importProxies: 'Import {count} proxies', - batchImportSuccess: 'Successfully imported {created} proxies, skipped {skipped} duplicates', - batchImportAllSkipped: 'All {skipped} proxies already exist, skipped import', - failedToImport: 'Failed to batch import', - // Other messages - creating: 'Creating...', - updating: 'Updating...', - proxyCreated: 'Proxy created successfully', - proxyUpdated: 'Proxy updated successfully', - proxyDeleted: 'Proxy deleted successfully', - proxyWorking: 'Proxy is working!', - proxyWorkingWithLatency: 'Proxy is working! Latency: {latency}ms', - proxyTestFailed: 'Proxy test failed', - qualityCheckDone: 'Quality check completed: score {score} ({grade})', - qualityCheckFailed: 'Failed to run proxy quality check', - batchQualityDone: - 'Batch quality check completed for {count} proxies: healthy {healthy}, warn {warn}, challenge {challenge}, abnormal {failed}', - batchQualityFailed: 'Batch quality check failed', - batchQualityEmpty: 'No proxies available for quality check', - qualityReportTitle: 'Proxy Quality Report', - qualityGrade: 'Grade {grade}', - qualityExitIP: 'Exit IP', - qualityCountry: 'Exit Region', - qualityBaseLatency: 'Base Latency', - qualityCheckedAt: 'Checked At', - qualityTableTarget: 'Target', - qualityTableStatus: 'Status', - qualityTableLatency: 'Latency', - qualityTableMessage: 'Message', - qualityInline: 'Quality {grade}/{score}', - qualityStatusHealthy: 'Healthy', - qualityStatusPass: 'Pass', - qualityStatusWarn: 'Warn', - qualityStatusFail: 'Fail', - qualityStatusChallenge: 'Challenge', - qualityTargetBase: 'Base Connectivity', - failedToLoad: 'Failed to load proxies', - failedToCreate: 'Failed to create proxy', - failedToUpdate: 'Failed to update proxy', - failedToDelete: 'Failed to delete proxy', - failedToTest: 'Failed to test proxy', - nameRequired: 'Please enter proxy name', - hostRequired: 'Please enter host address', - portInvalid: 'Port must be between 1-65535', - deleteConfirm: - "Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed.", - neverExpires: 'Never', - expired: 'Expired', - overdueDays: 'Overdue {days}d', - expiringInDays: 'Expires in {days}d', - remainingDays: '{days}d left', - expiresAt: 'Validity', - nDays: '{days}d', - expiryDaysPlaceholder: 'Custom days, empty = never', - expiryWarnDays: 'Expiry warning (days)', - fallbackMode: 'Failure fallback', - fallbackNone: 'No fallback', - fallbackProxy: 'Backup proxy', - fallbackDirect: 'Direct connection', - backupProxy: 'Backup proxy', - }, - - // Redeem Codes - redeem: { - title: 'Redeem Code Management', - description: 'Generate and manage redeem codes', - generateCodes: 'Generate Codes', - searchCodes: 'Search codes or email...', - allTypes: 'All Types', - allStatus: 'All Status', - balance: 'Balance', - concurrency: 'Concurrency', - subscription: 'Subscription', - invitation: 'Invitation', - invitationHint: 'Invitation codes are used to restrict user registration. They are automatically marked as used after use.', - unused: 'Unused', - used: 'Used', - columns: { - code: 'Code', - type: 'Type', - value: 'Value', - status: 'Status', - usedBy: 'Used By', - usedAt: 'Used At', - expiresAt: 'Expires At', - actions: 'Actions' - }, - userPrefix: 'User #{id}', - exportCsv: 'Export CSV', - batchUpdate: 'Batch Update', - batchUpdateTitle: 'Batch Update Redeem Codes', - selectedCount: '{count} redeem code(s) selected', - clearSelection: 'Clear selection', - selectCodesFirst: 'Select redeem codes first', - noBatchFieldsSelected: 'Select at least one field to update', - batchUpdateSuccess: 'Updated {count} redeem code(s)', - failedToBatchUpdate: 'Failed to batch update redeem codes', - batchFields: { - status: 'Status', - expiresAt: 'Expires At', - notes: 'Notes', - group: 'Group' - }, - batchNotesPlaceholder: 'Enter the new note, or leave blank to clear it', - clearGroup: 'Clear group', - deleteAllUnused: 'Delete All Unused Codes', - deleteCode: 'Delete Redeem Code', - deleteCodeConfirm: - 'Are you sure you want to delete this redeem code? This action cannot be undone.', - deleteAllUnusedConfirm: - 'Are you sure you want to delete all unused (active) redeem codes? This action cannot be undone.', - deleteAll: 'Delete All', - generateCodesTitle: 'Generate Redeem Codes', - generatedSuccessfully: 'Generated Successfully', - codesCreated: '{count} redeem code(s) created', - codeType: 'Code Type', - amount: 'Amount ($)', - value: 'Value', - count: 'Count', - generating: 'Generating...', - generate: 'Generate', - copyAll: 'Copy All', - copied: 'Copied!', - download: 'Download', - codesExported: 'Codes exported successfully', - codeDeleted: 'Redeem code deleted successfully', - codesDeleted: 'Successfully deleted {count} unused code(s)', - noUnusedCodes: 'No unused codes to delete', - failedToLoad: 'Failed to load redeem codes', - failedToGenerate: 'Failed to generate codes', - failedToExport: 'Failed to export codes', - failedToDelete: 'Failed to delete code', - failedToDeleteUnused: 'Failed to delete unused codes', - failedToCopy: 'Failed to copy codes', - types: { - balance: 'Balance', - concurrency: 'Concurrency', - subscription: 'Subscription', - invitation: 'Invitation', - // Admin adjustment types (created when admin modifies user balance/concurrency) - admin_balance: 'Balance (Admin)', - admin_concurrency: 'Concurrency (Admin)' - }, - selectGroup: 'Select Group', - selectGroupPlaceholder: 'Choose a subscription group', - validityDays: 'Validity Days', - codeExpiry: 'Code Expiry', - neverExpires: 'Never expires', - expiryPresetDays: '{days} days', - customExpiry: 'Custom', - customExpiryDays: 'Custom days', - expiryDaysRequired: 'Please enter a valid expiry day count', - groupRequired: 'Please select a subscription group', - days: ' days', - status: { - unused: 'Unused', - used: 'Used', - expired: 'Expired', - disabled: 'Disabled' - } - }, - - // Announcements - announcements: { - title: 'Announcements', - description: 'Create announcements and target by conditions', - createAnnouncement: 'Create Announcement', - editAnnouncement: 'Edit Announcement', - deleteAnnouncement: 'Delete Announcement', - searchAnnouncements: 'Search announcements...', - status: 'Status', - allStatus: 'All Status', - columns: { - title: 'Title', - status: 'Status', - notifyMode: 'Notify Mode', - targeting: 'Targeting', - timeRange: 'Schedule', - createdAt: 'Created At', - actions: 'Actions' - }, - statusLabels: { - draft: 'Draft', - active: 'Active', - archived: 'Archived' - }, - notifyModeLabels: { - silent: 'Silent', - popup: 'Popup' - }, - form: { - title: 'Title', - content: 'Content (Markdown supported)', - status: 'Status', - notifyMode: 'Notify Mode', - notifyModeHint: 'Popup mode will show a popup notification to users', - startsAt: 'Starts At', - endsAt: 'Ends At', - startsAtHint: 'Leave empty to start immediately', - endsAtHint: 'Leave empty to never expire', - targetingMode: 'Targeting', - targetingAll: 'All users', - targetingCustom: 'Custom rules', - addOrGroup: 'Add OR group', - addAndCondition: 'Add AND condition', - conditionType: 'Condition type', - conditionSubscription: 'Subscription', - conditionBalance: 'Balance', - operator: 'Operator', - balanceValue: 'Balance threshold', - selectPackages: 'Select packages' - }, - operators: { - gt: '>', - gte: '≥', - lt: '<', - lte: '≤', - eq: '=' - }, - targetingSummaryAll: 'All users', - targetingSummaryCustom: 'Custom ({groups} groups)', - timeImmediate: 'Immediate', - timeNever: 'Never', - readStatus: 'Read Status', - eligible: 'Eligible', - readAt: 'Read at', - unread: 'Unread', - searchUsers: 'Search users...', - failedToLoad: 'Failed to load announcements', - failedToCreate: 'Failed to create announcement', - failedToUpdate: 'Failed to update announcement', - failedToDelete: 'Failed to delete announcement', - failedToLoadReadStatus: 'Failed to load read status', - deleteConfirm: 'Are you sure you want to delete this announcement? This action cannot be undone.' - }, - - // Promo Codes - promo: { - title: 'Promo Code Management', - description: 'Create and manage registration promo codes', - createCode: 'Create Promo Code', - editCode: 'Edit Promo Code', - deleteCode: 'Delete Promo Code', - searchCodes: 'Search codes...', - allStatus: 'All Status', - columns: { - code: 'Code', - bonusAmount: 'Bonus Amount', - maxUses: 'Max Uses', - usedCount: 'Used', - usage: 'Usage', - status: 'Status', - expiresAt: 'Expires At', - createdAt: 'Created At', - actions: 'Actions' - }, - // Form labels (flat structure for template usage) - code: 'Promo Code', - autoGenerate: 'auto-generate if empty', - codePlaceholder: 'Enter promo code or leave empty', - bonusAmount: 'Bonus Amount ($)', - maxUses: 'Max Uses', - zeroUnlimited: '0 = unlimited', - expiresAt: 'Expires At', - notes: 'Notes', - notesPlaceholder: 'Optional notes for this code', - status: 'Status', - neverExpires: 'Never expires', - // Status labels - statusActive: 'Active', - statusDisabled: 'Disabled', - statusExpired: 'Expired', - statusMaxUsed: 'Used Up', - // Usage records - usageRecords: 'Usage Records', - viewUsages: 'View Usages', - noUsages: 'No usage records yet', - userPrefix: 'User #{id}', - copied: 'Copied!', - // Messages - noCodesYet: 'No promo codes yet', - createFirstCode: 'Create your first promo code to offer registration bonuses.', - codeCreated: 'Promo code created successfully', - codeUpdated: 'Promo code updated successfully', - codeDeleted: 'Promo code deleted successfully', - deleteCodeConfirm: 'Are you sure you want to delete this promo code? This action cannot be undone.', - copyRegisterLink: 'Copy register link', - registerLinkCopied: 'Register link copied to clipboard', - failedToLoad: 'Failed to load promo codes', - failedToCreate: 'Failed to create promo code', - failedToUpdate: 'Failed to update promo code', - failedToDelete: 'Failed to delete promo code', - failedToLoadUsages: 'Failed to load usage records' - }, - - // Usage Records - usage: { - title: 'Usage Records', - description: 'View and manage all user usage records', - userFilter: 'User', - searchUserPlaceholder: 'Search user by email...', - searchApiKeyPlaceholder: 'Search API key by name...', - searchAccountPlaceholder: 'Search account by name...', - selectedUser: 'Selected', - user: 'User', - account: 'Account', - group: 'Group', - requestId: 'Request ID', - requestIdCopied: 'Request ID copied', - allModels: 'All Models', - allAccounts: 'All Accounts', - allGroups: 'All Groups', - allTypes: 'All Types', - inputCost: 'Input Cost', - outputCost: 'Output Cost', - cacheCreationCost: 'Cache Creation Cost', - cacheReadCost: 'Cache Read Cost', - inputTokens: 'Input Tokens', - outputTokens: 'Output Tokens', - cacheCreationTokens: 'Cache Creation Tokens', - cacheCreation5mTokens: 'Cache Write', - cacheCreation1hTokens: 'Cache Write', - cacheReadTokens: 'Cache Read Tokens', - failedToLoad: 'Failed to load usage records', - billingType: 'Billing Type', - allBillingTypes: 'All Billing Types', - billingTypeBalance: 'Balance', - billingTypeSubscription: 'Subscription', - billingMode: 'Billing Mode', - billingModeToken: 'Token', - billingModePerRequest: 'Per Request', - billingModeImage: 'Image', - allBillingModes: 'All Billing Modes', - ipAddress: 'IP', - clickToViewBalance: 'Click to view balance history', - failedToLoadUser: 'Failed to load user info', - userDeletedBadge: 'Deleted', - cleanup: { - button: 'Cleanup', - title: 'Cleanup Usage Records', - warning: 'Cleanup is irreversible and will affect historical stats.', - submit: 'Submit Cleanup', - submitting: 'Submitting...', - confirmTitle: 'Confirm Cleanup', - confirmMessage: 'Are you sure you want to submit this cleanup task? This action cannot be undone.', - confirmSubmit: 'Confirm Cleanup', - cancel: 'Cancel', - cancelConfirmTitle: 'Confirm Cancel', - cancelConfirmMessage: 'Are you sure you want to cancel this cleanup task?', - cancelConfirm: 'Confirm Cancel', - cancelSuccess: 'Cleanup task canceled', - cancelFailed: 'Failed to cancel cleanup task', - recentTasks: 'Recent Cleanup Tasks', - loadingTasks: 'Loading tasks...', - noTasks: 'No cleanup tasks yet', - range: 'Range', - deletedRows: 'Deleted', - missingRange: 'Please select a date range', - submitSuccess: 'Cleanup task created', - submitFailed: 'Failed to create cleanup task', - loadFailed: 'Failed to load cleanup tasks', - status: { - pending: 'Pending', - running: 'Running', - succeeded: 'Succeeded', - failed: 'Failed', - canceled: 'Canceled' - } - } - }, - - // Ops Monitoring - ops: { - title: 'Ops Monitoring', - description: 'Operational monitoring and troubleshooting', - // Dashboard - systemHealth: 'System Health', - overview: 'Overview', - noSystemMetrics: 'No system metrics collected yet.', - collectedAt: 'Collected at:', - window: 'window', - memory: 'Memory', - db: 'DB', - goroutines: 'Goroutines', - jobs: 'Jobs', - jobsHelp: 'Click “Details” to view job heartbeats and recent errors', - active: 'active', - idle: 'idle', - waiting: 'waiting', - conns: 'conns', - queue: 'queue', - accountSwitches: 'Account switches', - ok: 'ok', - lastRun: 'last_run:', - lastSuccess: 'last_success:', - lastError: 'last_error:', - noData: 'No data.', - loadingText: 'loading', - ready: 'ready', - autoRefreshRemaining: 'Remaining {seconds}s', - systemLogs: { - title: 'System Logs', - description: 'Newest logs are shown first. Filter, search, and clean up by condition.', - queue: 'Queue', - written: 'Written', - dropped: 'Dropped', - failed: 'Failed', - runtimeConfig: 'Runtime Log Configuration (applies immediately)', - all: 'All', - level: 'Level', - stacktraceThreshold: 'Stacktrace threshold', - samplingInitial: 'Sampling initial', - samplingThereafter: 'Sampling thereafter', - retentionDays: 'Retention days', - caller: 'caller', - sampling: 'sampling', - saveAndApply: 'Save and apply', - resetDefaults: 'Reset defaults', - latestWriteError: 'Latest write error:', - timeRange: 'Time range', - startTime: 'Start time (optional)', - endTime: 'End time (optional)', - component: 'Component', - componentPlaceholder: 'e.g. http.access', - keyId: 'KEY ID', - platform: 'Platform', - model: 'Model', - keyword: 'Keyword', - keywordPlaceholder: 'message/request_id', - search: 'Search', - cleanCurrentFilters: 'Clean current filters', - refreshHealth: 'Refresh health', - empty: 'No system logs', - time: 'Time', - logDetails: 'Log Details', - loadFailed: 'Failed to load system logs', - runtimeConfigActive: 'Runtime log configuration is active', - runtimeConfigSaveFailed: 'Failed to save log configuration', - resetRuntimeConfigConfirm: 'Reset to startup configuration (env/yaml) and apply immediately?', - runtimeConfigReset: 'Reset to startup log configuration', - runtimeConfigResetFailed: 'Failed to reset log configuration', - cleanupConfirm: 'Clean up system logs matching the current filters? This cannot be undone.', - cleanupSuccess: 'Cleanup complete. Deleted {count} log entries.', - cleanupFailed: 'Failed to clean up system logs' - }, - requestsTotal: 'Requests (total)', - slaScope: 'SLA scope:', - tokens: 'Tokens', - tps: 'TPS:', - current: 'current', - peak: 'peak', - average: 'average', - totalRequests: 'Total Requests', - avgQps: 'Avg QPS', - avgTps: 'Avg TPS', - avgLatency: 'Avg Request Duration', - avgTtft: 'Avg TTFT', - exceptions: 'Exceptions', - requestErrors: 'Request Errors', - errorCount: 'Error Count', - upstreamErrors: 'Upstream Errors', - errorCountExcl429529: 'Error Count (excl 429/529)', - sla: 'SLA (excl business limits)', - businessLimited: 'business_limited:', - errors: 'Errors', - errorRate: 'error_rate:', - upstreamRate: 'upstream_rate:', - latencyDuration: 'Request Duration', - ttftLabel: 'TTFT (first_token_ms)', - p50: 'p50:', - p90: 'p90:', - p95: 'p95:', - p99: 'p99:', - avg: 'avg:', - max: 'max:', - requests: 'Requests', - requestsTitle: 'Requests', - upstream: 'Upstream', - client: 'Client', - system: 'System', - other: 'Other', - errorsSla: 'Errors (SLA scope)', - upstreamExcl429529: 'Upstream (excl 429/529)', - failedToLoadData: 'Failed to load ops data.', - failedToLoadOverview: 'Failed to load overview', - failedToLoadThroughputTrend: 'Failed to load throughput trend', - failedToLoadSwitchTrend: 'Failed to load avg account switches trend', - failedToLoadLatencyHistogram: 'Failed to load request duration histogram', - failedToLoadErrorTrend: 'Failed to load error trend', - failedToLoadErrorDistribution: 'Failed to load error distribution', - failedToLoadErrorDetail: 'Failed to load error detail', - retryFailed: 'Retry failed', - tpsK: 'TPS (K)', - top: 'Top:', - throughputTrend: 'Throughput Trend', - switchRateTrend: 'Avg Account Switches', - latencyHistogram: 'Request Duration Histogram', - errorTrend: 'Error Trend', - errorDistribution: 'Error Distribution', - switchRate: 'Avg switches', - // Health Score & Diagnosis - health: 'Health', - healthCondition: 'Health Condition', - healthHelp: 'Overall system health score based on SLA, error rate, and resource usage', - healthyStatus: 'Healthy', - riskyStatus: 'At Risk', - idleStatus: 'Idle', - timeRange: { - '5m': 'Last 5 minutes', - '30m': 'Last 30 minutes', - '1h': 'Last 1 hour', - '1d': 'Last 1 day', - '15d': 'Last 15 days', - '6h': 'Last 6 hours', - '24h': 'Last 24 hours', - '7d': 'Last 7 days', - '30d': 'Last 30 days' - }, - openaiTokenStats: { - title: 'OpenAI Token Request Stats', - viewModeTopN: 'TopN', - viewModePagination: 'Pagination', - prevPage: 'Previous', - nextPage: 'Next', - pageInfo: 'Page {page}/{total}', - totalModels: 'Total models: {total}', - failedToLoad: 'Failed to load OpenAI token stats', - empty: 'No OpenAI token stats for the current filters', - table: { - model: 'Model', - requestCount: 'Requests', - avgTokensPerSec: 'Avg Tokens/sec', - avgFirstTokenMs: 'Avg First Token Latency (ms)', - totalOutputTokens: 'Total Output Tokens', - avgDurationMs: 'Avg Duration (ms)', - requestsWithFirstToken: 'Requests With First Token' - } - }, - fullscreen: { - enter: 'Enter Fullscreen' - }, - diagnosis: { - title: 'Smart Diagnosis', - footer: 'Automated diagnostic suggestions based on current metrics', - idle: 'System is currently idle', - idleImpact: 'No active traffic', - // Resource diagnostics - dbDown: 'Database connection failed', - dbDownImpact: 'All database operations will fail', - dbDownAction: 'Check database service status, network connectivity, and connection configuration', - redisDown: 'Redis connection failed', - redisDownImpact: 'Cache functionality degraded, performance may decline', - redisDownAction: 'Check Redis service status and network connectivity', - cpuCritical: 'CPU usage critically high ({usage}%)', - cpuCriticalImpact: 'System response slowing, may affect all requests', - cpuCriticalAction: 'Check CPU-intensive tasks, consider scaling or code optimization', - cpuHigh: 'CPU usage elevated ({usage}%)', - cpuHighImpact: 'System load is high, needs attention', - cpuHighAction: 'Monitor CPU trends, prepare scaling plan', - memoryCritical: 'Memory usage critically high ({usage}%)', - memoryCriticalImpact: 'May trigger OOM, system stability threatened', - memoryCriticalAction: 'Check for memory leaks, consider increasing memory or optimizing usage', - memoryHigh: 'Memory usage elevated ({usage}%)', - memoryHighImpact: 'Memory pressure is high, needs attention', - memoryHighAction: 'Monitor memory trends, check for memory leaks', - ttftHigh: 'Time to first token elevated ({ttft}ms)', - ttftHighImpact: 'User perceived latency increased', - ttftHighAction: 'Optimize request processing flow, reduce pre-processing time', - // Error rate diagnostics - upstreamCritical: 'Upstream error rate critically high ({rate}%)', - upstreamCriticalImpact: 'May affect many user requests', - upstreamCriticalAction: 'Check upstream service health, enable fallback strategies', - upstreamHigh: 'Upstream error rate elevated ({rate}%)', - upstreamHighImpact: 'Recommend checking upstream service status', - upstreamHighAction: 'Contact upstream service team, prepare fallback plan', - errorHigh: 'Error rate too high ({rate}%)', - errorHighImpact: 'Many requests failing', - errorHighAction: 'Check error logs, identify root cause, urgent fix required', - errorElevated: 'Error rate elevated ({rate}%)', - errorElevatedImpact: 'Recommend checking error logs', - errorElevatedAction: 'Analyze error types and distribution, create fix plan', - // SLA diagnostics - slaCritical: 'SLA critically below target ({sla}%)', - slaCriticalImpact: 'User experience severely degraded', - slaCriticalAction: 'Urgently investigate errors and latency, consider rate limiting', - slaLow: 'SLA below target ({sla}%)', - slaLowImpact: 'Service quality needs attention', - slaLowAction: 'Analyze SLA decline causes, optimize system performance', - // Health score diagnostics - healthCritical: 'Overall health score critically low ({score})', - healthCriticalImpact: 'Multiple metrics may be degraded; prioritize error rate and latency investigation', - healthCriticalAction: 'Comprehensive system check, prioritize critical-level issues', - healthLow: 'Overall health score low ({score})', - healthLowImpact: 'May indicate minor instability; monitor SLA and error rates', - healthLowAction: 'Monitor metric trends, prevent issue escalation', - healthy: 'All system metrics normal', - healthyImpact: 'Service running stable' - }, - // Error Log - errorLog: { - timeId: 'Time / ID', - commonErrors: { - contextDeadlineExceeded: 'context deadline exceeded', - connectionRefused: 'connection refused', - rateLimit: 'rate limit' - }, - time: 'Time', - type: 'Type', - context: 'Context', - platform: 'Platform', - model: 'Model', - group: 'Group', - user: 'User', - userId: 'User ID', - apiKey: 'API Key', - keyDeletedBadge: 'Key Deleted', - account: 'Account', - accountId: 'Account ID', - status: 'Status', - message: 'Message', - ip: 'IP', - latency: 'Request Duration', - action: 'Action', - noErrors: 'No errors in this window.', - grp: 'GRP:', - acc: 'ACC:', - details: 'Details', - phase: 'Phase', - id: 'ID:', - typeUpstream: 'Upstream', - typeRequest: 'Request', - typeAuth: 'Auth', - typeRouting: 'Routing', - typeInternal: 'Internal', - endpoint: 'Endpoint', - requestType: 'Type', - requestTypeSync: 'Sync', - requestTypeStream: 'Stream', - requestTypeWs: 'WS' - }, - // Error Details Modal - errorDetails: { - upstreamErrors: 'Upstream Errors', - requestErrors: 'Request Errors', - unresolved: 'Unresolved', - resolved: 'Resolved', - viewErrors: 'Errors', - viewExcluded: 'Excluded', - statusCodeOther: 'Other', - owner: { - provider: 'Provider', - client: 'Client', - platform: 'Platform' - }, - phase: { - request: 'Request', - auth: 'Auth', - routing: 'Routing', - upstream: 'Upstream', - network: 'Network', - internal: 'Internal' - }, - total: 'Total:', - searchPlaceholder: 'Search request_id / client_request_id / message', - }, - // Error Detail Modal - errorDetail: { - title: 'Error Detail', - titleWithId: 'Error #{id}', - noErrorSelected: 'No error selected.', - resolution: 'Resolved:', - failedToUpdateResolvedStatus: 'Failed to update resolved status', - classificationKeys: { - phase: 'Phase', - owner: 'Owner', - source: 'Source', - resolvedAt: 'Resolved At', - resolvedBy: 'Resolved By' - }, - source: { - upstream_http: 'Upstream HTTP' - }, - upstreamKeys: { - status: 'Status', - message: 'Message', - detail: 'Detail', - upstreamErrors: 'Upstream Errors' - }, - upstreamEvent: { - account: 'Account', - status: 'Status', - requestId: 'Request ID' - }, - responsePreview: { - expand: 'Response (click to expand)', - collapse: 'Response (click to collapse)' - }, - loading: 'Loading…', - requestId: 'Request ID', - time: 'Time', - phase: 'Phase', - status: 'Status', - message: 'Message', - basicInfo: 'Basic Info', - platform: 'Platform', - model: 'Model', - group: 'Group', - user: 'User', - account: 'Account', - latency: 'Request Duration', - businessLimited: 'Business Limited', - requestPath: 'Request Path', - inboundEndpoint: 'Inbound Endpoint', - upstreamEndpoint: 'Upstream Endpoint', - requestedModel: 'Requested Model', - upstreamModel: 'Upstream Model', - requestType: 'Request Type', - requestTypeUnknown: 'Unknown', - requestTypeSync: 'Sync', - requestTypeStream: 'Stream', - requestTypeWs: 'WebSocket', - modelMapping: 'Model Mapping', - timings: 'Timings', - auth: 'Auth', - routing: 'Routing', - upstream: 'Upstream', - response: 'Response', - classification: 'Classification', - errorBody: 'Error Body', - trimmed: 'trimmed', - markResolved: 'Mark resolved', - markUnresolved: 'Mark unresolved', - tabOverview: 'Overview', - tabRequest: 'Request', - tabResponse: 'Response', - responseBody: 'Response', - compareA: 'Compare A', - compareB: 'Compare B', - suggestion: 'Suggestion', - suggestUpstream: 'Upstream instability: check account status or consider switching accounts', - suggestRequest: 'Client request error: ask customer to fix request parameters', - suggestAuth: 'Auth failed: verify API key/credentials', - suggestPlatform: 'Platform error: prioritize investigation and fix', - suggestGeneric: 'See details for more context', - apiKeyPrefix: 'Key Prefix', - attemptedKeyPrefix: 'Attempted Key Prefix', - deletedKeyOwner: 'Deleted Key Owner', - keyDeletedBadge: 'Key Deleted' - }, - requestDetails: { - title: 'Request Details', - details: 'Details', - rangeLabel: 'Window: {range}', - rangeMinutes: '{n} minutes', - rangeHours: '{n} hours', - empty: 'No requests in this window.', - emptyHint: 'Try a different time range or remove filters.', - failedToLoad: 'Failed to load request details', - requestIdCopied: 'Request ID copied', - copyFailed: 'Copy failed', - copy: 'Copy', - viewError: 'View Error', - kind: { - success: 'SUCCESS', - error: 'ERROR' - }, - table: { - time: 'Time', - kind: 'Kind', - platform: 'Platform', - model: 'Model', - duration: 'Duration', - status: 'Status', - requestId: 'Request ID', - actions: 'Actions' - } - }, - alertEvents: { - title: 'Alert Events', - description: 'Recent alert firing/resolution records (email-only)', - loading: 'Loading...', - empty: 'No alert events', - loadFailed: 'Failed to load alert events', - status: { - firing: 'FIRING', - resolved: 'RESOLVED', - manualResolved: 'MANUAL RESOLVED' - }, - detail: { - title: 'Alert Detail', - loading: 'Loading detail...', - empty: 'No detail', - loadFailed: 'Failed to load alert detail', - manualResolve: 'Mark as Resolved', - manualResolvedSuccess: 'Marked as manually resolved', - manualResolvedFailed: 'Failed to mark as manually resolved', - silence: 'Ignore Alert', - silenceSuccess: 'Alert silenced', - silenceFailed: 'Failed to silence alert', - viewRule: 'View Rule', - viewLogs: 'View Logs', - firedAt: 'Fired At', - resolvedAt: 'Resolved At', - ruleId: 'Rule ID', - dimensions: 'Dimensions', - historyTitle: 'History', - historyHint: 'Recent events with same rule + dimensions', - historyLoading: 'Loading history...', - historyEmpty: 'No history' - }, - table: { - time: 'Time', - status: 'Status', - severity: 'Severity', - platform: 'Platform', - ruleId: 'Rule ID', - title: 'Title', - duration: 'Duration', - metric: 'Metric / Threshold', - dimensions: 'Dimensions', - email: 'Email Sent', - emailSent: 'Sent', - emailIgnored: 'Ignored' - } - }, - alertRules: { - title: 'Alert Rules', - description: 'Create and manage threshold-based system alerts (email-only)', - loading: 'Loading...', - empty: 'No alert rules', - loadFailed: 'Failed to load alert rules', - saveFailed: 'Failed to save alert rule', - saveSuccess: 'Alert rule saved successfully', - deleteFailed: 'Failed to delete alert rule', - deleteSuccess: 'Alert rule deleted successfully', - manage: 'Manage Alert Rules', - create: 'Create Rule', - createTitle: 'Create Alert Rule', - editTitle: 'Edit Alert Rule', - deleteConfirmTitle: 'Delete this rule?', - deleteConfirmMessage: 'This will remove the rule and its related events. Continue?', - metricGroups: { - system: 'System Metrics', - group: 'Group-level Metrics (requires group_id)', - account: 'Account-level Metrics' - }, - metrics: { - successRate: 'Success Rate (%)', - errorRate: 'Error Rate (%)', - upstreamErrorRate: 'Upstream Error Rate (%)', - p95: 'P95 Latency (ms)', - p99: 'P99 Latency (ms)', - cpu: 'CPU Usage (%)', - memory: 'Memory Usage (%)', - queueDepth: 'Concurrency Queue Depth', - groupAvailableAccounts: 'Group Available Accounts', - groupAvailableRatio: 'Group Available Ratio (%)', - groupRateLimitRatio: 'Group Rate Limit Ratio (%)', - accountRateLimitedCount: 'Rate-limited Accounts', - accountErrorCount: 'Error Accounts (excluding temporarily unschedulable)', - accountErrorRatio: 'Error Account Ratio (%)', - accountTempUnscheduledCount: 'Temporarily Unschedulable Accounts', - overloadAccountCount: 'Overloaded Accounts' - }, - metricDescriptions: { - successRate: 'Percentage of successful requests in the window (0-100).', - errorRate: 'Percentage of failed requests in the window (0-100).', - upstreamErrorRate: 'Percentage of upstream failures in the window (0-100).', - p95: 'P95 request latency within the window (ms).', - p99: 'P99 request latency within the window (ms).', - cpu: 'Current instance CPU usage (0-100).', - memory: 'Current instance memory usage (0-100).', - queueDepth: 'Concurrency queue depth within the window (queued requests).', - groupAvailableAccounts: 'Number of available accounts in the selected group (requires group_id).', - groupAvailableRatio: 'Available account ratio in the selected group (0-100, requires group_id).', - groupRateLimitRatio: 'Rate-limited account ratio in the selected group (0-100, requires group_id).', - accountRateLimitedCount: 'Number of rate-limited accounts within the window.', - accountErrorCount: 'Number of error accounts within the window (excluding temporarily unschedulable).', - accountErrorRatio: 'Error account ratio within the window (0-100).', - accountTempUnscheduledCount: 'Number of accounts currently temporarily unschedulable (e.g. proxy/credential failure auto-eviction).', - overloadAccountCount: 'Number of overloaded accounts within the window.' - }, - hints: { - recommended: 'Recommended: operator {operator}, threshold {threshold}{unit}', - groupRequired: 'This is a group-level metric; selecting a group (group_id) is required.', - groupOptional: 'Optional: limit the rule to a specific group via group_id.' - }, - table: { - name: 'Name', - metric: 'Metric', - severity: 'Severity', - enabled: 'Enabled', - actions: 'Actions' - }, - form: { - name: 'Name', - description: 'Description', - metric: 'Metric', - operator: 'Operator', - groupId: 'Group (group_id)', - groupPlaceholder: 'Select a group', - allGroups: 'All groups', - threshold: 'Threshold', - severity: 'Severity', - window: 'Window (minutes)', - sustained: 'Sustained (samples)', - cooldown: 'Cooldown (minutes)', - enabled: 'Enabled', - notifyEmail: 'Send email notifications' - }, - validation: { - title: 'Please fix the following issues', - invalid: 'Invalid rule', - nameRequired: 'Name is required', - metricRequired: 'Metric is required', - groupIdRequired: 'group_id is required for group-level metrics', - operatorRequired: 'Operator is required', - thresholdRequired: 'Threshold must be a number', - windowRange: 'Window must be one of: 1, 5, 60 minutes', - sustainedRange: 'Sustained must be between 1 and 1440 samples', - cooldownRange: 'Cooldown must be between 0 and 1440 minutes' - } - }, - runtime: { - title: 'Ops Runtime Settings', - description: 'Stored in database; changes take effect without editing config files.', - loading: 'Loading...', - noData: 'No runtime settings available', - loadFailed: 'Failed to load runtime settings', - saveSuccess: 'Runtime settings saved', - saveFailed: 'Failed to save runtime settings', - alertTitle: 'Alert Evaluator', - groupAvailabilityTitle: 'Group Availability Monitor', - evalIntervalSeconds: 'Evaluation Interval (seconds)', - silencing: { - title: 'Alert Silencing (Maintenance Mode)', - enabled: 'Enable silencing', - globalUntil: 'Silence until (RFC3339)', - untilHint: 'Leave empty to only toggle silencing without an expiry (not recommended).', - reason: 'Reason', - reasonPlaceholder: 'e.g., planned maintenance', - entries: { - title: 'Advanced: targeted silencing', - hint: 'Optional: silence only certain rules or severities. Leave fields empty to match all.', - add: 'Add Entry', - empty: 'No targeted entries', - entryTitle: 'Entry #{n}', - ruleId: 'Rule ID (optional)', - ruleIdPlaceholder: 'e.g., 1', - severities: 'Severities (optional)', - severitiesPlaceholder: 'e.g., P0,P1 (empty = all)', - until: 'Until (RFC3339)', - reason: 'Reason', - validation: { - untilRequired: 'Entry until time is required', - untilFormat: 'Entry until time must be a valid RFC3339 timestamp', - ruleIdPositive: 'Entry rule_id must be a positive integer', - severitiesFormat: 'Entry severities must be a comma-separated list of P0..P3' - } - }, - validation: { - timeFormat: 'Silence time must be a valid RFC3339 timestamp' - } - }, - lockEnabled: 'Distributed Lock Enabled', - lockKey: 'Distributed Lock Key', - lockTTLSeconds: 'Distributed Lock TTL (seconds)', - showAdvancedDeveloperSettings: 'Show advanced developer settings (Distributed Lock)', - advancedSettingsSummary: 'Advanced settings (Distributed Lock)', - evalIntervalHint: 'How often the evaluator runs. Keeping the default is recommended.', - validation: { - title: 'Please fix the following issues', - invalid: 'Invalid settings', - evalIntervalRange: 'Evaluation interval must be between 1 and 86400 seconds', - lockKeyRequired: 'Distributed lock key is required when lock is enabled', - lockKeyPrefix: 'Distributed lock key must start with "{prefix}"', - lockKeyHint: 'Recommended: start with "{prefix}" to avoid conflicts', - lockTtlRange: 'Distributed lock TTL must be between 1 and 86400 seconds', - slaMinPercentRange: 'SLA minimum percentage must be between 0 and 100', - ttftP99MaxRange: 'TTFT P99 maximum must be a number ≥ 0', - requestErrorRateMaxRange: 'Request error rate maximum must be between 0 and 100', - upstreamErrorRateMaxRange: 'Upstream error rate maximum must be between 0 and 100' - } - }, - email: { - title: 'Email Notification', - description: 'Configure alert/report email notifications (stored in database).', - loading: 'Loading...', - noData: 'No email notification config', - loadFailed: 'Failed to load email notification config', - saveSuccess: 'Email notification config saved', - saveFailed: 'Failed to save email notification config', - alertTitle: 'Alert Emails', - reportTitle: 'Report Emails', - recipients: 'Recipients', - recipientsHint: 'If empty, the system may fallback to the first admin email.', - minSeverity: 'Min Severity', - minSeverityAll: 'All severities', - rateLimitPerHour: 'Rate limit per hour', - batchWindowSeconds: 'Batch window (seconds)', - includeResolved: 'Include resolved alerts', - dailySummary: 'Daily summary', - weeklySummary: 'Weekly summary', - errorDigest: 'Error digest', - errorDigestMinCount: 'Min errors for digest', - accountHealth: 'Account health', - accountHealthThreshold: 'Error rate threshold (%)', - cronPlaceholder: 'Cron expression', - reportHint: 'Schedules use cron syntax; leave empty to use defaults.', - validation: { - title: 'Please fix the following issues', - invalid: 'Invalid email notification config', - alertRecipientsRequired: 'Alert emails are enabled but no recipients are configured', - reportRecipientsRequired: 'Report emails are enabled but no recipients are configured', - invalidRecipients: 'One or more recipient emails are invalid', - rateLimitRange: 'Rate limit per hour must be a number ≥ 0', - batchWindowRange: 'Batch window must be between 0 and 86400 seconds', - cronRequired: 'A cron expression is required when schedule is enabled', - cronFormat: 'Cron expression format looks invalid (expected at least 5 parts)', - digestMinCountRange: 'Min errors for digest must be a number ≥ 0', - accountHealthThresholdRange: 'Account health threshold must be between 0 and 100' - } - }, - settings: { - title: 'Ops Monitoring Settings', - loadFailed: 'Failed to load settings', - saveSuccess: 'Ops monitoring settings saved successfully', - saveFailed: 'Failed to save settings', - dataCollection: 'Data Collection', - evaluationInterval: 'Evaluation Interval (seconds)', - evaluationIntervalHint: 'Frequency of detection tasks, recommended to keep default', - alertConfig: 'Alert Configuration', - enableAlert: 'Enable Alerts', - alertRecipients: 'Alert Recipient Emails', - emailPlaceholder: 'Enter email address', - recipientsHint: 'If empty, the system will use the first admin email as default recipient', - minSeverity: 'Minimum Severity', - reportConfig: 'Report Configuration', - enableReport: 'Enable Reports', - reportRecipients: 'Report Recipient Emails', - dailySummary: 'Daily Summary', - weeklySummary: 'Weekly Summary', - metricThresholds: 'Metric Thresholds', - metricThresholdsHint: 'Configure alert thresholds for metrics, values exceeding thresholds will be displayed in red', - slaMinPercent: 'SLA Minimum Percentage', - slaMinPercentHint: 'SLA below this value will be displayed in red (default: 99.5%)', - ttftP99MaxMs: 'TTFT P99 Maximum (ms)', - ttftP99MaxMsHint: 'TTFT P99 above this value will be displayed in red (default: 500ms)', - requestErrorRateMaxPercent: 'Request Error Rate Maximum (%)', - requestErrorRateMaxPercentHint: 'Request error rate above this value will be displayed in red (default: 5%)', - upstreamErrorRateMaxPercent: 'Upstream Error Rate Maximum (%)', - upstreamErrorRateMaxPercentHint: 'Upstream error rate above this value will be displayed in red (default: 5%)', - advancedSettings: 'Advanced Settings', - dataRetention: 'Data Retention Policy', - enableCleanup: 'Enable Data Cleanup', - cleanupSchedule: 'Cleanup Schedule (Cron)', - cleanupScheduleHint: 'Example: 0 2 * * * means 2 AM daily', - errorLogRetentionDays: 'Error Log Retention Days', - minuteMetricsRetentionDays: 'Minute Metrics Retention Days', - hourlyMetricsRetentionDays: 'Hourly Metrics Retention Days', - retentionDaysHint: 'Recommended 7-90 days; longer periods consume more storage. Set to 0 to wipe all history on every scheduled cleanup', - aggregation: 'Pre-aggregation Tasks', - enableAggregation: 'Enable Pre-aggregation', - aggregationHint: 'Pre-aggregation improves query performance for long time windows', - openaiQuotaAutoPause: 'OpenAI Account Quota Auto-pause', - openaiQuotaAutoPauseHint: 'When an OpenAI account reaches its 5h / 7d usage threshold, the scheduler skips it automatically and resumes once the window rolls over. Per-account thresholds take precedence over this global default.', - openaiQuotaAutoPauseDefault5h: 'Default 5h usage threshold (%)', - openaiQuotaAutoPauseDefault7d: 'Default 7d usage threshold (%)', - openaiQuotaAutoPauseThresholdHint: 'Value 0-100; leave blank or 0 to disable the global default threshold.', - errorFiltering: 'Error Filtering', - ignoreCountTokensErrors: 'Ignore count_tokens errors', - ignoreCountTokensErrorsHint: 'When enabled, errors from count_tokens requests will not be written to the error log.', - ignoreContextCanceled: 'Ignore client disconnect errors', - ignoreContextCanceledHint: 'When enabled, client disconnect (context canceled) errors will not be written to the error log.', - ignoreNoAvailableAccounts: 'Ignore no available accounts errors', - ignoreNoAvailableAccountsHint: 'When enabled, "No available accounts" errors will not be written to the error log (not recommended; usually a config issue).', - ignoreInvalidApiKeyErrors: 'Ignore invalid API key errors', - ignoreInvalidApiKeyErrorsHint: 'When enabled, invalid or missing API key errors (INVALID_API_KEY, API_KEY_REQUIRED) will not be written to the error log.', - ignoreInsufficientBalanceErrors: 'Ignore Insufficient Balance Errors', - ignoreInsufficientBalanceErrorsHint: 'When enabled, insufficient account balance errors will not be written to the error log.', - autoRefresh: 'Auto Refresh', - enableAutoRefresh: 'Enable auto refresh', - enableAutoRefreshHint: 'Automatically refresh dashboard data at a fixed interval.', - refreshInterval: 'Refresh Interval', - refreshInterval15s: '15 seconds', - refreshInterval30s: '30 seconds', - refreshInterval60s: '60 seconds', - dashboardCards: 'Dashboard Cards', - displayAlertEvents: 'Display alert events', - displayAlertEventsHint: 'Show or hide the recent alert events card on the ops dashboard. Enabled by default.', - displayOpenAITokenStats: 'Display OpenAI token request stats', - displayOpenAITokenStatsHint: 'Show or hide the OpenAI token request stats card on the ops dashboard. Hidden by default.', - autoRefreshCountdown: 'Auto refresh: {seconds}s', - validation: { - title: 'Please fix the following issues', - retentionDaysRange: 'Retention days must be between 0 and 365 (0 = wipe all on every cleanup)', - slaMinPercentRange: 'SLA minimum percentage must be between 0 and 100', - ttftP99MaxRange: 'TTFT P99 maximum must be a number ≥ 0', - requestErrorRateMaxRange: 'Request error rate maximum must be between 0 and 100', - upstreamErrorRateMaxRange: 'Upstream error rate maximum must be between 0 and 100', - openaiQuotaAutoPauseRange: 'OpenAI quota auto-pause threshold must be between 0 and 100' - } - }, - concurrency: { - title: 'Concurrency / Queue', - byPlatform: 'By Platform', - byGroup: 'By Group', - byAccount: 'By Account', - byUser: 'By User', - showByUserTooltip: 'Switch to user view to see concurrency usage per user', - switchToUser: 'Switch to user view', - switchToPlatform: 'Switch to platform view', - totalRows: '{count} rows', - disabledHint: 'Realtime monitoring is disabled in settings.', - empty: 'No data', - queued: 'Queue {count}', - rateLimited: 'Rate-limited {count}', - errorAccounts: 'Errors {count}', - loadFailed: 'Failed to load concurrency data' - }, - realtime: { - title: 'Realtime', - connected: 'Realtime connected', - connecting: 'Realtime connecting', - reconnecting: 'Realtime reconnecting', - offline: 'Realtime offline', - closed: 'Realtime closed', - reconnectIn: 'retry in {seconds}s' - }, - queryMode: { - auto: 'Auto', - raw: 'Raw', - preagg: 'Preagg' - }, - accountAvailability: { - available: 'Available', - unavailable: 'Unavailable', - accountError: 'Error' - }, - tooltips: { - totalRequests: 'Total number of requests (including both successful and failed requests) in the selected time window.', - throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.', - switchRateTrend: 'Trend of account switches / total requests over the last 5 hours (avg switches).', - latencyHistogram: 'Request duration distribution (ms) for successful requests.', - errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).', - errorDistribution: 'Error distribution by status code (SLA scope, excluding business limits).', - goroutines: - 'Number of Go runtime goroutines (lightweight threads). There is no absolute "safe" number—use your historical baseline. Heuristic: <2k is common; 2k–8k watch; >8k plus rising queue/latency often suggests blocking/leaks.', - cpu: 'CPU usage percentage, showing system processor load.', - memory: 'Memory usage, including used and total available memory.', - db: 'Database connection pool status, including active, idle, and waiting connections.', - redis: 'Redis connection pool status, showing active and idle connections.', - jobs: 'Background job execution status, including last run time, success time, and error information.', - qps: 'Queries Per Second (QPS) and Tokens Per Second (TPS), real-time system throughput.', - tokens: 'Total number of tokens processed in the current time window.', - sla: 'Service Level Agreement success rate, excluding business limits (e.g., insufficient balance, quota exceeded).', - errors: 'Error statistics, including total errors, error rate, and upstream error rate.', - upstreamErrors: 'Upstream error statistics, excluding rate limit errors (429/529).', - latency: 'Request duration statistics, including p50, p90, p95, p99 percentiles.', - ttft: 'Time To First Token, measuring the speed of first token return in streaming responses.', - health: 'System health score (0-100), considering SLA, error rate, and resource usage.' - }, - charts: { - emptyRequest: 'No requests in this window.', - emptyError: 'No errors in this window.', - resetZoom: 'Reset', - resetZoomHint: 'Reset zoom (if enabled)', - downloadChart: 'Download', - downloadChartHint: 'Download chart as image' - } - }, - - // Settings - settings: { - title: 'System Settings', - description: 'Manage registration, email verification, default values, and SMTP settings', - tabs: { - general: 'General', - agreement: 'Agreement', - features: 'Feature Switches', - security: 'Security', - users: 'Users', - gateway: 'Gateway', - email: 'Email', - backup: 'Backup', - payment: 'Payment', - }, - features: { - channelMonitor: { - title: 'Channel Monitor', - description: 'Periodically probe configured channels and surface availability / latency to users. Turning it off stops the scheduler and returns an empty list on the user page.', - configureLink: 'Configure monitors in Channel Management > Channel Monitor', - enabled: 'Enable Channel Monitor', - enabledHint: 'Disabling stops background checks; existing history is preserved.', - defaultInterval: 'Default check interval (seconds)', - defaultIntervalHint: 'Pre-fills the interval when creating a new monitor; each monitor can override it. Range 15 – 3600.', - }, - availableChannels: { - title: 'Available Channels', - description: 'Show logged-in users an aggregate view of the channels, models and pricing they can access. Disabled by default.', - configureLink: 'Configure model pricing in Channel Management > Channel Pricing', - enabled: 'Enable Available Channels', - enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.', - }, - riskControl: { - title: 'Risk Control', - description: 'Enable the content moderation menu and gateway audit entry point. Disabled by default.', - configureLink: 'Configure content moderation in Risk Control', - enabled: 'Enable Risk Control', - enabledHint: 'When off, the admin sidebar entry is hidden and gateway moderation is skipped.', - cyberSessionBlock: 'Cyber session auto-block', - cyberSessionBlockHint: 'When enabled, sessions hit by upstream cyber_policy are blocked locally for the TTL and no longer forwarded. Only the offending session is blocked; other sessions on the same key are unaffected.', - cyberSessionBlockTTL: 'Block TTL (seconds)', - }, - affiliate: { - title: 'Affiliate (Invite Rebate)', - description: 'Existing users invite new ones; the inviter earns a percentage rebate on the invitee’s recharges. Disabled by default.', - enabled: 'Enable Affiliate', - enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.', - rebateRate: 'Global Rebate Rate', - rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).', - freezeHours: 'Rebate Freeze Period (hours)', - freezeHoursDesc: 'New rebates will be frozen for this period before becoming available for withdrawal. 0 = no freeze.', - durationDays: 'Rebate Duration (days)', - durationDaysDesc: 'Rebate relationship expires after this many days since invitee registration. 0 = permanent.', - perInviteeCap: 'Per-Invitee Rebate Cap', - perInviteeCapDesc: 'Maximum total rebate from a single invitee. 0 = no limit.', - customUsers: { - title: 'Per-User Overrides', - description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.', - addButton: 'Add Custom User', - searchPlaceholder: 'Search by email or username', - batchButton: 'Batch Set Rate ({count} selected)', - empty: 'No users with custom affiliate settings yet', - customBadge: 'custom', - useGlobal: 'use global', - resetTitle: 'Reset Custom Settings', - resetMessage: 'Reset all custom settings for {email}?\n• The exclusive rebate rate will be cleared (fall back to the global rate)\n• The invite code will be regenerated as a new system code (previously shared links will stop working)', - totalLabel: '{total} total', - col: { - email: 'Email', - username: 'Username', - code: 'Invite Code', - rate: 'Custom Rate', - actions: 'Actions', - }, - }, - modal: { - addTitle: 'Add Custom User', - editTitle: 'Edit Custom Settings', - userLabel: 'User', - userPlaceholder: 'Search by email or username', - changeUser: 'Change user', - codeLabel: 'Custom Invite Code (optional)', - codePlaceholder: 'e.g. VIP2026', - codeHint: '4-32 characters; A-Z, 0-9, underscore, dash. Leave empty to keep current. Input is upper-cased.', - rateLabel: 'Exclusive Rebate Rate (optional)', - ratePlaceholder: 'e.g. 30', - rateHint: '0-100. Leave empty (in edit mode) to clear and fall back to the global rate.', - errorBadRate: 'Please enter a number between 0 and 100', - errorEmpty: 'Fill at least one: custom invite code or exclusive rebate rate', - }, - batchModal: { - title: 'Batch Set Rate ({count} users selected)', - hint: 'Apply the same exclusive rebate rate to all selected users.', - placeholder: 'e.g. 30', - clearHint: 'Submitting empty will clear the exclusive rate for selected users.', - }, - }, - }, - emailTabDisabledTitle: 'Email Verification Not Enabled', - emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.', - registration: { - title: 'Registration Settings', - description: 'Control user registration and verification', - enableRegistration: 'Enable Registration', - enableRegistrationHint: 'Allow new users to register', - emailVerification: 'Email Verification', - emailVerificationHint: 'Require email verification for new registrations', - emailSuffixWhitelist: 'Email Domain Whitelist', - emailSuffixWhitelistHint: - "Only email addresses from the specified domains can register (for example, {'@'}qq.com, {'@'}gmail.com, *.edu.cn)", - emailSuffixWhitelistPlaceholder: "{'@'}example.com, *.edu.cn", - emailSuffixWhitelistInputHint: 'Leave empty for no restriction. Use *.edu.cn to match edu.cn and its subdomains.', - promoCode: 'Promo Code', - promoCodeHint: 'Allow users to use promo codes during registration', - invitationCode: 'Invitation Code Registration', - invitationCodeHint: 'When enabled, users must enter a valid invitation code to register', - passwordReset: 'Password Reset', - passwordResetHint: 'Allow users to reset their password via email', - frontendUrl: 'Frontend URL', - frontendUrlPlaceholder: 'https://example.com', - frontendUrlHint: 'Used to generate password reset links in emails. Example: https://example.com', - totp: 'Two-Factor Authentication (2FA)', - totpHint: 'Allow users to use authenticator apps like Google Authenticator', - totpKeyNotConfigured: - 'Please configure TOTP_ENCRYPTION_KEY in environment variables first. Generate a key with: openssl rand -hex 32' - }, - turnstile: { - title: 'Cloudflare Turnstile', - description: 'Bot protection for login and registration', - enableTurnstile: 'Enable Turnstile', - enableTurnstileHint: 'Require Cloudflare Turnstile verification', - siteKey: 'Site Key', - secretKey: 'Secret Key', - siteKeyHint: 'Get this from your Cloudflare Dashboard', - cloudflareDashboard: 'Cloudflare Dashboard', - secretKeyHint: 'Server-side verification key (keep this secret)', - secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' - }, - apiKeyAcl: { - title: 'API Key IP Access Control', - description: 'Choose which client IP is used by API Key allowlists and denylists', - trustForwardedIp: 'Trust forwarded client IP', - trustForwardedIpHint: - 'Disabled by default. Enable only when the origin is reachable only through Cloudflare or Nginx reverse proxy. When enabled, API Key IP allowlists and denylists use CF-Connecting-IP, X-Real-IP, or X-Forwarded-For, matching the request IP shown in usage records.' - }, - linuxdo: { - title: 'LinuxDo Connect Login', - description: 'Configure LinuxDo Connect OAuth for Sub2API end-user login', - enable: 'Enable LinuxDo Login', - enableHint: 'Show LinuxDo login on the login/register pages', - clientId: 'Client ID', - clientIdPlaceholder: 'e.g., hprJ5pC3...', - clientIdHint: 'Get this from Connect.Linux.Do', - clientSecret: 'Client Secret', - clientSecretPlaceholder: '********', - clientSecretHint: 'Used by backend to exchange tokens (keep it secret)', - clientSecretConfiguredPlaceholder: '********', - clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', - redirectUrl: 'Redirect URL', - redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/linuxdo/callback', - redirectUrlHint: - 'Must match the redirect URL configured in Connect.Linux.Do (must be an absolute http(s) URL)', - quickSetCopy: 'Generate & Copy (current site)', - redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard' - }, - dingtalk: { - title: 'DingTalk Login', - description: 'Configure DingTalk OAuth for Sub2API end-user login', - enable: 'Enable DingTalk Login (Internal Corporate App)', - enableHint: 'Show DingTalk login on the login/register pages', - clientId: 'Client ID (AppKey)', - clientIdPlaceholder: 'e.g., dingxxxxxxxxxxxxxxxx', - clientIdHint: 'Get this from the DingTalk Open Platform app details', - clientSecret: 'Client Secret (AppSecret)', - clientSecretPlaceholder: '********', - clientSecretHint: 'Used by backend to exchange tokens (keep it secret)', - clientSecretConfiguredPlaceholder: '********', - clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', - redirectUrl: 'Redirect URL', - redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/dingtalk/callback', - redirectUrlHint: - 'Must match the redirect URL configured in DingTalk Open Platform (must be an absolute http(s) URL)', - corpPolicy: { - label: 'Corp Restriction Policy', - hint: 'Control which DingTalk accounts (orgs) are allowed to sign in', - none: 'No restriction (all DingTalk accounts allowed)', - internalOnly: 'Internal only (single corp)' - }, - bypassRegistration: 'Enable DingTalk signup', - bypassRegistrationHint: 'Allow new users to register via DingTalk even when public registration is disabled.', - syncDisplayName: 'Sync DingTalk display name', - syncDisplayNameHint: 'Overwrite username with the DingTalk staff name on each login (also stored in the dingtalk_name attribute).', - syncCorpEmail: 'Sync corporate email', - syncCorpEmailHint: 'Write the DingTalk corporate email to the dingtalk_email attribute on each login (does not change the login email).', - syncCorpEmailPermissionHint: 'Requires the OAPI permission "Personal info incl. email (fieldEmail)" to be granted to the app on the DingTalk open platform, otherwise OAPI will not return the email field.', - syncDept: 'Sync department', - syncDeptHint: 'Write the full DingTalk department path to the dingtalk_department attribute on each login (fetched live each time).', - syncDeptPermissionHint: 'Requires the OAPI "Department info read (qyapi_get_department_list)" permission to be granted to the app on the DingTalk open platform, otherwise the department path cannot be resolved.', - syncDisplayNameTarget: 'Attribute key', - syncDisplayNameTargetHint: 'Defaults to dingtalk_name / DingTalk Name. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).', - syncCorpEmailTarget: 'Attribute key', - syncCorpEmailTargetHint: 'Defaults to dingtalk_email / DingTalk Corporate Email. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).', - syncDeptTarget: 'Attribute key', - syncDeptTargetHint: 'Defaults to dingtalk_department / DingTalk Department. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).', - syncAttrDisplayName: 'Display name' - }, - oidc: { - title: 'OIDC Login', - description: 'Configure a standard OIDC provider (for example Keycloak)', - enable: 'Enable OIDC Login', - enableHint: 'Show OIDC login on the login/register pages', - providerName: 'Provider Name', - providerNamePlaceholder: 'for example Keycloak', - clientId: 'Client ID', - clientIdPlaceholder: 'OIDC client id', - clientSecret: 'Client Secret', - clientSecretPlaceholder: '********', - clientSecretHint: 'Used by backend to exchange tokens (keep it secret)', - clientSecretConfiguredPlaceholder: '********', - clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', - issuerUrl: 'Issuer URL', - issuerUrlPlaceholder: 'https://id.example.com/realms/main', - discoveryUrl: 'Discovery URL', - discoveryUrlPlaceholder: 'Optional, leave empty to auto-derive from issuer', - authorizeUrl: 'Authorize URL', - authorizeUrlPlaceholder: 'Optional, can be discovered automatically', - tokenUrl: 'Token URL', - tokenUrlPlaceholder: 'Optional, can be discovered automatically', - userinfoUrl: 'UserInfo URL', - userinfoUrlPlaceholder: 'Optional, can be discovered automatically', - jwksUrl: 'JWKS URL', - jwksUrlPlaceholder: 'Optional, required when strict ID token validation is enabled', - scopes: 'Scopes', - scopesPlaceholder: 'openid email profile', - scopesHint: 'Must include openid', - redirectUrl: 'Backend Redirect URL', - redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback', - redirectUrlHint: 'Must match the callback URL configured in the OIDC provider', - quickSetCopy: 'Generate & Copy (current site)', - redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard', - frontendRedirectUrl: 'Frontend Callback Path', - frontendRedirectUrlPlaceholder: '/auth/oidc/callback', - frontendRedirectUrlHint: 'Frontend route used after backend callback', - tokenAuthMethod: 'Token Auth Method', - clockSkewSeconds: 'Clock Skew (seconds)', - allowedSigningAlgs: 'Allowed Signing Algs', - allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256', - usePkce: 'Use PKCE', - validateIdToken: 'Validate ID Token', - requireEmailVerified: 'Require Email Verified', - userinfoEmailPath: 'UserInfo Email Path', - userinfoEmailPathPlaceholder: 'for example data.email', - userinfoIdPath: 'UserInfo ID Path', - userinfoIdPathPlaceholder: 'for example data.id', - userinfoUsernamePath: 'UserInfo Username Path', - userinfoUsernamePathPlaceholder: 'for example data.username' - }, - defaults: { - title: 'Default User Settings', - description: 'Default values for new users', - defaultBalance: 'Default Balance', - defaultBalanceHint: 'Initial balance for new users', - affiliateRebateRate: 'Affiliate Rebate Rate', - affiliateRebateRateHint: - 'Rebate percentage credited to inviter after recharge (0-100%, e.g. 10 means 10%)', - defaultConcurrency: 'Default Concurrency', - defaultConcurrencyHint: 'Maximum concurrent requests for new users', - defaultUserRpmLimit: 'Default User RPM Limit', - defaultUserRpmLimitHint: 'Default max requests per minute for new users; 0 = unlimited. Only applied at new user creation.', - defaultSubscriptions: 'Default Subscriptions', - defaultSubscriptionsHint: 'Auto-assign these subscriptions when a new user is created or registered', - addDefaultSubscription: 'Add Default Subscription', - defaultSubscriptionsEmpty: 'No default subscriptions configured.', - defaultSubscriptionsDuplicate: - 'Duplicate subscription group: {groupId}. Each group can only appear once.', - subscriptionGroup: 'Subscription Group', - subscriptionValidityDays: 'Validity (days)', - defaultPlatformQuotas: 'Default Platform Quotas (on signup)', - defaultPlatformQuotasHint: 'Automatically assigned to new users on signup; existing users are not affected. Leave blank = unlimited.', - platformQuotaNotice: 'Monthly quota uses a 30-day rolling window, not a calendar month.', - }, - platformQuota: { - platform: 'Platform', - daily: 'Daily (USD)', - weekly: 'Weekly (USD)', - monthly: 'Monthly (USD, 30d rolling)', - placeholder: 'Unlimited', - }, - claudeCode: { - title: 'Claude Code Settings', - description: 'Control Claude Code client access requirements', - minVersion: 'Minimum Version', - minVersionPlaceholder: 'e.g. 2.1.63', - minVersionHint: - 'Reject Claude Code clients below this version (semver format). Leave empty to disable version check.', - maxVersion: 'Maximum Version', - maxVersionPlaceholder: 'e.g. 2.5.0', - maxVersionHint: - 'Reject Claude Code clients above this version (semver format). Leave empty to allow any version.' - }, - scheduling: { - title: 'Gateway Scheduling Settings', - description: 'Control API Key scheduling behavior', - allowUngroupedKey: 'Allow Ungrouped Key Scheduling', - allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.' - }, - gatewayForwarding: { - title: 'Request Forwarding', - description: 'Control how requests are forwarded to upstream OAuth accounts', - fingerprintUnification: 'Fingerprint Unification', - fingerprintUnificationHint: 'Unify X-Stainless-* headers across users sharing the same OAuth account. Disabling passes through each client\'s original headers.', - metadataPassthrough: 'Metadata Passthrough', - metadataPassthroughHint: 'Pass through client\'s original metadata.user_id without rewriting. May improve upstream cache hit rates.', - cchSigning: 'CCH Signing', - cchSigningHint: 'Sign the billing header in forwarded requests with CCH hash. When disabled, the placeholder is preserved.', - claudeOAuthSystemPromptInjection: 'Claude OAuth System Blocks', - claudeOAuthSystemPromptInjectionHint: 'Inject Claude Code-like system blocks for Claude OAuth requests from non-Claude-Code clients. Enabled by default.', - claudeOAuthSystemPrompt: 'Claude OAuth Expansion Prompt', - claudeOAuthSystemPromptPlaceholder: 'Leave empty to use the built-in Claude Code expansion prompt.', - claudeOAuthSystemPromptHint: 'Legacy compatibility: controls only the third injected system block.', - claudeOAuthSystemPromptBlocks: 'Claude OAuth System Blocks', - claudeOAuthSystemPromptBlocksPlaceholder: 'Leave empty to use the built-in 3 blocks. Supports an array or {"blocks": [...]}.', - claudeOAuthSystemPromptBlocksHint: 'Each block is saved as JSON with enabled, type, text, and optional cache_control. {billing_header} stays dynamic per request; the Claude Code identity and expansion prompts can be edited directly or restored from presets.', - systemBlockTitle: 'System Block {index}', - systemBlockPreset: 'Preset', - systemBlockPresetBilling: 'Billing header', - systemBlockPresetIdentity: 'Claude Code identity', - systemBlockPresetExpansion: 'Claude Code expansion', - systemBlockPresetCustom: 'Custom', - systemBlockType: 'Type', - systemBlockTypeText: 'Text', - systemBlockText: 'Content', - systemBlockCacheControl: 'Cache control', - systemBlockHide: 'Hide block details', - systemBlockShow: 'Show block details', - addSystemBlock: 'Add block', - resetSystemBlocks: 'Reset defaults', - cacheTTL5m: '5 minutes', - cacheTTL1h: '1 hour', - anthropicCacheTTL1hInjection: 'Anthropic Cache TTL Injection', - anthropicCacheTTL1hInjectionHint: 'When enabled, existing ephemeral cache_control blocks in Anthropic OAuth/Setup Token request bodies are forced to 1h; response usage is billed back as 5m by default, with account-level TTL billing override taking priority.', - rewriteMessageCacheControl: 'Rewrite Message Cache Breakpoints', - rewriteMessageCacheControlHint: 'Default off: preserve client cache_control on message content blocks. When enabled, client breakpoints are stripped and proxy breakpoints are injected for clients that do not manage caching themselves.', - clientDatelineNormalization: 'Client Dateline Normalization', - clientDatelineNormalizationHint: 'Default on. Rewrites the "Today\'s date is …" sentence in Anthropic OAuth/Setup Token requests back to a canonical ASCII apostrophe and hyphen date format, erasing steganographic fingerprint bits some clients inject when they detect a non-official base URL. Applies to system prompts and blocks only; API-Key accounts are unaffected.', - antigravityUserAgentVersion: 'Antigravity UA Version', - antigravityUserAgentVersionPlaceholder: '1.23.2', - antigravityUserAgentVersionHint: 'Leave empty to use ANTIGRAVITY_USER_AGENT_VERSION or the built-in default 1.23.2; when set, the admin setting takes precedence.', - openaiCodexUserAgent: 'OpenAI Codex UA', - openaiCodexUserAgentPlaceholder: 'codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)', - openaiCodexUserAgentHint: 'Used to bypass Cloudflare browser-UA challenges on the OpenAI upstream. Only applies when the client User-Agent is detected as a browser (Mozilla/...). Leave empty to use the built-in default.', - codexHardeningTitle: "Codex Settings", - codexClientRestrictionTitle: "Codex client restriction", - codexHardeningDesc: - "Only affects OpenAI OAuth accounts with 'Codex official clients only' enabled (global). Beyond User-Agent/Originator, harden the decision with a version range, an engine-fingerprint gate, and black/whitelists.", - minCodexVersion: "Min Codex Version", - minCodexVersionPlaceholder: "e.g. 0.142.0", - maxCodexVersion: "Max Codex Version", - maxCodexVersionPlaceholder: "e.g. 0.200.0", - codexVersionHint: - "Official clients only: checks their version against the [min, max] range. Leave a side empty to not limit it.", - codexFingerprintSignals: "Codex engine fingerprint signals", - codexFingerprintSignalsDesc: - "Define engine-fingerprint signals: every Required signal must match (AND); within a row, '/'-separated variants are OR'd. None checked = not enforced. Default checks only the x-codex- prefix. Types: header exact / header prefix / body path.", - codexFpTypeHeaderExact: "Header exact", - codexFpTypeHeaderPrefix: "Header prefix", - codexFpTypeBodyPath: "Body path", - codexFpMatchPlaceholder: "match; '/'-separate variants (e.g. session-id / session_id or x-codex-)", - codexFpRequired: "Required", - codexFingerprintNoRequiredWarn: "No signal is marked Required — the engine-fingerprint gate is inactive, allowing every candidate that passes identity/version. Check at least one signal to enable it.", - codexAllowAppServer: "Codex app-server", - codexAllowAppServerDesc: - "Allow third-party clients that embed the Codex engine and connect over the app-server protocol (e.g. Claude Code's codex plugin). Off by default; when on, such clients are allowed once they pass the engine-fingerprint gate (the signal list below); off = only official clients and the whitelist are allowed.", - codexBlacklist: "User-Agent/Originator Blacklist", - codexBlacklistDesc: - "Deny if any field matches; takes precedence over any allow. originator is exact; User-Agent is a 'contains' match (comma-separated).", - codexWhitelist: "User-Agent/Originator Whitelist", - codexWhitelistDesc: - "Allow clients outside the official set: requires exact originator and every User-Agent marker present. Still subject to the fingerprint gate unless 'Skip engine fingerprint' is checked.", - codexWhitelistSkipFingerprint: "Skip engine fingerprint", - codexWhitelistSkipFingerprintTooltip: - "Risk: when checked this entry is allowed on originator + User-Agent alone (both forgeable), with no engine-fingerprint backstop. Use only for trusted third-party clients that genuinely do not send a codex engine fingerprint.", - codexOriginatorPlaceholder: "originator (exact, e.g. opencode)", - codexUaContainsPlaceholder: "User-Agent contains markers, comma-separated (e.g. opencode/)", - codexAddRow: "Add entry", - codexRemoveRow: "Remove", - }, - webSearchEmulation: { - title: 'Web Search Emulation', - description: 'Inject web search capability for Anthropic API Key accounts that don\'t natively support it', - enabled: 'Enable Web Search Emulation', - enabledHint: 'Global switch. When disabled, web search emulation is inactive for all channels and accounts.', - providers: 'Search Providers', - addProvider: 'Add Provider', - providerType: 'Provider Type', - apiKey: 'API Key', - apiKeyPlaceholder: 'Enter API Key', - apiKeyConfigured: 'Configured', - showApiKey: 'Show', - hideApiKey: 'Hide', - copyApiKey: 'Copy', - copied: 'Copied', - quotaLimit: 'Quota Limit', - quotaLimitHint: 'Leave empty for unlimited; must be > 0 if set', - quotaLimitMustBePositive: 'Quota limit must be greater than 0', - subscribedAt: 'Subscribed At', - subscribedAtHint: 'Quota resets monthly from this date; leave empty to disable auto-reset', - quotaUsage: 'Usage', - resetUsage: 'Reset', - resetUsageConfirm: 'Reset usage counter for this provider?', - resetUsageSuccess: 'Usage counter reset', - proxy: 'Proxy', - removeProvider: 'Remove', - noProviders: 'No search providers configured', - test: 'Test', - testDefaultQuery: 'Major world events this year', - testing: 'Searching...', - testResultTitle: 'Search Results', - testResultProvider: 'Provider', - testNoResults: 'No results found', - }, - site: { - title: 'Site Settings', - description: 'Customize site branding', - backendMode: 'Backend Mode', - backendModeDescription: - 'Disables user registration, public site, and self-service features. Only admin can log in and manage the platform.', - siteName: 'Site Name', - siteNamePlaceholder: 'Sub2API', - siteNameHint: 'Displayed in emails and page titles', - siteSubtitle: 'Site Subtitle', - siteSubtitlePlaceholder: 'Subscription to API Conversion Platform', - siteSubtitleHint: 'Displayed on login and register pages', - apiBaseUrl: 'API Base URL', - apiBaseUrlPlaceholder: 'https://api.example.com', - apiBaseUrlHint: - 'Used for "Use Key", "Import to CC Switch", and callback URL suggestions. Leave empty to use current site URL.', - tablePreferencesTitle: 'Global Table Preferences', - tablePreferencesDescription: 'Configure default pagination behavior for shared table components', - tableDefaultPageSize: 'Default Rows Per Page', - tableDefaultPageSizeHint: 'Must be an integer between 5 and 1000', - tablePageSizeOptions: 'Rows Per Page Options', - tablePageSizeOptionsPlaceholder: '10, 20, 50, 100', - tablePageSizeOptionsHint: 'Use commas to separate integers between 5 and 1000; values are deduplicated and sorted on save', - tableDefaultPageSizeRangeError: 'Default rows per page must be between {min} and {max}', - tablePageSizeOptionsFormatError: 'Invalid options format. Enter comma-separated integers between {min} and {max}', - customEndpoints: { - title: 'Custom Endpoints', - description: 'Add additional API endpoint URLs for users to quickly copy on the API Keys page', - itemLabel: 'Endpoint #{n}', - name: 'Name', - namePlaceholder: 'e.g., OpenAI Compatible', - endpointUrl: 'Endpoint URL', - endpointUrlPlaceholder: 'https://api2.example.com', - descriptionLabel: 'Description', - descriptionPlaceholder: 'e.g., Supports OpenAI format requests', - add: 'Add Endpoint', - }, - contactInfo: 'Contact Info', - contactInfoPlaceholder: 'e.g., QQ: 123456789', - contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.', - docUrl: 'Documentation URL', - docUrlPlaceholder: 'https://docs.example.com', - docUrlHint: 'Link to your documentation site. Leave empty to hide the documentation link.', - siteLogo: 'Site Logo', - uploadImage: 'Upload Image', - remove: 'Remove', - logoHint: 'PNG, JPG, or SVG. Max 300KB. Recommended: 80x80px square image.', - logoSizeError: 'Image size exceeds 300KB limit ({size}KB)', - logoTypeError: 'Please select an image file', - logoReadError: 'Failed to read the image file', - homeContent: 'Home Page Content', - homeContentPlaceholder: 'Enter custom content for the home page. Supports Markdown & HTML. If a URL is entered, it will be displayed as an iframe.', - homeContentHint: 'Customize the home page content. Supports Markdown/HTML. If you enter a URL (starting with http:// or https://), it will be used as an iframe src to embed an external page. When set, the default status information will no longer be displayed.', - homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.', - hideCcsImportButton: 'Hide CCS Import Button', - hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page' - }, - purchase: { - title: 'Recharge / Subscription Page', - description: 'Show a "Recharge / Subscription" entry in the sidebar and open the configured URL in an iframe', - enabled: 'Show Recharge / Subscription Entry', - enabledHint: 'Only shown in standard mode (not simple mode)', - url: 'Recharge / Subscription URL', - urlPlaceholder: 'https://example.com/purchase', - urlHint: 'Must be an absolute http(s) URL', - iframeWarning: - '⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.', - integrationDoc: 'Payment Integration Docs', - integrationDocHint: 'Covers endpoint specs, idempotency semantics, and code samples' - }, - soraClient: { - title: 'Sora Client', - description: 'Control whether to show the Sora client entry in the sidebar', - enabled: 'Enable Sora Client', - enabledHint: 'When enabled, the Sora entry will be shown in the sidebar for users to access Sora features' - }, - customMenu: { - title: 'Custom Menu Pages', - description: 'Add custom iframe pages to the sidebar navigation. Each page can be visible to regular users or administrators.', - itemLabel: 'Menu Item #{n}', - name: 'Menu Name', - namePlaceholder: 'e.g. Help Center', - url: 'Page URL', - urlPlaceholder: 'https://example.com/page', - iconSvg: 'SVG Icon', - iconSvgPlaceholder: '...', - iconPreview: 'Icon Preview', - uploadSvg: 'Upload SVG', - removeSvg: 'Remove', - visibility: 'Visible To', - visibilityUser: 'Regular Users', - visibilityAdmin: 'Administrators', - add: 'Add Menu Item', - remove: 'Remove', - moveUp: 'Move Up', - moveDown: 'Move Down', - }, - payment: { - title: 'Payment Settings', - description: 'Configure payment system options', - configGuide: 'Configuration Guide', - enabled: 'Enable Payment', - enabledHint: 'Enable or disable the payment system', - enabledPaymentTypes: 'Enabled Providers', - enabledPaymentTypesHint: 'Disabling a provider will also disable its instances.', - findProvider: 'Looking for a suitable EasyPay provider?', - minAmount: 'Minimum Amount', - maxAmount: 'Maximum Amount', - dailyLimit: 'Daily Limit', - balanceRechargeMultiplier: 'Balance Recharge Multiplier', - balanceRechargeMultiplierHint: 'How many USD balance the user receives for each 1 CNY paid', - balanceRechargePreview: 'Preview: 1 CNY = {usd} USD', - subscriptionUsdToCnyRate: 'Subscription USD to CNY Rate', - subscriptionUsdToCnyRateHint: - 'CNY charged per 1 USD of plan price on CNY channels (e.g. 7.15). 0 or empty = disabled, plan price is charged as-is. When enabled, all plan prices must be set in USD', - subscriptionUsdToCnyRateDisabled: 'Disabled (price charged as-is)', - rechargeFeeRate: 'Recharge Fee Rate', - rechargeFeeRateHint: 'Percentage of service fee charged on top of recharge amount, 0 means no fee', - rechargeFeePreview: 'Preview: Recharge 100, fee {fee}', - orderTimeout: 'Order Timeout', - orderTimeoutHint: 'In minutes, minimum 1', - maxPendingOrders: 'Max Pending Orders', - cancelRateLimit: 'Limit Cancel Rate', - cancelRateLimitHint: 'When enabled, users who exceed the cancel limit within the time window cannot create new orders', - cancelRateLimitEvery: 'Every', - cancelRateLimitAllowMax: 'allow max', - cancelRateLimitTimes: 'cancels', - cancelRateLimitWindow: 'Window', - cancelRateLimitUnit: 'Unit', - cancelRateLimitMax: 'Max Cancels', - cancelRateLimitUnitMinute: 'Minutes', - cancelRateLimitUnitHour: 'Hours', - cancelRateLimitUnitDay: 'Days', - cancelRateLimitWindowMode: 'Window Mode', - cancelRateLimitWindowModeRolling: 'Rolling', - cancelRateLimitWindowModeFixed: 'Fixed', - alipayForceQRCode: 'Force Alipay QR Code', - alipayForceQRCodeHint: 'When enabled, mobile Alipay users always see a QR code instead of being redirected to the mobile payment page', - helpText: 'Help Text', - helpImageUrl: 'Help Image URL', - manageProviders: 'Manage Providers', - balancePaymentDisabled: 'Disable Balance Recharge', - noLimit: 'Empty = no limit', - helpImage: 'Help Image', - helpImagePlaceholder: 'Upload or enter image URL', - helpTextPlaceholder: 'Enter help text...', - providerEasypay: 'EasyPay', - providerAlipay: 'Alipay (Direct)', - providerWxpay: 'WeChat Pay (Direct)', - providerStripe: 'Stripe', - providerAirwallex: 'Airwallex', - typeDisabled: 'type disabled', - enableTypesFirst: 'Enable at least one payment type above first', - easypayRedirect: 'Redirect', - paymentMode: 'Payment Mode', - modeRedirect: 'Redirect', - modeQRCode: 'QR Code', - modePopup: 'Popup', - validationNameRequired: 'Provider name is required', - validationTypesRequired: 'Please select at least one supported payment type', - validationFieldRequired: '{field} is required', - validationEasyPayCustomMethodRequired: 'Each custom EasyPay method requires both a payment type and an upstream type', - validationEasyPayCustomMethodTypeInvalid: 'Custom EasyPay payment types may only contain lowercase letters, digits, underscores, and hyphens', - validationEasyPayCustomMethodUpstreamTypeInvalid: 'EasyPay upstream types may only contain lowercase letters, digits, underscores, and hyphens', - validationEasyPayCustomMethodReserved: 'Custom EasyPay payment types cannot use built-in alipay or wxpay', - validationEasyPayCustomMethodPrefixReserved: 'Custom EasyPay payment types cannot start with alipay or wxpay', - validationEasyPayCustomMethodDuplicate: 'Custom EasyPay payment types must be unique', - field_apiBase: 'API Base URL', - field_notifyUrl: 'Notify URL', - field_returnUrl: 'Return URL', - callbackBaseUrl: 'Callback Base URL', - field_privateKey: 'Private Key', - field_publicKey: 'Public Key', - field_mpAppId: 'MP App ID', - field_mchId: 'Merchant ID', - field_apiV3Key: 'API v3 Key', - field_publicKeyId: 'Public Key ID', - field_certSerial: 'Certificate Serial', - field_h5AppName: 'H5 App Name', - field_h5AppUrl: 'H5 App URL', - wxpayConfigHint: 'WeChat Pay usually only needs App ID. Fill MP App ID, H5 App Name, and H5 App URL only when your Official Account or H5 flow specifically requires them.', - wxpayAdvancedOptions: 'WeChat Pay Advanced Options', - field_secretKey: 'Secret Key', - field_clientId: 'Client ID', - field_apiKey: 'API Key', - field_publishableKey: 'Publishable Key', - field_webhookSecret: 'Webhook Secret', - field_countryCode: 'Country/region code', - field_currency: 'Payment currency', - field_accountId: 'Airwallex Account ID', - field_airwallexApiBaseHint: 'Must match the API key environment: use https://api-demo.airwallex.com/api/v1 for sandbox/demo keys, and https://api.airwallex.com/api/v1 for production keys. Mixed environments return credentials_invalid / Access Denied.', - field_paymentCurrencyHint: 'Default is CNY. Stripe and Airwallex can choose HKD, USD, or another listed currency supported by the account; WeChat Pay, Alipay, and EasyPay remain CNY.', - field_accountIdHint: 'Leave this empty unless you use multiple accounts, an organization-level key, or connected-account payments. A single-account scoped API key uses the selected account by default.', - field_cid: 'Channel ID', - field_cidAlipay: 'Alipay Channel ID', - field_cidWxpay: 'WeChat Channel ID', - easypayCustomMethods: 'Custom EasyPay methods', - easypayCustomMethodsHint: 'Add provider-specific methods supported by this EasyPay endpoint. The payment type is stored on Sub2API orders; the upstream type is sent as EasyPay type.', - addCustomMethod: 'Add method', - customMethodType: 'Payment type', - customMethodUpstreamType: 'Upstream type', - customMethodDisplayName: 'Display name', - stripeWebhookHint: 'Configure the following URL as a Webhook endpoint in Stripe Dashboard:', - stripeWebhookApiVersionHint: 'Set this Webhook endpoint API version to match the integrated Stripe SDK. Recommended: {version}. A mismatch can cause webhook parsing errors.', - airwallexWebhookHint: 'Configure the following URL as a Webhook endpoint in Airwallex. Select at least Payment Intent -> Succeeded (payment_intent.succeeded), preferably also Payment Intent -> Cancelled (payment_intent.cancelled). Use the account default or latest stable API version.', - airwallexGuideSummary: 'When creating an Airwallex scoped API key, select Read and Write for Payment Acceptance under account-level permissions.', - airwallexGuideNote: 'Do not grant unrelated permissions such as Spend, Payouts, Transfers, Funds Splits, or POS Terminals unless you explicitly need them. For webhooks, select at least payment_intent.succeeded, preferably also payment_intent.cancelled, and use the account default or latest stable API version.', - limitsTitle: 'Limits', - limitSingleMin: 'Min per order', - limitSingleMax: 'Max per order', - limitDaily: 'Daily limit', - limitsHint: 'All empty = use global config; partially filled = empty means no limit', - limitsUseGlobal: 'Use global', - limitsNoLimit: 'No limit', - productNamePrefix: 'Product Name Prefix', - productNameSuffix: 'Product Name Suffix', - preview: 'Preview', - loadBalanceStrategy: 'Load Balance Strategy', - strategyRoundRobin: 'Round Robin', - strategyLeastAmount: 'Least Daily Amount', - providerManagement: 'Provider Management', - providerManagementDesc: 'Manage payment provider instances', - createProvider: 'Add Provider', - editProvider: 'Edit Provider', - deleteProvider: 'Delete Provider', - deleteProviderConfirm: 'Are you sure you want to delete this provider?', - providerName: 'Provider Name', - providerKey: 'Provider Type', - selectProviderKey: 'Select Provider Type', - providerConfig: 'Credentials', - paymentGuideTrigger: 'View payment guide', - guideOpenLabel: 'Enable: ', - guideCallLabel: 'Call: ', - guideFallbackLabel: 'Fallback: ', - alipayGuideSummary: 'Desktop prefers QR precreate and falls back to cashier; mobile prefers WAP checkout.', - alipayGuideFaceToFaceTitle: 'Face-to-face / QR Payment', - alipayGuideFaceToFaceOpen: 'Enable face-to-face or QR payment capability.', - alipayGuideFaceToFaceCall: 'Desktop orders call alipay.trade.precreate first and render the QR code directly.', - alipayGuideFaceToFaceFallback: 'If unavailable or failed, the flow falls back to website checkout automatically.', - alipayGuidePagePayTitle: 'Website Payment', - alipayGuidePagePayOpen: 'Enable website payment.', - alipayGuidePagePayCall: 'When face-to-face is unavailable on desktop, the flow calls alipay.trade.page.pay and still renders the returned link as a QR code.', - alipayGuidePagePayFallback: 'The cashier link stays available so users can reopen the checkout page manually.', - alipayGuideWapTitle: 'WAP Payment', - alipayGuideWapOpen: 'Enable mobile website payment.', - alipayGuideWapCall: 'Mobile orders call alipay.trade.wap.pay first and jump to Alipay checkout.', - alipayGuideWapFallback: 'If mobile payment is unavailable or fails, the frontend switches to QR payment and shows a notice.', - wxpayGuideSummary: 'Desktop prefers Native QR; mobile routes to JSAPI or H5 based on browser context.', - wxpayGuideNote: 'The current form defaults to one shared App ID, which fits the common single-subject web, mobile, and Official Account setup.', - wxpayGuideNativeTitle: 'Native / QR Payment', - wxpayGuideNativeOpen: 'Enable Native or QR payment capability.', - wxpayGuideNativeCall: 'Desktop orders use Native by default and the frontend renders the QR payload.', - wxpayGuideNativeFallback: 'Mobile flows also fall back here when JSAPI or H5 cannot be used.', - wxpayGuideJsapiTitle: 'JSAPI / Official Account', - wxpayGuideJsapiOpen: 'Enable Official Account payment and ensure the browser is inside WeChat with an available OpenID.', - wxpayGuideJsapiCall: 'Inside WeChat, the app calls JSAPI after authorization and launches WeChat Pay directly.', - wxpayGuideJsapiFallback: 'If configuration is missing, the bridge is unavailable, or launch fails, the flow falls back to QR payment.', - wxpayGuideH5Title: 'H5 Payment', - wxpayGuideH5Open: 'Enable H5 payment.', - wxpayGuideH5Call: 'On mobile browsers outside WeChat, the app calls H5 payment when a client IP is available.', - wxpayGuideH5Fallback: 'If H5 is unavailable or order creation fails, the flow falls back to QR payment.', - noProviders: 'No provider instances configured', - supportedTypes: 'Supported Payment Types', - supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay', - refundEnabled: 'Allow Refund', - allowUserRefund: 'Allow User Refund', - enableConflict: '{method} already has an enabled provider instance: {provider}. Disable the existing instance before switching.', - }, - balanceNotify: { - title: 'Balance Low Notification', - description: 'Send email notification when user balance falls below threshold', - enabled: 'Enable Balance Low Notification', - threshold: 'Default Threshold', - thresholdHint: 'Used when user has not set a custom value', - thresholdPlaceholder: 'Enter amount', - rechargeUrl: 'Recharge Page URL', - rechargeUrlPlaceholder: 'https://example.com/payment', - rechargeUrlHint: 'A top-up button will appear in the email when set', - }, - quotaNotify: { - title: 'Account Quota Notification', - description: 'Notify admins when account quota usage reaches alert threshold', - enabled: 'Enable Account Quota Notification', - emails: 'Notification Emails', - emailsHint: 'Leave empty to disable notifications', - addEmail: 'Add Email', - emailPlaceholder: 'Enter email address', - }, - subscriptionExpiryNotify: { - title: 'Subscription Expiry Reminder', - description: 'Control whether users receive subscription expiry reminder emails.', - enabled: 'Enable Subscription Expiry Reminder', - enabledHint: 'When enabled, the system sends reminders 7, 3, and 1 day before expiry.' - }, - smtp: { - title: 'SMTP Settings', - description: 'Configure email sending for verification codes', - testConnection: 'Test Connection', - testing: 'Testing...', - host: 'SMTP Host', - hostPlaceholder: 'smtp.gmail.com', - port: 'SMTP Port', - portPlaceholder: '587', - username: 'SMTP Username', - usernamePlaceholder: "your-email{'@'}gmail.com", - password: 'SMTP Password', - passwordPlaceholder: '********', - passwordHint: 'Leave empty to keep existing password', - passwordConfiguredPlaceholder: '********', - passwordConfiguredHint: 'Password configured. Leave empty to keep the current value.', - fromEmail: 'From Email', - fromEmailPlaceholder: "noreply{'@'}example.com", - fromName: 'From Name', - fromNamePlaceholder: 'Sub2API', - useTls: 'Use TLS', - useTlsHint: 'Enable TLS encryption for SMTP connection' - }, - testEmail: { - title: 'Send Test Email', - description: 'Send a test email to verify your SMTP configuration', - recipientEmail: 'Recipient Email', - recipientEmailPlaceholder: "test{'@'}example.com", - sendTestEmail: 'Send Test Email', - sending: 'Sending...', - enterRecipientHint: 'Please enter a recipient email address' - }, - emailTemplates: { - title: 'Email Templates', - description: 'Customize notification email subjects and HTML content for each event and locale.', - event: 'Event', - locale: 'Locale', - localeEn: 'English', - localeZh: 'Chinese', - subject: 'Subject', - subjectPlaceholder: 'Enter the email subject', - html: 'HTML Template', - htmlPlaceholder: 'Edit the email HTML template', - placeholders: 'Available Placeholders', - placeholdersHelp: 'Click a placeholder to copy it. The backend replaces these values when sending emails.', - livePreview: 'Live Preview', - previewSecurityHint: 'Preview HTML is generated by the backend preview endpoint and displayed in a sandboxed iframe with scripts disabled.', - preview: 'Preview / Refresh', - previewing: 'Previewing...', - save: 'Save Template', - saving: 'Saving...', - restoreOfficial: 'Restore Official', - restoring: 'Restoring...', - restoreConfirm: 'Restore the official template for this event and locale? Your custom version will be replaced.', - restoreSuccess: 'Official template restored', - saveSuccess: 'Email template saved', - placeholderCopied: 'Placeholder copied', - validationRequired: 'Subject and HTML template are required', - empty: 'No email template events or locales are available yet.', - noPreview: 'Refresh the preview to see the rendered email subject.', - customized: 'Customized' - }, - opsMonitoring: { - title: 'Ops Monitoring', - description: 'Enable ops monitoring for troubleshooting and health visibility', - disabled: 'Ops monitoring is disabled', - enabled: 'Enable Ops Monitoring', - enabledHint: 'Enable the ops monitoring module (admin only)', - realtimeEnabled: 'Enable Realtime Monitoring', - realtimeEnabledHint: 'Enable realtime QPS/metrics push (WebSocket)', - queryMode: 'Default Query Mode', - queryModeHint: 'Default query mode for Ops Dashboard (auto/raw/preagg)', - queryModeAuto: 'Auto (recommended)', - queryModeRaw: 'Raw (most accurate, slower)', - queryModePreagg: 'Preagg (fastest, requires aggregation)', - metricsInterval: 'Metrics Collection Interval (seconds)', - metricsIntervalHint: 'How often to collect system/request metrics (60-3600 seconds)' - }, - adminApiKey: { - title: 'Admin API Key', - description: 'Global API key for external system integration with full admin access', - notConfigured: 'Admin API key not configured', - configured: 'Admin API key is active', - currentKey: 'Current Key', - regenerate: 'Regenerate', - regenerating: 'Regenerating...', - delete: 'Delete', - deleting: 'Deleting...', - create: 'Create Key', - creating: 'Creating...', - regenerateConfirm: 'Are you sure? The current key will be immediately invalidated.', - deleteConfirm: - 'Are you sure you want to delete the admin API key? External integrations will stop working.', - keyGenerated: 'New admin API key generated', - keyDeleted: 'Admin API key deleted', - copyKey: 'Copy Key', - keyCopied: 'Key copied to clipboard', - keyWarning: 'This key will only be shown once. Please copy it now.', - securityWarning: 'Warning: This key provides full admin access. Keep it secure.', - usage: 'Usage: Add to request header - x-api-key: ' - }, - soraS3: { - title: 'Sora Storage', - description: 'Manage Sora media storage profiles with S3 and Google Drive support', - newProfile: 'New Profile', - reloadProfiles: 'Reload Profiles', - empty: 'No storage profiles yet, create one first', - createTitle: 'Create Storage Profile', - editTitle: 'Edit Storage Profile', - selectProvider: 'Select Storage Type', - providerS3Desc: 'S3-compatible object storage', - providerGDriveDesc: 'Google Drive cloud storage', - profileID: 'Profile ID', - profileName: 'Profile Name', - setActive: 'Set as active after creation', - saveProfile: 'Save Profile', - activateProfile: 'Activate', - profileCreated: 'Storage profile created', - profileSaved: 'Storage profile saved', - profileDeleted: 'Storage profile deleted', - profileActivated: 'Active storage profile switched', - profileIDRequired: 'Profile ID is required', - profileNameRequired: 'Profile name is required', - profileSelectRequired: 'Please select a profile first', - endpointRequired: 'S3 endpoint is required when enabled', - bucketRequired: 'Bucket is required when enabled', - accessKeyRequired: 'Access Key ID is required when enabled', - deleteConfirm: 'Delete storage profile {profileID}?', - columns: { - profile: 'Profile', - profileId: 'Profile ID', - name: 'Name', - provider: 'Type', - active: 'Active', - endpoint: 'Endpoint', - bucket: 'Bucket', - storagePath: 'Storage Path', - capacityUsage: 'Capacity / Used', - capacityUnlimited: 'Unlimited', - videoCount: 'Videos', - videoCompleted: 'completed', - videoInProgress: 'in progress', - quota: 'Default Quota', - updatedAt: 'Updated At', - actions: 'Actions', - rootFolder: 'Root folder', - testInTable: 'Test', - testingInTable: 'Testing...', - testTimeout: 'Test timed out (15s)' - }, - enabled: 'Enable Storage', - enabledHint: 'When enabled, Sora generated media files will be automatically uploaded', - endpoint: 'S3 Endpoint', - region: 'Region', - bucket: 'Bucket', - prefix: 'Object Prefix', - accessKeyId: 'Access Key ID', - secretAccessKey: 'Secret Access Key', - secretConfigured: '(Configured, leave blank to keep)', - cdnUrl: 'CDN URL', - cdnUrlHint: 'Optional. When configured, files are accessed via CDN URL', - forcePathStyle: 'Force Path Style', - defaultQuota: 'Default Storage Quota', - defaultQuotaHint: 'Default quota when not specified at user or group level. 0 means unlimited', - testConnection: 'Test Connection', - testing: 'Testing...', - testSuccess: 'Connection test successful', - testFailed: 'Connection test failed', - saved: 'Storage settings saved successfully', - saveFailed: 'Failed to save storage settings', - gdrive: { - authType: 'Authentication Method', - serviceAccount: 'Service Account', - clientId: 'Client ID', - clientSecret: 'Client Secret', - clientSecretConfigured: '(Configured, leave blank to keep)', - refreshToken: 'Refresh Token', - refreshTokenConfigured: '(Configured, leave blank to keep)', - serviceAccountJson: 'Service Account JSON', - serviceAccountConfigured: '(Configured, leave blank to keep)', - folderId: 'Folder ID (optional)', - authorize: 'Authorize Google Drive', - authorizeHint: 'Get Refresh Token via OAuth2', - oauthFieldsRequired: 'Please fill in Client ID and Client Secret first', - oauthSuccess: 'Google Drive authorization successful', - oauthFailed: 'Google Drive authorization failed', - closeWindow: 'This window will close automatically', - processing: 'Processing authorization...', - testStorage: 'Test Storage', - testSuccess: 'Google Drive storage test passed (upload, access, delete all OK)', - testFailed: 'Google Drive storage test failed' - } - }, - overloadCooldown: { - title: '529 Overload Cooldown', - description: 'Configure account scheduling pause strategy when upstream returns 529 (overloaded)', - enabled: 'Enable Overload Cooldown', - enabledHint: 'Pause account scheduling on 529 errors, auto-recover after cooldown', - cooldownMinutes: 'Cooldown Duration (minutes)', - cooldownMinutesHint: 'Duration to pause account scheduling (1-120 minutes)', - saved: 'Overload cooldown settings saved', - saveFailed: 'Failed to save overload cooldown settings' - }, - rateLimit429Cooldown: { - title: '429 Default Cooldown', - description: 'Configure the default account cooldown when upstream returns 429 without an explicit reset time', - enabled: 'Enable 429 Default Cooldown', - enabledHint: 'Pause account scheduling when a 429 has no reset time, then auto-recover after cooldown', - cooldownSeconds: 'Cooldown Duration (seconds)', - cooldownSecondsHint: 'Default cooldown duration (1-7200 seconds); explicit upstream reset times still take precedence', - saved: '429 default cooldown settings saved', - saveFailed: 'Failed to save 429 default cooldown settings' - }, - streamTimeout: { - title: 'Stream Timeout Handling', - description: 'Configure account handling strategy when upstream response times out', - enabled: 'Enable Stream Timeout Handling', - enabledHint: 'Automatically handle problematic accounts when upstream times out', - timeoutSeconds: 'Timeout Threshold (seconds)', - timeoutSecondsHint: 'Stream data interval exceeding this time is considered timeout (30-300s)', - action: 'Action', - actionTempUnsched: 'Temporarily Unschedulable', - actionError: 'Mark as Error', - actionNone: 'No Action', - actionHint: 'Action to take on the account after timeout', - tempUnschedMinutes: 'Pause Duration (minutes)', - tempUnschedMinutesHint: 'Duration of temporary unschedulable state (1-60 minutes)', - thresholdCount: 'Trigger Threshold (count)', - thresholdCountHint: 'Number of timeouts before triggering action (1-10)', - thresholdWindowMinutes: 'Threshold Window (minutes)', - thresholdWindowMinutesHint: 'Time window for counting timeouts (1-60 minutes)', - saved: 'Stream timeout settings saved', - saveFailed: 'Failed to save stream timeout settings' - }, - rectifier: { - title: 'Request Rectifier', - description: 'Automatically fix request parameters and retry when upstream returns specific errors', - enabled: 'Enable Request Rectifier', - enabledHint: 'Master switch - disabling turns off all rectification features', - thinkingSignature: 'Thinking Signature Rectifier', - thinkingSignatureHint: 'Automatically strip signatures and retry when upstream returns thinking block signature validation errors', - thinkingBudget: 'Thinking Budget Rectifier', - thinkingBudgetHint: 'Automatically set budget to 32000 and retry when upstream returns budget_tokens constraint error (≥1024)', - apikeySignature: 'API Key Signature Rectifier', - apikeySignatureHint: - 'Automatically strip signatures and retry when API Key accounts receive signature-related errors (built-in patterns always apply)', - apikeyPatterns: 'Custom Match Patterns', - apikeyPatternsHint: - 'Additional keywords matched against the response body (case-insensitive). Built-in patterns always apply; use these for supplementary matching.', - apikeyPatternPlaceholder: 'e.g., thinking_error', - addPattern: 'Add Pattern', - saved: 'Rectifier settings saved', - saveFailed: 'Failed to save rectifier settings' - }, - betaPolicy: { - title: 'Beta Policy', - description: 'How to handle Beta features when configuring the forwarding of Anthropic API requests. Applicable only to the /v1/messages endpoint.', - action: 'Action', - actionPass: 'Pass (transparent)', - actionFilter: 'Filter (remove)', - actionBlock: 'Block (reject)', - scope: 'Scope', - scopeAll: 'All accounts', - scopeOAuth: 'OAuth only', - scopeAPIKey: 'API Key only', - scopeBedrock: 'Bedrock only', - errorMessage: 'Error message', - errorMessagePlaceholder: 'Custom error message when blocked', - errorMessageHint: 'Leave empty for default message', - saved: 'Beta policy settings saved', - saveFailed: 'Failed to save beta policy settings', - modelWhitelist: 'Model Whitelist', - modelWhitelistHint: 'Leave empty to apply to all models. Supports exact match and wildcard prefix (e.g., claude-opus-*)', - modelPatternPlaceholder: 'e.g., claude-opus-* or claude-opus-4-6', - addModelPattern: 'Add model pattern', - removePattern: 'Remove', - fallbackAction: 'Fallback Action', - fallbackActionHint: 'Action for models not matching the whitelist', - fallbackErrorMessagePlaceholder: 'Custom error message when non-whitelisted models are blocked', - quickPresets: 'Quick Presets', - presetOpusOnly: 'Opus only for 1M', - presetOpusOnlyDesc: 'Pass for Opus, filter others', - commonPatterns: 'Common patterns' - }, - openaiFastPolicy: { - title: 'OpenAI Fast/Flex Policy', - description: 'Intercept, filter, or pass OpenAI fast(priority) / flex requests based on the request body service_tier field. Applies to the OpenAI gateway only.', - empty: 'No rules configured. Click the button below to add one.', - ruleHeader: 'Rule #{index}', - removeRule: 'Remove rule', - addRule: 'Add rule', - saveHint: 'Saved together with system settings (click the global Save button at the bottom of the page).', - serviceTier: 'service_tier match', - tierAll: 'All tiers', - tierPriority: 'priority (fast)', - tierFlex: 'flex', - action: 'Action', - actionPass: 'Pass (keep service_tier)', - actionFilter: 'Filter (remove service_tier)', - actionForcePriority: 'Force priority (fast)', - actionBlock: 'Block (reject request)', - scope: 'Scope', - scopeAll: 'All accounts', - scopeOAuth: 'OAuth only', - scopeAPIKey: 'API Key only', - scopeBedrock: 'Bedrock only', - errorMessage: 'Error message', - errorMessagePlaceholder: 'Custom error message when blocked', - errorMessageHint: 'Leave empty for the default message.', - modelWhitelist: 'Model whitelist', - modelWhitelistHint: 'Leave empty to apply to all models. Supports exact match and wildcard prefix (e.g., gpt-5.5*).', - modelPatternPlaceholder: 'e.g., gpt-5.5 or gpt-5.5*', - addModelPattern: 'Add model pattern', - fallbackAction: 'Fallback action', - fallbackActionHint: 'Action for models not matching the whitelist.', - fallbackErrorMessagePlaceholder: 'Custom error message when non-whitelisted models are blocked' - }, - wechatConnect: { - title: 'WeChat Connect', - description: 'Third-party login configuration for WeChat Open Platform or Official Account / Mini Program.', - enabledLabel: 'Enable WeChat Connect', - enabledHint: 'Enable this to configure WeChat OAuth callbacks and authorization.', - appIdLabel: 'App ID', - appIdPlaceholder: 'WeChat App ID', - appSecretLabel: 'App Secret', - appSecretConfiguredPlaceholder: 'Secret configured. Leave empty to keep the current value.', - appSecretPlaceholder: 'WeChat App Secret', - appSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', - appSecretHint: 'Enter a new secret to replace the current WeChat credential.', - modeLabel: 'Mode', - openModeLabel: 'Use Open outside WeChat', - openModeHint: 'Use Open Platform QR authorization outside the WeChat browser.', - mpModeLabel: 'Use MP inside WeChat', - mpModeHint: 'Use Official Account authorization inside the WeChat browser.', - redirectUrlLabel: 'Redirect URL', - redirectUrlPlaceholder: 'https://your-site.com/api/v1/auth/oauth/wechat/callback', - generateAndCopy: 'Generate & Copy (current site)', - redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard', - frontendRedirectUrlLabel: 'Frontend redirect URL', - frontendRedirectUrlPlaceholder: '/auth/wechat/callback', - frontendRedirectUrlHint: 'Usually the frontend route callback path; keep it aligned with the backend.' - }, - authSourceDefaults: { - title: 'Auth Source Defaults', - description: 'Configure per-source default balance, concurrency, subscriptions, and grant rules.', - requireEmailLabel: 'Require email on third-party signup', - requireEmailHint: 'When enabled, Linux DO, OIDC, and WeChat signups must provide an email before account creation.', - enabledHint: 'These defaults apply when a new user registers through this source. Grant on first bind only applies when an existing user binds this source.', - sources: { - email: { - title: 'Email signup', - description: 'Default quota grants for email-password signups.' - }, - linuxdo: { - title: 'Linux DO signup', - description: 'Default quota grants for Linux DO signups.' - }, - oidc: { - title: 'OIDC signup', - description: 'Default quota grants for OIDC signups.' - }, - wechat: { - title: 'WeChat signup', - description: 'Default quota grants for WeChat signups.' - } - }, - grantOnFirstBindLabel: 'Grant on first bind', - grantOnFirstBindHint: 'Grant default entitlements when an existing user first binds this source.', - defaultSubscriptionsLabel: 'Default subscriptions', - defaultSubscriptionsHint: 'Applies only to this auth source. Leave empty to skip source-specific subscriptions.', - noSourceSubscriptions: 'No source-specific default subscriptions configured.', - platformQuotasOverride: 'Platform Quota Overrides', - platformQuotasOverrideHint: 'Blank fields inherit the system default. Set to 0 to fully block that window for this auth source.', - }, - paymentVisibleMethods: { - methodLabel: '{title} visible method', - methodHint: 'Controls whether checkout shows this method and which source key it exposes.', - sourceLabel: 'Payment source', - sourceHint: 'Choose an explicit source before enabling the method. Not configured methods are not exposed.', - sourceRequiredError: 'Select a payment source before enabling {title}.' - }, - openaiExperimentalScheduler: { - title: 'OpenAI experimental scheduler policy', - description: "Disabled by default. When enabled, this only changes the gateway's experimental account-selection policy for OpenAI traffic; it does not indicate an upstream OpenAI capability.", - stickyWeightedTitle: 'Sticky weighting', - stickyWeightedDescription: 'When enabled, previous_response_id and session_hash affinity are scored by the advanced scheduler. When disabled, sticky accounts keep the legacy hard-hit behavior.', - subscriptionPriorityTitle: 'Subscription priority', - subscriptionPriorityDescription: 'When enabled, the scheduler scores ChatGPT subscription accounts first and falls back to non-subscription accounts only if no subscription slot can be acquired.', - weightsTitle: 'Scheduler weight overrides', - weightsDescription: 'Blank values use config/environment values; when config is not set, built-in defaults apply. Non-blank page settings take priority.', - defaultPlaceholder: 'config/default: {value}', - topKLabel: 'TopK', - priorityWeight: 'Priority', - loadWeight: 'Load', - queueWeight: 'Queue', - errorRateWeight: 'Error rate', - ttftWeight: 'TTFT', - resetWeight: 'Reset window', - quotaHeadroomWeight: 'Quota headroom', - previousResponseWeight: 'previous_response sticky', - sessionStickyWeight: 'session_hash sticky' - }, - usageRecords: { - title: 'Usage Records', - description: 'Settings for usage and failed-request records visible to end users.', - }, - user_error_view: { - label: 'Allow users to view their own error requests', - description: 'When enabled, users can see a redacted view of their failed requests on the usage page (no internal/upstream details). Requires ops monitoring enabled to have data.', - }, - saveSettings: 'Save Settings', - saving: 'Saving...', - settingsSaved: 'Settings saved successfully', - smtpConnectionSuccess: 'SMTP connection successful', - testEmailSent: 'Test email sent successfully', - failedToLoad: 'Failed to load settings', - failedToSave: 'Failed to save settings', - failedToTestSmtp: 'SMTP connection test failed', - failedToSendTestEmail: 'Failed to send test email' - }, - - // Error Passthrough Rules - errorPassthrough: { - title: 'Error Passthrough Rules', - description: 'Configure how upstream errors are returned to clients', - createRule: 'Create Rule', - editRule: 'Edit Rule', - deleteRule: 'Delete Rule', - noRules: 'No rules configured', - createFirstRule: 'Create your first error passthrough rule', - allPlatforms: 'All Platforms', - passthrough: 'Passthrough', - custom: 'Custom', - code: 'Code', - body: 'Body', - skipMonitoring: 'Skip Monitoring', - - // Columns - columns: { - priority: 'Priority', - name: 'Name', - conditions: 'Conditions', - platforms: 'Platforms', - behavior: 'Behavior', - status: 'Status', - actions: 'Actions' - }, - - // Match Mode - matchMode: { - any: 'Code OR Keyword', - all: 'Code AND Keyword', - anyHint: 'Status code matches any error code, OR message contains any keyword', - allHint: 'Status code matches any error code, AND message contains any keyword' - }, - - // Form - form: { - name: 'Rule Name', - namePlaceholder: 'e.g., Context Limit Passthrough', - priority: 'Priority', - priorityHint: 'Lower values have higher priority', - description: 'Description', - descriptionPlaceholder: 'Describe the purpose of this rule...', - matchConditions: 'Match Conditions', - errorCodes: 'Error Codes', - errorCodesPlaceholder: '422, 400, 429', - errorCodesHint: 'Separate multiple codes with commas', - keywords: 'Keywords', - keywordsPlaceholder: 'One keyword per line\ncontext limit\nmodel not supported', - keywordsHint: 'One keyword per line, case-insensitive', - matchMode: 'Match Mode', - platforms: 'Platforms', - platformsHint: 'Leave empty to apply to all platforms', - responseBehavior: 'Response Behavior', - passthroughCode: 'Passthrough upstream status code', - responseCode: 'Custom status code', - passthroughBody: 'Passthrough upstream error message', - customMessage: 'Custom error message', - customMessagePlaceholder: 'Error message to return to client...', - skipMonitoring: 'Skip monitoring', - skipMonitoringHint: 'When enabled, errors matching this rule will not be recorded in ops monitoring', - enabled: 'Enable this rule' - }, - - // Messages - nameRequired: 'Please enter rule name', - conditionsRequired: 'Please configure at least one error code or keyword', - ruleCreated: 'Rule created successfully', - ruleUpdated: 'Rule updated successfully', - ruleDeleted: 'Rule deleted successfully', - deleteConfirm: 'Are you sure you want to delete rule "{name}"?', - failedToLoad: 'Failed to load rules', - failedToSave: 'Failed to save rule', - failedToDelete: 'Failed to delete rule', - failedToToggle: 'Failed to toggle status' - }, - - // TLS Fingerprint Profiles - tlsFingerprintProfiles: { - title: 'TLS Fingerprint Profiles', - description: 'Manage TLS fingerprint profiles for simulating specific client TLS handshake characteristics', - createProfile: 'Create Profile', - editProfile: 'Edit Profile', - deleteProfile: 'Delete Profile', - noProfiles: 'No profiles configured', - createFirstProfile: 'Create your first TLS fingerprint profile', - - columns: { - name: 'Name', - description: 'Description', - grease: 'GREASE', - alpn: 'ALPN', - actions: 'Actions' - }, - - form: { - pasteYaml: 'Paste YAML Configuration', - pasteYamlPlaceholder: 'Paste YAML output from TLS Fingerprint Collector here...', - pasteYamlHint: 'Paste the YAML copied from TLS Fingerprint Collector to auto-fill all fields.', - openCollector: 'Open Collector', - parseYaml: 'Parse YAML', - yamlParsed: 'YAML parsed successfully, fields auto-filled', - yamlParseFailed: 'Failed to parse YAML: name field not found', - name: 'Profile Name', - namePlaceholder: 'e.g. macOS Node.js v24', - description: 'Description', - descriptionPlaceholder: 'Optional description for this profile', - enableGrease: 'Enable GREASE', - enableGreaseHint: 'Insert GREASE values in TLS ClientHello extensions', - cipherSuites: 'Cipher Suites', - cipherSuitesHint: 'Comma-separated hex values, e.g. 0x1301, 0x1302, 0xc02c', - curves: 'Elliptic Curves', - curvesHint: 'Comma-separated curve IDs', - pointFormats: 'Point Formats', - signatureAlgorithms: 'Signature Algorithms', - alpnProtocols: 'ALPN Protocols', - alpnProtocolsHint: 'Comma-separated, e.g. h2, http/1.1', - supportedVersions: 'Supported TLS Versions', - keyShareGroups: 'Key Share Groups', - pskModes: 'PSK Modes', - extensions: 'Extensions' - }, - - deleteConfirm: 'Delete Profile', - deleteConfirmMessage: 'Are you sure you want to delete profile "{name}"? Accounts using this profile will fall back to the built-in default.', - createSuccess: 'Profile created successfully', - updateSuccess: 'Profile updated successfully', - deleteSuccess: 'Profile deleted successfully', - loadFailed: 'Failed to load profiles', - saveFailed: 'Failed to save profile', - deleteFailed: 'Failed to delete profile' - } - }, - - // Subscription Progress (Header component) - subscriptionProgress: { - title: 'My Subscriptions', - viewDetails: 'View subscription details', - activeCount: '{count} active subscription(s)', - daily: 'Daily', - weekly: 'Weekly', - monthly: 'Monthly', - daysRemaining: '{days} days left', - expired: 'Expired', - expiresToday: 'Expires today', - expiresTomorrow: 'Expires tomorrow', - viewAll: 'View all subscriptions', - noSubscriptions: 'No active subscriptions', - unlimited: 'Unlimited' - }, - - // Version Badge - version: { - currentVersion: 'Current Version', - latestVersion: 'Latest Version', - upToDate: "You're running the latest version.", - updateAvailable: 'A new version is available!', - releaseNotes: 'Release Notes', - noReleaseNotes: 'No release notes', - viewUpdate: 'View Update', - viewRelease: 'View Release', - viewChangelog: 'View Changelog', - refresh: 'Refresh', - sourceMode: 'Source Build', - sourceModeHint: 'Source build, use git pull to update', - updateNow: 'Update Now', - updating: 'Updating...', - updateComplete: 'Update Complete', - updateFailed: 'Update Failed', - restartRequired: 'Please restart the service to apply the update', - restartNow: 'Restart Now', - restarting: 'Restarting...', - retry: 'Retry' - }, - - // Recharge / Subscription Page - purchase: { - title: 'Recharge / Subscription', - description: 'Recharge balance or purchase subscription via the embedded page', - openInNewTab: 'Open in new tab', - notEnabledTitle: 'Feature not enabled', - notEnabledDesc: 'The administrator has not enabled the recharge/subscription entry. Please contact admin.', - notConfiguredTitle: 'Recharge / Subscription URL not configured', - notConfiguredDesc: - 'The administrator enabled the entry but has not configured a recharge/subscription URL. Please contact admin.' - }, - - // Custom Page (iframe embed) - customPage: { - title: 'Custom Page', - openInNewTab: 'Open in new tab', - notFoundTitle: 'Page not found', - notFoundDesc: 'This custom page does not exist or has been removed.', - notConfiguredTitle: 'Page URL not configured', - notConfiguredDesc: 'The URL for this custom page has not been properly configured.', - tableOfContents: 'Contents', - copyCode: 'Copy', - copiedCode: 'Copied', - copyCodeFailed: 'Failed' - }, - - // Announcements Page - announcements: { - title: 'Announcements', - description: 'View system announcements', - unreadOnly: 'Show unread only', - markRead: 'Mark as read', - markAllRead: 'Mark all as read', - viewAll: 'View all announcements', - markedAsRead: 'Marked as read', - allMarkedAsRead: 'All announcements marked as read', - newCount: '{count} new announcement | {count} new announcements', - readAt: 'Read at', - read: 'Read', - unread: 'Unread', - startsAt: 'Starts at', - endsAt: 'Ends at', - empty: 'No announcements', - emptyUnread: 'No unread announcements', - total: 'announcements', - emptyDescription: 'There are no system announcements at this time', - readStatus: 'You have read this announcement', - markReadHint: 'Click "Mark as read" to mark this announcement' - }, - - // User Subscriptions Page - userSubscriptions: { - title: 'My Subscriptions', - description: 'View your subscription plans and usage', - noActiveSubscriptions: 'No Active Subscriptions', - noActiveSubscriptionsDesc: - "You don't have any active subscriptions. Contact administrator to get one.", - failedToLoad: 'Failed to load subscriptions', - status: { - active: 'Active', - expired: 'Expired', - revoked: 'Revoked' - }, - usage: 'Usage', - expires: 'Expires', - noExpiration: 'No expiration', - unlimited: 'Unlimited', - unlimitedDesc: 'No usage limits on this subscription', - daily: 'Daily', - weekly: 'Weekly', - monthly: 'Monthly', - daysRemaining: '{days} days remaining', - expiresOn: 'Expires on {date}', - resetIn: 'Resets in {time}', - quotaEndsIn: 'Quota ends in {time}', - windowNotActive: 'Awaiting first use', - usageOf: '{used} of {limit}' - }, - - // Onboarding Tour - onboarding: { - restartTour: 'Restart Onboarding Tour', - dontShowAgain: "Don't show again", - dontShowAgainTitle: 'Permanently close onboarding guide', - confirmDontShow: "Are you sure you don't want to see the onboarding guide again?\n\nYou can restart it anytime from the user menu in the top right corner.", - confirmExit: 'Are you sure you want to exit the onboarding guide? You can restart it anytime from the top right menu.', - interactiveHint: 'Press Enter or Click to continue', - navigation: { - flipPage: 'Flip Page', - exit: 'Exit' - }, - // Admin tour steps - admin: { - welcome: { - title: '👋 Welcome to Sub2API', - description: '

Sub2API is a powerful AI service gateway platform that helps you easily manage and distribute AI services.

🎯 Core Features:

  • 📦 Group Management - Create service tiers (VIP, Free Trial, etc.)
  • 🔗 Account Pool - Connect multiple upstream AI service accounts
  • 🔑 Key Distribution - Generate independent API Keys for users
  • 💰 Billing Control - Flexible rate and quota management

Let\'s complete the initial setup in 3 minutes →

', - nextBtn: 'Start Setup 🚀', - prevBtn: 'Skip' - }, - groupManage: { - title: '📦 Step 1: Group Management', - description: '

What is a Group?

Groups are the core concept of Sub2API, like a "service package":

  • 🎯 Each group can contain multiple upstream accounts
  • 💰 Each group has independent billing multiplier
  • 👥 Can be set as public or exclusive

💡 Example: You can create "VIP Premium" (high rate) and "Free Trial" (low rate) groups

👉 Click "Group Management" on the left sidebar

' - }, - createGroup: { - title: '➕ Create New Group', - description: '

Let\'s create your first group.

📝 Tip: Recommend creating a test group first to familiarize yourself with the process

👉 Click the "Create Group" button

' - }, - groupName: { - title: '✏️ 1. Group Name', - description: '

Give your group an easy-to-identify name.

💡 Naming Suggestions:
  • "Test Group" - For testing
  • "VIP Premium" - High-quality service
  • "Free Trial" - Trial version

Click "Next" when done

', - nextBtn: 'Next' - }, - groupPlatform: { - title: '🤖 2. Select Platform', - description: '

Choose the AI platform this group supports.

📌 Platform Guide:
  • Anthropic - Claude models
  • OpenAI - GPT models
  • Google - Gemini models

One group can only have one platform

', - nextBtn: 'Next' - }, - groupMultiplier: { - title: '💰 3. Rate Multiplier', - description: '

Set the billing multiplier to control user charges.

⚙️ Billing Rules:
  • 1.0 - Original price (cost price)
  • 1.5 - User consumes $1, charged $1.5
  • 2.0 - User consumes $1, charged $2
  • 0.8 - Subsidy mode (loss-making)

Recommend setting test group to 1.0

', - nextBtn: 'Next' - }, - groupExclusive: { - title: '🔒 4. Exclusive Group (Optional)', - description: '

Control group visibility and access permissions.

🔐 Permission Guide:
  • Off - Public group, visible to all users
  • On - Exclusive group, only for specified users

💡 Use Cases: VIP exclusive, internal testing, special customers

', - nextBtn: 'Next' - }, - groupSubmit: { - title: '✅ Save Group', - description: '

Confirm the information and click create to save the group.

⚠️ Note: Platform type cannot be changed after creation, but other settings can be edited anytime

📌 Next Step: After creation, we\'ll add upstream accounts to this group

👉 Click "Create" button

' - }, - accountManage: { - title: '🔗 Step 2: Add Account', - description: '

Great! Group created successfully 🎉

Now add upstream AI service accounts to enable actual service delivery.

🔑 Account Purpose:
  • Connect to upstream AI services (Claude, GPT, etc.)
  • One group can contain multiple accounts (load balancing)
  • Supports OAuth and Session Key methods

👉 Click "Account Management" on the left sidebar

' - }, - createAccount: { - title: '➕ Add New Account', - description: '

Click the button to start adding your first upstream account.

💡 Tip: Recommend using OAuth method - more secure and no manual key extraction needed

👉 Click "Add Account" button

' - }, - accountName: { - title: '✏️ 1. Account Name', - description: '

Set an easy-to-identify name for the account.

💡 Naming Suggestions: "Claude Main", "GPT Backup 1", "Test Account", etc.

', - nextBtn: 'Next' - }, - accountPlatform: { - title: '🤖 2. Select Platform', - description: '

Choose the service provider platform for this account.

⚠️ Important: Platform must match the group you just created

', - nextBtn: 'Next' - }, - accountType: { - title: '🔐 3. Authorization Method', - description: '

Choose the account authorization method.

✅ Recommended: OAuth Method
  • No manual key extraction needed
  • More secure with auto-refresh support
  • Works with Claude Code, ChatGPT OAuth
📌 Session Key Method
  • Requires manual extraction from browser
  • May need periodic updates
  • For platforms without OAuth support
', - nextBtn: 'Next' - }, - accountPriority: { - title: '⚖️ 4. Priority (Optional)', - description: '

Set the account call priority.

📊 Priority Rules:
  • Lower number = higher priority
  • System uses low-value accounts first
  • Same priority = random selection

💡 Use Case: Set main account to lower value, backup accounts to higher value

', - nextBtn: 'Next' - }, - accountGroups: { - title: '🎯 5. Assign Groups', - description: '

Key Step! Assign the account to the group you just created.

⚠️ Important Reminder:
  • Must select at least one group
  • Unassigned accounts cannot be used
  • One account can be assigned to multiple groups

💡 Tip: Select the test group you just created

', - nextBtn: 'Next' - }, - accountSubmit: { - title: '✅ Save Account', - description: '

Confirm the information and click save.

📌 OAuth Flow:
  • Will redirect to service provider page after clicking save
  • Complete login and authorization on provider page
  • Auto-return after successful authorization

📌 Next Step: After adding account, we\'ll create an API key

👉 Click "Save" button

' - }, - keyManage: { - title: '🔑 Step 3: Generate Key', - description: '

Congratulations! Account setup complete 🎉

Final step: generate an API Key to test if the service works properly.

🔑 API Key Purpose:
  • Credential for calling AI services
  • Each key is bound to one group
  • Can set quota and expiration
  • Supports independent usage statistics

👉 Click "API Keys" on the left sidebar

' - }, - createKey: { - title: '➕ Create Key', - description: '

Click the button to create your first API Key.

💡 Tip: Copy and save immediately after creation - key is only shown once

👉 Click "Create Key" button

' - }, - keyName: { - title: '✏️ 1. Key Name', - description: '

Set an easy-to-manage name for the key.

💡 Naming Suggestions: "Test Key", "Production", "Mobile", etc.

', - nextBtn: 'Next' - }, - keyGroup: { - title: '🎯 2. Select Group', - description: '

Select the group you just configured.

📌 Group Determines:
  • Which accounts this key can use
  • What billing multiplier applies
  • Whether it\'s an exclusive key

💡 Tip: Select the test group you just created

', - nextBtn: 'Next' - }, - keySubmit: { - title: '🎉 Generate and Copy', - description: '

System will generate a complete API Key after clicking create.

⚠️ Important Reminder:
  • Key is only shown once, copy immediately
  • Need to regenerate if lost
  • Keep it safe, don\'t share with others
🚀 Next Steps:
  • Copy the generated sk-xxx key
  • Use in any OpenAI-compatible client
  • Start experiencing AI services!

👉 Click "Create" button

' - } - }, - // User tour steps - user: { - welcome: { - title: '👋 Welcome to Sub2API', - description: '

Hello! Welcome to the Sub2API AI service platform.

🎯 Quick Start:

  • 🔑 Create API Key
  • 📋 Copy key to your application
  • 🚀 Start using AI services

Just 1 minute, let\'s get started →

', - nextBtn: 'Start 🚀', - prevBtn: 'Skip' - }, - keyManage: { - title: '🔑 API Key Management', - description: '

Manage all your API access keys here.

📌 What is an API Key?
An API key is your credential for accessing AI services, like a key that allows your application to call AI capabilities.

👉 Click to enter key page

' - }, - createKey: { - title: '➕ Create New Key', - description: '

Click the button to create your first API key.

💡 Tip: Key is only shown once after creation, make sure to copy and save

👉 Click "Create Key"

' - }, - keyName: { - title: '✏️ Key Name', - description: '

Give your key an easy-to-identify name.

💡 Examples: "My First Key", "For Testing", etc.

', - nextBtn: 'Next' - }, - keyGroup: { - title: '🎯 Select Group', - description: '

Select the service group assigned by the administrator.

📌 Group Info:
Different groups may have different service quality and billing rates, choose according to your needs.

', - nextBtn: 'Next' - }, - keySubmit: { - title: '🎉 Complete Creation', - description: '

Click to confirm and create your API key.

⚠️ Important:
  • Copy the key (sk-xxx) immediately after creation
  • Key is only shown once, need to regenerate if lost

🚀 How to Use:
Configure the key in any OpenAI-compatible client (like ChatBox, OpenCat, etc.) and start using!

👉 Click "Create" button

' - } - } - }, - - // Payment System - payment: { - title: 'Recharge / Subscription', - amountLabel: 'Amount', - paymentAmount: 'Payment Amount', - creditedBalance: 'Credited Balance', - quickAmounts: 'Quick Amounts', - customAmount: 'Custom Amount', - enterAmount: 'Enter amount', - paymentMethod: 'Payment Method', - fee: 'Fee', - actualPay: 'Actual Payment', - createOrder: 'Confirm Payment', - methods: { - easypay: 'EasyPay', - alipay: 'Alipay', - wxpay: 'WeChat Pay', - stripe: 'Stripe', - airwallex: 'Airwallex', - card: 'Card', - link: 'Link', - alipay_direct: 'Alipay (Direct)', - wxpay_direct: 'WeChat Pay (Direct)', - }, - status: { - pending: 'Pending', - paid: 'Paid', - recharging: 'Recharging', - completed: 'Completed', - expired: 'Expired', - cancelled: 'Cancelled', - failed: 'Failed', - refund_requested: 'Refund Requested', - refunding: 'Refunding', - refund_pending: 'Refund Pending', - refunded: 'Refunded', - partially_refunded: 'Partially Refunded', - refund_failed: 'Refund Failed', - }, - qr: { - scanToPay: 'Scan to Pay', - scanAlipay: 'Alipay QR Payment', - scanWxpay: 'WeChat QR Payment', - scanAlipayHint: 'Open Alipay on your phone and scan the QR code to pay', - scanWxpayHint: 'Open WeChat on your phone and scan the QR code to pay', - payInNewWindow: 'Complete Payment in New Window', - payInNewWindowHint: 'The payment page has opened in a new window. Please complete the payment there and return to this page.', - openPayWindow: 'Reopen Payment Page', - expiresIn: 'Expires in', - expired: 'Order Expired', - expiredDesc: 'This order has expired. Please create a new one.', - cancelled: 'Order Cancelled', - cancelledDesc: 'You have cancelled this payment.', - waitingPayment: 'Waiting for payment...', - cancelOrder: 'Cancel Order', - }, - orders: { - title: 'My Orders', - empty: 'No orders yet', - orderId: 'Order ID', - orderNo: 'Order No.', - amount: 'Amount', - payAmount: 'Paid', - creditedAmount: 'Credited Amount', - fee: 'Fee', - baseAmount: 'Base Amount', - includedInPayAmount: 'included in paid amount', - status: 'Status', - paymentMethod: 'Payment Method', - createdAt: 'Created', - cancel: 'Cancel Order', - userId: 'User ID', - orderType: 'Order Type', - actions: 'Actions', - requestRefund: 'Request Refund', - }, - result: { - success: 'Payment Successful', - subscriptionSuccess: 'Subscription Successful', - processing: 'Payment Processing', - processingHint: 'Payment confirmation is still pending. This page will refresh automatically.', - failed: 'Payment Failed', - backToRecharge: 'Back to Recharge', - viewOrders: 'View Orders', - }, - currentBalance: 'Current Balance', - groupFallback: 'Group #{id}', - rechargeAccount: 'Recharge Account', - activeSubscription: 'Active Subscription', - noActiveSubscription: 'No active subscription', - tabTopUp: 'Top Up', - tabSubscribe: 'Subscribe', - noPlans: 'No subscription plans available', - notAvailable: 'Top-up is currently unavailable', - confirmSubscription: 'Confirm Subscription', - confirmCancel: 'Are you sure you want to cancel this order?', - amountTooLow: 'Minimum amount is {min}', - amountTooHigh: 'Maximum amount is {max}', - amountNoMethod: 'No payment method available for this amount', - rechargeRatePreview: 'Current rate: 1 CNY = {usd} USD', - refundReason: 'Refund Reason', - refundReasonPlaceholder: 'Please describe your refund reason', - stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.', - stripeMissingParams: 'Missing order ID or client secret', - stripeNotConfigured: 'Stripe is not configured', - airwallexLoadFailed: 'Failed to load Airwallex payment component. Please refresh and try again.', - airwallexMissingParams: 'Missing Airwallex payment parameters', - errors: { - tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.', - cancelRateLimited: 'Too many cancellations. Please try again later.', - wechatH5NotAuthorized: 'This merchant has not enabled WeChat H5 payment. Open this page in WeChat to continue.', - wechatPaymentMpNotConfigured: 'This site has not completed WeChat MP/JSAPI payment setup, so in-app WeChat payment is unavailable right now.', - wechatJsapiUnavailable: 'WeChat payment could not be invoked in the current environment. Reopen this page inside WeChat and try again.', - wechatJsapiFailed: 'WeChat payment did not complete. Try invoking it again or switch to QR payment.', - wechatUnavailable: 'WeChat payment is temporarily unavailable. Please try again later.', - wechatOpenInWeChatHint: 'Open the current page inside WeChat, or switch to desktop WeChat QR payment.', - wechatScanOnDesktopHint: 'On desktop, use WeChat Scan to pay; on mobile, reopen the current page inside WeChat.', - wechatSwitchBrowserHint: 'Switch to desktop WeChat QR payment, or reopen this page in an external browser and retry.', - mobilePaymentFallbackToQr: 'This merchant has not enabled mobile payment. The flow has been switched to QR payment automatically.', - alipayDesktopUnavailable: 'The desktop Alipay flow could not generate a QR code.', - alipayDesktopQrHint: 'Desktop Alipay should render a QR code. Refresh and retry, or make sure the payment page was not blocked.', - alipayMobileUnavailable: 'This page could not hand off to Alipay.', - alipayMobileOpenHint: 'Allow the current page to open the Alipay app, or retry from the system browser.', - // Structured error codes (reason strings from backend ApplicationError) - PAYMENT_DISABLED: 'Payment system is disabled.', - USER_INACTIVE: 'Your account is disabled.', - BALANCE_PAYMENT_DISABLED: 'Balance recharge has been disabled.', - INVALID_AMOUNT: 'Invalid amount.', - INVALID_INPUT: 'Invalid request.', - PLAN_NOT_AVAILABLE: 'Plan not found or no longer available.', - GROUP_NOT_FOUND: 'Subscription group is no longer available.', - GROUP_TYPE_MISMATCH: 'Group is not a subscription type.', - TOO_MANY_PENDING: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.', - DAILY_LIMIT_EXCEEDED: 'Daily recharge limit reached. Remaining: {remaining}.', - PAYMENT_GATEWAY_ERROR: 'Payment method is unavailable.', - NO_AVAILABLE_INSTANCE: 'No payment channel available right now.', - PAYMENT_PROVIDER_MISCONFIGURED: 'Payment provider misconfigured. Please contact an administrator.', - WXPAY_CONFIG_MISSING_KEY: 'WeChat Pay config missing required key: {key}.', - WXPAY_CONFIG_INVALID_KEY_LENGTH: 'WeChat Pay {key} length is invalid (expected {expected} bytes, got {actual}).', - WXPAY_CONFIG_INVALID_KEY: 'WeChat Pay {key} is malformed. Make sure you copied the full PEM content.', - PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.', - PAYMENT_PROVIDER_CONFLICT: 'Another enabled provider instance is already serving this payment method. Disable it before continuing.', - CANCEL_RATE_LIMITED: 'Too many cancellations. Please try again later.', - NOT_FOUND: 'Order not found.', - FORBIDDEN: 'No permission for this order.', - CONFLICT: 'Order status has changed. Please refresh.', - INVALID_ORDER_TYPE: 'Only balance orders can request a refund.', - INVALID_STATUS: 'The current order status does not allow this operation.', - BALANCE_NOT_ENOUGH: 'Refund amount exceeds balance.', - REFUND_AMOUNT_EXCEEDED: 'Refund amount exceeds the recharge amount.', - REFUND_FAILED: 'Refund failed.', - }, - airwallexPay: 'Airwallex Payment', - stripePay: 'Pay Now', - stripeSuccessProcessing: 'Payment successful, processing your order...', - stripePopup: { - redirecting: 'Redirecting to payment page...', - loadingQr: 'Loading WeChat Pay QR code...', - timeout: 'Timed out waiting for payment credentials, please retry', - qrFailed: 'Failed to get WeChat Pay QR code', - }, - subscribeNow: 'Subscribe Now', - renewNow: 'Renew', - selectPlan: 'Select Plan', - planFeatures: 'Features', - planCard: { - rate: 'Rate', - peakRate: 'Peak Rate', - dailyLimit: 'Daily', - weeklyLimit: 'Weekly', - monthlyLimit: 'Monthly', - quota: 'Quota', - unlimited: 'Unlimited', - models: 'Models', - }, - days: 'days', - months: 'months', - years: 'years', - oneMonth: '1 Month', - oneYear: '1 Year', - perMonth: 'month', - perYear: 'year', - admin: { - tabs: { - overview: 'Overview', - orders: 'Orders', - channels: 'Channels', - plans: 'Plans', - }, - todayRevenue: 'Today Revenue', - totalRevenue: 'Total Revenue', - todayOrders: 'Today Orders', - orderCount: 'Order Count', - avgAmount: 'Average Amount', - revenue: 'Revenue', - dailyRevenue: 'Daily Revenue', - paymentDistribution: 'Payment Distribution', - colUser: 'User', - topUsers: 'Top Users', - noData: 'No data', - days: 'days', - weeks: 'weeks', - months: 'months', - searchOrders: 'Search orders...', - allStatuses: 'All Statuses', - allPaymentTypes: 'All Payment Types', - allOrderTypes: 'All Order Types', - orderDetail: 'Order Detail', - orderType: 'Order Type', - orders: 'Orders', - balanceOrder: 'Balance Top-Up', - subscriptionOrder: 'Subscription', - paidAt: 'Paid At', - completedAt: 'Completed At', - expiresAt: 'Expires At', - feeRate: 'Fee Rate', - refund: 'Refund', - refundOrder: 'Refund Order', - refundAmount: 'Refund Amount', - maxRefundable: 'Max Refundable', - refundReason: 'Refund Reason', - refundReasonPlaceholder: 'Please enter refund reason', - confirmRefund: 'Confirm Refund', - refundSuccess: 'Refund successful', - refundPending: 'Refund pending gateway confirmation', - queryRefundStatus: 'Query refund status', - refundInfo: 'Refund Info', - refundEnabled: 'Refund Enabled', - allowUserRefund: 'Allow User Refund', - alreadyRefunded: 'Already Refunded', - deductBalance: 'Deduct Balance', - deductBalanceHint: 'Subtract recharged amount from user balance', - userBalance: 'User Balance', - orderAmount: 'Order Amount', - insufficientBalance: 'Insufficient balance — will deduct to $0', - noDeduction: 'Will NOT deduct user balance', - forceRefund: 'Force refund (ignore balance check)', - orderCancelled: 'Order Cancelled', - retry: 'Retry', - retrySuccess: 'Retry successful', - approveRefund: 'Approve Refund', - retryRefund: 'Retry Refund', - refundRequestInfo: 'Refund Request Info', - refundRequestedAt: 'Requested At', - refundRequestedBy: 'Requested By', - refundRequestReason: 'Request Reason', - auditLogs: 'Audit Logs', - operator: 'Operator', - channelName: 'Channel Name', - channelDescription: 'Channel Description', - createChannel: 'Create Channel', - editChannel: 'Edit Channel', - deleteChannel: 'Delete Channel', - deleteChannelConfirm: 'Are you sure you want to delete this channel?', - planName: 'Plan Name', - planDescription: 'Plan Description', - createPlan: 'Create Plan', - editPlan: 'Edit Plan', - deletePlan: 'Delete Plan', - deletePlanConfirm: 'Are you sure you want to delete this plan?', - originalPrice: 'Original Price', - price: 'Price', - subscriptionCnyPayPreview: 'CNY channel charge preview: {amount}', - subscriptionCnyPayPreviewWithFee: '({feeRate}% fee included: {total})', - validityDays: 'Validity (days)', - validityUnit: 'Validity Unit', - sortOrder: 'Sort Order', - forSale: 'For Sale', - onSale: 'On Sale', - offSale: 'Off Sale', - group: 'Group', - groupId: 'Group ID', - features: 'Features', - featuresHint: 'One feature per line', - featuresPlaceholder: 'Enter plan features...', - providerManagement: 'Provider Management', - providerManagementDesc: 'Manage payment provider instances', - createProvider: 'Create Provider', - editProvider: 'Edit Provider', - deleteProvider: 'Delete Provider', - deleteProviderConfirm: 'Are you sure you want to delete this provider?', - providerName: 'Provider Name', - providerKey: 'Provider Key', - selectProviderKey: 'Select Provider Key', - providerConfig: 'Provider Config', - noProviders: 'No providers configured', - noProvidersHint: 'Create a provider instance to start accepting payments', - supportedTypes: 'Supported Payment Types', - supportedTypesHint: 'Select the payment types this provider supports', - rateMultiplier: 'Rate Multiplier', - dashboardTitle: 'Payment Dashboard', - dashboardDesc: 'Recharge order analytics and insights', - daySuffix: 'd', - paymentConfigTitle: 'Payment Config', - paymentConfigDesc: 'Configure payment providers and settings', - plansPageTitle: 'Subscription Plans', - plansPageDesc: 'Manage subscription plan configuration', - tabPlanConfig: 'Plan Configuration', - tabUserSubs: 'User Subscriptions', - selectGroup: 'Select a group', - groupRequired: 'Please select a subscription group', - priceRequired: 'Price must be greater than 0', - validityDaysRequired: 'Validity days must be greater than 0', - groupMissing: 'Missing', - groupInfo: 'Group Info', - platform: 'Platform', - rateMultiplierLabel: 'Rate', - dailyLimit: 'Daily Limit', - weeklyLimit: 'Weekly Limit', - monthlyLimit: 'Monthly Limit', - unlimited: 'Unlimited', - searchUserSubs: 'Search user subscriptions...', - daily: 'D', - weekly: 'W', - monthly: 'M', - subsStatus: { - active: 'Active', - expired: 'Expired', - revoked: 'Revoked', - }, - }, - }, - -} diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts new file mode 100644 index 0000000000..8664da1d5a --- /dev/null +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -0,0 +1,1241 @@ +export default { + accounts: { + title: 'Account Management', + description: 'Manage AI platform accounts and credentials', + createAccount: 'Create Account', + autoRefresh: 'Auto Refresh', + enableAutoRefresh: 'Enable auto refresh', + refreshInterval5s: '5 seconds', + refreshInterval10s: '10 seconds', + refreshInterval15s: '15 seconds', + refreshInterval30s: '30 seconds', + autoRefreshCountdown: 'Auto refresh: {seconds}s', + listPendingSyncHint: 'List changes are pending sync. Click sync to load latest rows.', + listPendingSyncAction: 'Sync now', + syncFromCrs: 'Sync from CRS', + dataExport: 'Export', + dataExportSelected: 'Export Selected', + dataExportIncludeProxies: 'Include proxies linked to the exported accounts', + dataImport: 'Import', + moreActions: 'More Actions', + dataActions: 'Data', + toolActions: 'Tools', + viewColumns: 'Columns', + selectedCount: '{count} selected', + dataExportConfirmMessage: 'The exported data contains sensitive account and proxy information. Store it securely.', + dataExportConfirm: 'Confirm Export', + dataExported: 'Data exported successfully', + dataExportedSkippedShadows: 'Data exported. Skipped {count} spark shadow account(s): their scheduling config is not included in the backup; recreate and re-tune them after restore.', + dataExportFailed: 'Failed to export data', + dataImportTitle: 'Import Data', + dataImportHint: 'Upload the exported JSON file to import accounts and proxies.', + dataImportWarning: 'Import will create new accounts/proxies; groups must be bound manually. Ensure existing data does not conflict.', + dataImportFile: 'Data file', + dataImportButton: 'Start Import', + dataImporting: 'Importing...', + dataImportSelectFile: 'Please select a data file', + dataImportParseFailed: 'Failed to parse data file', + dataImportParseFailedFile: 'Failed to parse {name}', + dataImportInvalidFile: '{name} is not a supported data export file', + dataImportIgnoredFiles: 'Ignored {count} non-JSON file(s)', + dataImportFailed: 'Data import failed', + dataImportResult: 'Import Result', + dataImportResultSummary: 'Proxies created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}; Accounts created {account_created}, failed {account_failed}', + dataImportErrors: 'Error Details', + dataImportSuccess: 'Import completed: accounts {account_created}, failed {account_failed}', + dataImportCompletedWithErrors: 'Import completed with errors: account failed {account_failed}, proxy failed {proxy_failed}', + syncFromCrsTitle: 'Sync Accounts from CRS', + syncFromCrsDesc: + 'Sync accounts from claude-relay-service (CRS) into this system (CRS is called server-to-server).', + crsVersionRequirement: '⚠️ Note: CRS version must be ≥ v1.1.240 to support this feature', + crsBaseUrl: 'CRS Base URL', + crsBaseUrlPlaceholder: 'e.g. http://127.0.0.1:3000', + crsUsername: 'Username', + crsPassword: 'Password', + syncProxies: 'Also sync proxies (match by host/port/auth or create)', + syncNow: 'Sync Now', + syncing: 'Syncing...', + syncMissingFields: 'Please fill base URL, username and password', + syncResult: 'Sync Result', + syncResultSummary: 'Created {created}, updated {updated}, skipped {skipped}, failed {failed}', + syncErrors: 'Errors / Skipped Details', + syncCompleted: 'Sync completed: created {created}, updated {updated}, skipped {skipped}', + syncCompletedWithErrors: + 'Sync completed with errors: failed {failed} (created {created}, updated {updated}, skipped {skipped})', + syncFailed: 'Sync failed', + crsPreview: 'Preview', + crsPreviewing: 'Previewing...', + crsPreviewFailed: 'Preview failed', + crsExistingAccounts: 'Existing accounts (will be updated)', + crsNewAccounts: 'New accounts (select to sync)', + crsSelectAll: 'Select all', + crsSelectNone: 'Select none', + crsNoNewAccounts: 'All CRS accounts are already synced.', + crsWillUpdate: 'Will update {count} existing accounts.', + crsSelectedCount: '{count} new accounts selected', + crsUpdateBehaviorNote: + 'Existing accounts only sync fields returned by CRS; missing fields keep their current values. Credentials are merged by key — keys not returned by CRS are preserved. Proxies are kept when "Sync proxies" is unchecked.', + crsBack: 'Back', + editAccount: 'Edit Account', + deleteAccount: 'Delete Account', + searchAccounts: 'Search accounts...', + notes: 'Notes', + notesPlaceholder: 'Enter notes', + notesHint: 'Notes are optional', + allPlatforms: 'All Platforms', + allTypes: 'All Types', + allStatus: 'All Status', + allGroups: 'All Groups', + ungroupedGroup: 'Ungrouped', + oauthType: 'OAuth', + setupToken: 'Setup Token', + apiKey: 'API Key', + // Schedulable toggle + schedulable: 'Schedulable', + schedulableHint: 'Enable to include this account in API request scheduling', + schedulableEnabled: 'Scheduling enabled', + schedulableDisabled: 'Scheduling disabled', + failedToToggleSchedulable: 'Failed to toggle scheduling status', + groupCountTotal: '{count} groups total', + platforms: { + anthropic: 'Anthropic', + claude: 'Claude', + openai: 'OpenAI', + gemini: 'Gemini', + antigravity: 'Antigravity', + grok: 'Grok', + }, + types: { + oauth: 'OAuth', + chatgptOauth: 'ChatGPT OAuth', + responsesApi: 'Responses API', + googleOauth: 'Google OAuth', + codeAssist: 'Code Assist', + antigravityOauth: 'Antigravity OAuth', + grokOauth: 'Grok OAuth', + antigravityApikey: 'Connect via Base URL + API Key', + upstream: 'Upstream', + upstreamDesc: 'Connect via Base URL + API Key' + }, + antigravityProjectIdLabel: 'GCP Project ID (optional)', + antigravityProjectIdPlaceholder: 'your-gcp-project-id', + antigravityProjectIdHint: + 'Antigravity standard-tier accounts that do not receive an automatic project_id need a user-owned GCP project.', + status: { + active: 'Active', + inactive: 'Inactive', + error: 'Error', + cooldown: 'Cooldown', + paused: 'Paused', + limited: 'Limited', + rateLimited: 'Rate Limited', + overloaded: 'Overloaded', + tempUnschedulable: 'Temp Unschedulable', + quotaExceeded: 'Quota Exceeded', + unschedulable: 'Unschedulable', + rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}', + rateLimitedAutoResume: 'Auto resumes in {time}', + modelRateLimitedUntil: '{model} rate limited until {time}', + modelCreditOveragesUntil: '{model} using AI Credits until {time}', + creditsExhausted: 'Credits Exhausted', + creditsExhaustedUntil: 'AI Credits exhausted, expected recovery at {time}', + overloadedUntil: 'Overloaded until {time}', + viewTempUnschedDetails: 'View temp unschedulable details' + }, + columns: { + name: 'Name', + id: 'Account ID', + platformType: 'Platform/Type', + platform: 'Platform', + type: 'Type', + capacity: 'Capacity', + notes: 'Notes', + priority: 'Priority', + billingRateMultiplier: 'Billing Rate', + weight: 'Weight', + schedulerScore: 'Scheduler Score', + status: 'Status', + schedulable: 'Schedulable', + todayStats: 'Today Stats', + groups: 'Groups', + usageWindows: 'Usage Windows', + proxy: 'Proxy', + lastUsed: 'Last Used', + createdAt: 'Created', + expiresAt: 'Expires At', + actions: 'Actions' + }, + schedulerScore: { + baseShort: 'Base', + stickyShort: 'Sticky', + ungrouped: 'Ungrouped', + hint: 'Displayed as "group / base score / sticky bonus". The base score is computed within the current filtered candidate set and includes priority, load, queue depth, error rate, first-token latency, reset window, quota headroom, and related factors. The sticky bonus applies only when sticky weighting is enabled for previous_response_id or session_hash. Higher scores are preferred.' + }, + usageWindowsHint: '"5h / 7d" are the upstream account\'s official rolling usage windows (e.g. OpenAI ChatGPT, Claude). They are imposed by the upstream provider on the account itself — not configured by sub2api, and unrelated to the models you map. Usage resets automatically once each window rolls over, and the limit cannot be lifted from within sub2api.', + allPrivacyModes: 'All Privacy States', + privacyUnset: 'Unset', + privacyTrainingOff: 'Training data sharing disabled', + privacyCfBlocked: 'Blocked by Cloudflare, training may still be on', + privacyFailed: 'Failed to disable training', + privacyAntigravitySet: 'Telemetry and marketing emails disabled', + privacyAntigravityFailed: 'Privacy setting failed', + setPrivacy: 'Set Privacy', + subscriptionAbnormal: 'Abnormal', + subscriptionExpires: 'Expires', + // Capacity status tooltips + capacity: { + windowCost: { + blocked: '5h window cost exceeded, account scheduling paused', + stickyOnly: '5h window cost at threshold, only sticky sessions allowed', + normal: '5h window cost normal' + }, + sessions: { + full: 'Active sessions full, new sessions must wait (idle timeout: {idle} min)', + normal: 'Active sessions normal (idle timeout: {idle} min)' + }, + rpm: { + full: 'RPM limit reached', + warning: 'RPM approaching limit', + normal: 'RPM normal', + tieredNormal: 'RPM limit (Tiered) - Normal', + tieredWarning: 'RPM limit (Tiered) - Approaching limit', + tieredStickyOnly: 'RPM limit (Tiered) - Sticky only | Buffer: {buffer}', + tieredBlocked: 'RPM limit (Tiered) - Blocked | Buffer: {buffer}', + stickyExemptNormal: 'RPM limit (Sticky Exempt) - Normal', + stickyExemptWarning: 'RPM limit (Sticky Exempt) - Approaching limit', + stickyExemptOver: 'RPM limit (Sticky Exempt) - Over limit, sticky only' + }, + quota: { + exceeded: 'Quota exceeded, account paused', + normal: 'Quota normal' + }, + }, + tempUnschedulable: { + title: 'Temp Unschedulable', + statusTitle: 'Temp Unschedulable Status', + hint: 'Disable accounts temporarily when error code and keyword both match.', + notice: 'Rules are evaluated in order and require both error code and keyword match.', + addRule: 'Add Rule', + ruleOrder: 'Rule Order', + ruleIndex: 'Rule #{index}', + errorCode: 'Error Code', + errorCodePlaceholder: 'e.g. 429', + durationMinutes: 'Duration (minutes)', + durationPlaceholder: 'e.g. 30', + keywords: 'Keywords', + keywordsPlaceholder: 'e.g. overloaded, too many requests', + keywordsHint: 'Separate keywords with commas; any keyword match will trigger.', + description: 'Description', + descriptionPlaceholder: 'Optional note for this rule', + rulesInvalid: 'Add at least one rule with error code, keywords, and duration.', + viewDetails: 'View temp unschedulable details', + accountName: 'Account', + triggeredAt: 'Triggered At', + until: 'Until', + remaining: 'Remaining', + matchedKeyword: 'Matched Keyword', + errorMessage: 'Error Details', + reset: 'Recover State', + resetSuccess: 'Account state recovered successfully', + resetFailed: 'Failed to recover account state', + failedToLoad: 'Failed to load temp unschedulable status', + notActive: 'This account is not temporarily unschedulable.', + expired: 'Expired', + remainingMinutes: 'About {minutes} minutes', + remainingHours: 'About {hours} hours', + remainingHoursMinutes: 'About {hours} hours {minutes} minutes', + presets: { + overloadLabel: '529 Overloaded', + overloadDesc: 'Overloaded - pause 60 minutes', + rateLimitLabel: '429 Rate Limit', + rateLimitDesc: 'Rate limited - pause 10 minutes', + unavailableLabel: '503 Unavailable', + unavailableDesc: 'Unavailable - pause 30 minutes' + } + }, + clearRateLimit: 'Clear Rate Limit', + resetQuota: 'Reset Quota', + quotaLimit: 'Quota Limit', + quotaLimitPlaceholder: '0 means unlimited', + quotaLimitHint: 'Set daily/weekly/total spending limits (USD). Anthropic API key accounts can also configure client affinity. Changing limits won\'t reset usage.', + quotaLimitToggle: 'Enable Quota Limit', + quotaLimitToggleHint: 'When enabled, account will be paused when usage reaches the set limit', + quotaDailyLimit: 'Daily Limit', + quotaDailyLimitHint: 'Automatically resets every 24 hours from first usage.', + quotaWeeklyLimit: 'Weekly Limit', + quotaWeeklyLimitHint: 'Automatically resets every 7 days from first usage.', + quotaTotalLimit: 'Total Limit', + quotaTotalLimitHint: 'Cumulative spending limit. Does not auto-reset — use "Reset Quota" to clear.', + quotaResetMode: 'Reset Mode', + quotaResetModeRolling: 'Rolling Window', + quotaResetModeFixed: 'Fixed Time', + quotaResetHour: 'Reset Hour', + quotaWeeklyResetDay: 'Reset Day', + quotaResetTimezone: 'Reset Timezone', + quotaDailyLimitHintFixed: 'Resets daily at {hour}:00 ({timezone}).', + quotaWeeklyLimitHintFixed: 'Resets every {day} at {hour}:00 ({timezone}).', + dayOfWeek: { + monday: 'Monday', + tuesday: 'Tuesday', + wednesday: 'Wednesday', + thursday: 'Thursday', + friday: 'Friday', + saturday: 'Saturday', + sunday: 'Sunday', + }, + quotaLimitAmount: 'Total Limit', + quotaLimitAmountHint: 'Cumulative spending limit. Does not auto-reset.', + quotaNotify: { + alert: 'Alert', + enabled: 'Enable Alert', + threshold: 'Alert Amount', + thresholdPlaceholder: 'Enter percentage', + }, + testConnection: 'Test Connection', + reAuthorize: 'Re-Authorize', + refreshToken: 'Refresh Token', + noAccountsYet: 'No accounts yet', + createFirstAccount: 'Create your first account to start using AI services.', + tokenRefreshed: 'Token refreshed successfully', + accountDeleted: 'Account deleted successfully', + rateLimitCleared: 'Rate limit cleared successfully', + bulkSchedulableEnabled: 'Successfully enabled scheduling for {count} account(s)', + bulkSchedulableDisabled: 'Successfully disabled scheduling for {count} account(s)', + bulkSchedulablePartial: 'Scheduling updated partially: {success} succeeded, {failed} failed', + bulkSchedulableResultUnknown: 'Bulk scheduling result incomplete. Please retry or refresh.', + bulkActions: { + selected: '{count} account(s) selected', + selectCurrentPage: 'Select this page', + clear: 'Clear selection', + edit: 'Bulk Edit', + delete: 'Bulk Delete', + enableScheduling: 'Enable Scheduling', + disableScheduling: 'Disable Scheduling', + resetStatus: 'Reset Status', + refreshToken: 'Refresh Token', + resetStatusSuccess: 'Successfully reset {count} account(s) status', + refreshTokenSuccess: 'Successfully refreshed {count} account(s) token', + partialSuccess: 'Partially completed: {success} succeeded, {failed} failed' + }, + bulkEdit: { + title: 'Bulk Edit Accounts', + selectionInfo: + '{count} account(s) selected. Only checked or filled fields will be updated; others stay unchanged.', + baseUrlPlaceholder: 'https://api.anthropic.com or https://api.openai.com', + baseUrlNotice: 'Applies to API Key accounts only; leave empty to keep existing value', + submit: 'Update Accounts', + updating: 'Updating...', + success: 'Updated {count} account(s)', + partialSuccess: 'Partially updated: {success} succeeded, {failed} failed', + failed: 'Bulk update failed', + noSelection: 'Please select accounts to edit', + noFieldsSelected: 'Select at least one field to update', + mixedPlatformWarning: 'Selected accounts span multiple platforms ({platforms}). Model mapping presets shown are combined — ensure mappings are appropriate for each platform.' + }, + bulkDeleteTitle: 'Bulk Delete Accounts', + bulkDeleteConfirm: 'Delete the selected {count} account(s)? This action cannot be undone.', + bulkDeleteSuccess: 'Deleted {count} account(s)', + bulkDeletePartial: 'Partially deleted: {success} succeeded, {failed} failed', + bulkDeleteFailed: 'Bulk delete failed', + recoverState: 'Recover State', + recoverStateHint: 'Used to recover error, rate-limit, and temporary unschedulable runtime state.', + recoverStateSuccess: 'Account state recovered successfully', + recoverStateFailed: 'Failed to recover account state', + fallbackActive: 'Fallback', + fallbackActiveTip: 'Origin proxy {origin} expired', + revertProxy: 'Revert proxy', + revertProxySuccess: 'Successfully reverted to original proxy', + revertProxyFailed: 'Failed to revert proxy', + createSparkShadow: 'Create Spark Shadow', + createSparkShadowConfirm: 'Create a spark shadow account linked to "{name}"? It shares the parent\'s credentials and serves only spark models.', + createSparkShadowSuccess: 'Spark shadow account created', + createSparkShadowFailed: 'Failed to create spark shadow account', + resetStatus: 'Reset Status', + statusReset: 'Account status reset successfully', + failedToResetStatus: 'Failed to reset account status', + failedToLoad: 'Failed to load accounts', + failedToRefresh: 'Failed to refresh token', + failedToDelete: 'Failed to delete account', + failedToClearRateLimit: 'Failed to clear rate limit', + deleteConfirm: "Are you sure you want to delete '{name}'? This action cannot be undone.", + // Create/Edit Account Modal + platform: 'Platform', + accountName: 'Account Name', + enterAccountName: 'Enter account name', + accountType: 'Account Type', + claudeCode: 'Claude Code', + claudeConsole: 'Claude Console', + bedrockLabel: 'AWS Bedrock', + bedrockDesc: 'SigV4 / API Key', + vertexLabel: 'Vertex', + vertexDesc: 'Service Account', + vertexAnthropicHint: 'Use a Google Cloud Service Account JSON to call Anthropic Claude via Vertex AI. It is recommended to configure model mapping to map client Claude model names to Vertex model IDs.', + vertexGeminiHint: 'Use a Google Cloud Service Account JSON to access Vertex AI Gemini. It is recommended to place Vertex accounts in a separate group to avoid mixing with AI Studio/Gemini OAuth on the same models.', + vertexSaJsonLabel: 'Service Account JSON', + vertexSaJsonLoaded: 'Service Account JSON loaded', + vertexSaJsonDrop: 'Drop Service Account JSON here', + vertexSaJsonKeyHidden: 'Key content is not displayed in the form.', + vertexSaJsonDropHint: 'Drag a .json file here, or click the button to select one.', + vertexSaJsonSelectBtn: 'Select JSON', + vertexSaJsonUploadHint: 'After uploading or dropping a JSON file, the project_id will be auto-extracted. Key content is only used for account creation.', + vertexSaJsonEditHint: 'Service Account JSON is not shown on the edit page; to change the JSON, delete the account and recreate it.', + vertexProjectIdPlaceholder: 'Auto-extracted from JSON', + vertexLocationHint: 'Available locations vary by Vertex model. Select the default endpoint location for this account.', + vertexLocationRequired: 'Please enter a Vertex location', + vertexSaJsonMissingFields: 'Service Account JSON is missing project_id, client_email, or private_key', + vertexSaJsonMissingProjectId: 'Service Account JSON is missing project_id', + vertexSaJsonMissingClientEmail: 'Service Account JSON is missing client_email', + vertexSaJsonInvalid: 'Service Account JSON format is invalid', + vertexSaJsonRequired: 'Please upload a Service Account JSON', + oauthSetupToken: 'OAuth / Setup Token', + addMethod: 'Add Method', + setupTokenLongLived: 'Setup Token (Long-lived)', + baseUrl: 'Base URL', + baseUrlHint: 'Leave default for official Anthropic API', + apiKeyRequired: 'API Key *', + apiKeyPlaceholder: 'sk-ant-api03-...', + apiKeyHint: 'Your Claude Console API Key', + // OpenAI specific hints + openai: { + baseUrlHint: 'Leave default for official OpenAI API', + apiKeyHint: 'Your OpenAI API Key', + oauthPassthrough: 'Auto passthrough (auth only)', + oauthPassthroughDesc: + 'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.', + responsesWebsocketsV2: 'Responses WebSocket v2', + responsesWebsocketsV2Desc: + 'Disabled by default. Enable to allow responses_websockets_v2 capability (still gated by global and account-type switches).', + wsMode: 'WS mode', + wsModeDesc: 'Only applies to the current OpenAI account type.', + wsModeOff: 'Off (off)', + wsModeCtxPool: 'Context Pool (ctx_pool)', + wsModePassthrough: 'Passthrough (passthrough)', + wsModeHttpBridge: 'HTTP Bridge (http_bridge)', + wsModeShared: 'Shared (shared)', + wsModeDedicated: 'Dedicated (dedicated)', + wsModeConcurrencyHint: + 'When WS mode is enabled, account concurrency becomes the WS connection pool limit for this account.', + wsModePassthroughHint: 'Passthrough mode does not use the WS connection pool.', + oauthResponsesWebsocketsV2: 'OAuth WebSocket Mode', + oauthResponsesWebsocketsV2Desc: + 'Only applies to OpenAI OAuth. This account can use OpenAI WebSocket Mode only when enabled.', + apiKeyResponsesWebsocketsV2: 'API Key WebSocket Mode', + apiKeyResponsesWebsocketsV2Desc: + 'Only applies to OpenAI API Key. This account can use OpenAI WebSocket Mode only when enabled.', + responsesWebsocketsV2PassthroughHint: + 'Automatic passthrough is currently enabled: it only affects HTTP passthrough and does not disable WS mode.', + responsesMode: 'Responses API support', + responsesModeDesc: + 'Only applies to the OpenAI API Key text forwarding path. Auto follows probe results; force modes override probing.', + responsesModeAuto: 'Auto', + responsesModeForceResponses: 'Force Responses', + responsesModeForceChatCompletions: 'Force Chat Completions', + responsesModeTextDisabledHint: + 'Not applicable when the Responses / Chat Completions endpoint is not enabled.', + endpointCapabilities: 'Endpoint capabilities', + endpointCapabilitiesDesc: + 'Used by account routing. The text endpoint follows the Responses API support setting above and is shown as Responses, Chat Completions, or auto mode; Embeddings independently controls /v1/embeddings.', + capabilityResponses: 'Responses', + capabilityTextAuto: 'Responses / Chat Completions (Auto)', + capabilityResponsesAuto: 'Responses (auto probe)', + capabilityChatCompletions: 'Chat Completions', + capabilityChatCompletionsAuto: 'Chat Completions (auto probe)', + capabilityEmbeddings: 'Embeddings', + responsesStatusAutoSupported: 'Auto probe: Responses', + responsesStatusAutoUnsupported: 'Auto probe: Chat Completions', + responsesStatusAutoUnknown: 'Auto probe: unknown', + responsesStatusForcedResponses: 'Forced Responses', + responsesStatusForcedChatCompletions: 'Forced Chat Completions', + codexCLIOnly: 'Codex official clients only', + codexCLIOnlyDesc: + 'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.', + codexCLIOnlyAppServer: 'Allow Codex app-server clients', + codexCLIOnlyAppServerDesc: + "Effective only when the switch above is on. When enabled, this account also allows third-party clients that embed the Codex engine over the app-server protocol (e.g. Claude Code's codex plugin); they still pass the global engine-fingerprint gate. OR-combined with the global app-server toggle.", + codexImageTool: 'Codex image tool', + codexImageToolDesc: + 'One policy for the image_generation tool on Codex /responses text requests: whether it is auto-injected, and whether client-provided tools pass through. Account policy takes precedence over channel and global settings; standalone image-generation endpoints are unaffected.', + codexImageToolInherit: 'Follow channel', + codexImageToolInheritDesc: 'No account override; injection follows the channel or global policy, and client-provided image tools pass through.', + codexImageToolEnabled: 'Force inject', + codexImageToolEnabledDesc: 'Always inject the image tool for Codex /responses requests.', + codexImageToolDisabled: 'No injection', + codexImageToolDisabledDesc: 'Never auto-inject; client-provided image tools still pass through.', + codexImageToolBlock: 'Block all', + codexImageToolBlockDesc: 'No injection, and client-provided image tools plus matching tool_choice are removed.', + codexImageToolBadgeInherit: 'Channel policy', + codexImageToolBadgeEnabled: 'Force inject', + codexImageToolBadgeDisabled: 'No injection', + codexImageToolBadgeBlock: 'Blocked', + compactMode: 'Compact mode', + compactModeDesc: + 'Controls how this account participates in /responses/compact routing. Auto follows probe results, Force On always allows, Force Off always excludes.', + compactModeAuto: 'Auto', + compactModeForceOn: 'Force On', + compactModeForceOff: 'Force Off', + compactModelMapping: 'Compact-only model mapping', + compactModelMappingDesc: + 'Only applies to /responses/compact. Use this when the upstream compact endpoint requires a special compact model.', + compactSupported: 'Compact supported', + compactUnsupported: 'Compact unsupported', + compactAuto: 'Compact Auto', + compactUnknown: 'Compact Auto', + compactLastChecked: 'Last compact probe', + testMode: 'Test mode', + testModeDefault: 'Default request', + testModeCompact: 'Compact probe', + modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.', + }, + grok: { + baseUrlHint: 'Grok OAuth accounts forward to the official xAI API base URL.', + apiKeyHint: 'Grok subscription support uses OAuth refresh tokens; API keys are out of scope for this account type.' + }, + anthropic: { + apiKeyPassthrough: 'Auto passthrough (auth only)', + apiKeyPassthroughDesc: + 'Only applies to Anthropic API Key accounts. When enabled, messages/count_tokens are forwarded in passthrough mode with auth replacement only, while billing/concurrency/audit and safety filtering are preserved. Disable to roll back immediately.', + apiKeyAuthScheme: 'Upstream auth scheme', + apiKeyAuthSchemeDesc: 'Choose the API key auth header used when forwarding to an Anthropic-compatible upstream. Ollama Cloud uses Authorization: Bearer.', + apiKeyAuthSchemeXApiKey: 'x-api-key', + apiKeyAuthSchemeBearer: 'Authorization: Bearer', + webSearchEmulation: 'Web Search Emulation', + webSearchEmulationDesc: + 'Enable web search emulation for this API Key account. When a pure web_search request is detected, the gateway calls a third-party search API and constructs the response locally. Default follows channel config.', + webSearchDefault: 'Default', + webSearchEnabled: 'Enabled', + webSearchDisabled: 'Disabled', + }, + modelRestriction: 'Model Restriction (Optional)', + modelWhitelist: 'Model Whitelist', + modelMapping: 'Model Mapping', + selectAllowedModels: 'Select allowed models. Leave empty to support all models.', + mapRequestModels: + 'Map request models to actual models. Left is the requested model, right is the actual model sent to API.', + selectedModels: 'Selected {count} model(s)', + supportsAllModels: '(supports all models)', + requestModel: 'Request model', + actualModel: 'Actual model', + addMapping: 'Add Mapping', + mappingExists: 'Mapping for {model} already exists', + wildcardOnlyAtEnd: 'Wildcard * can only be at the end', + targetNoWildcard: 'Target model cannot contain wildcard *', + searchModels: 'Search models...', + noMatchingModels: 'No matching models', + fillRelatedModels: 'Sync latest supported models', + syncUpstreamModels: 'Sync upstream supported models', + syncUpstreamModelsLoading: 'Syncing upstream...', + syncUpstreamModelsSuccess: 'Synced {count} new model(s) from upstream ({total} upstream total)', + syncUpstreamModelsNoChanges: 'All {count} upstream model(s) are already in the whitelist', + syncUpstreamModelsEmpty: 'Upstream returned no models to sync', + syncUpstreamModelsFailed: 'Failed to sync upstream models', + syncUpstreamModelsError: 'Failed to sync upstream models: {message}', + clearAllModels: 'Clear all models', + customModelName: 'Custom model name', + enterCustomModelName: 'Enter custom model name', + addModel: 'Add', + modelExists: 'Model already exists', + modelCount: '{count} models', + poolMode: 'Pool Mode', + poolModeHint: 'Enable when upstream is an account pool; errors won\'t mark local account status', + poolModeInfo: + 'When enabled, upstream 429/403/401 errors will auto-retry without marking the account as rate-limited or errored. Suitable for upstream pointing to another sub2api instance.', + poolModeRetryCount: 'Same-Account Retries', + poolModeRetryCountHint: + 'Only applies in pool mode. Use 0 to disable in-place retry. Default {default}, maximum {max}.', + poolModeRetryStatusCodes: 'Retry Status Codes', + poolModeRetryStatusCodesHint: + 'Comma-separated HTTP status codes (100-599) that trigger same-account retry in pool mode. Leave blank to use defaults ({default}).', + customErrorCodes: 'Custom Error Codes', + customErrorCodesHint: 'Only stop scheduling for selected error codes', + customErrorCodesWarning: + 'Only selected error codes will stop scheduling. Other errors will return 500.', + customErrorCodes429Warning: + '429 already has built-in rate limit handling. Adding it to custom error codes will disable the account instead of temporary rate limiting. Are you sure?', + customErrorCodes529Warning: + '529 already has built-in overload handling. Adding it to custom error codes will disable the account instead of temporary overload marking. Are you sure?', + selectedErrorCodes: 'Selected', + noneSelectedUsesDefault: 'None selected (uses default policy)', + enterErrorCode: 'Enter error code (100-599)', + invalidErrorCode: 'Please enter a valid HTTP error code (100-599)', + errorCodeExists: 'This error code is already selected', + interceptWarmupRequests: 'Intercept Warmup Requests', + interceptWarmupRequestsDesc: + 'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens', + headerOverride: { + title: 'Header Override', + hint: 'Override same-named request headers on forwarding (case-insensitive)', + info: 'Applies to outbound requests of this account only: configured headers override client/gateway-generated headers of the same name before forwarding. Auth headers (authorization, x-api-key) and connection-control headers cannot be overridden.', + namePlaceholder: 'Header name (e.g. user-agent)', + valuePlaceholder: 'Override value (leave empty to skip)', + addRow: 'Add Header', + fillTemplate: 'Fill Template', + emptyValueHint: 'Rows with an empty value are placeholders and do not override anything.', + bulkDisableHint: 'Saving will disable header override and clear existing configuration on the selected accounts.', + bulkReplaceHint: 'Saving will replace the existing header override configuration on all selected accounts with the rows below.', + bulkEmptyRows: 'Add at least one header row before saving, or turn the toggle off to clear existing configuration.', + invalidName: 'Invalid header name (only letters, digits and !#$%&\'*+-.^_`|~ are allowed)', + blockedName: 'This header cannot be overridden (auth and connection-control headers are managed by the system)', + duplicateName: 'Duplicate header name (matching is case-insensitive)', + invalidValue: 'Invalid header value (control characters are not allowed; max length 8192)', + tooManyEntries: 'Too many header override entries (max 64)' + }, + autoPauseOnExpired: 'Auto Pause On Expired', + autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires', + autoPause5hThreshold: '5h Usage Threshold (%)', + autoPause7dThreshold: '7d Usage Threshold (%)', + autoPauseThresholdHint: 'Leave empty or set 0 to use the global default threshold (configured in Ops settings); set a value to override the global default. Reaching the threshold only skips the account during scheduling and does not modify schedulable.', + autoPause5hDisabled: 'Disable 5h auto-pause', + autoPause7dDisabled: 'Disable 7d auto-pause', + autoPauseDisabledHint: 'When enabled, this account is never auto-paused (even if a global default threshold is configured).', + // Quota control (Anthropic OAuth/SetupToken only) + quotaControl: { + title: 'Quota Control', + hint: 'Configure cost window, session limits, client affinity and other scheduling controls.', + windowCost: { + label: '5h Window Cost Limit', + hint: 'Limit account cost usage within the 5-hour window', + limit: 'Cost Threshold', + limitPlaceholder: '50', + limitHint: 'Account will not participate in new scheduling after reaching threshold', + stickyReserve: 'Sticky Reserve', + stickyReservePlaceholder: '10', + stickyReserveHint: 'Additional reserve for sticky sessions' + }, + sessionLimit: { + label: 'Session Count Limit', + hint: 'Limit the number of active concurrent sessions', + maxSessions: 'Max Sessions', + maxSessionsPlaceholder: '3', + maxSessionsHint: 'Maximum number of active concurrent sessions', + idleTimeout: 'Idle Timeout', + idleTimeoutPlaceholder: '5', + idleTimeoutHint: 'Sessions will be released after idle timeout' + }, + rpmLimit: { + label: 'RPM Limit', + hint: 'Limit requests per minute to protect upstream accounts', + baseRpm: 'Base RPM', + baseRpmPlaceholder: '15', + baseRpmHint: 'Max requests per minute, 0 or empty means no limit', + strategy: 'RPM Strategy', + strategyTiered: 'Tiered Model', + strategyStickyExempt: 'Sticky Exempt', + strategyTieredHint: 'Green → Yellow → Sticky only → Blocked, progressive throttling', + strategyStickyExemptHint: 'Only sticky sessions allowed when over limit', + strategyHint: 'Tiered: gradually restrict when exceeded; Sticky Exempt: existing sessions unrestricted', + stickyBuffer: 'Sticky Buffer', + stickyBufferPlaceholder: 'Default: 20% of base RPM', + stickyBufferHint: 'Extra requests allowed for sticky sessions after exceeding base RPM. Leave empty to use default (20% of base RPM, min 1)', + userMsgQueue: 'User Message Rate Control', + userMsgQueueHint: 'Rate-limit user messages to avoid triggering upstream RPM limits', + umqModeOff: 'Off', + umqModeThrottle: 'Throttle', + umqModeSerialize: 'Serialize', + }, + tlsFingerprint: { + label: 'TLS Fingerprint Simulation', + hint: 'Simulate Node.js/Claude Code client TLS fingerprint', + defaultProfile: 'Built-in Default', + randomProfile: 'Random' + }, + sessionIdMasking: { + label: 'Session ID Masking', + hint: 'When enabled, fixes the session ID in metadata.user_id for 15 minutes, making upstream think requests come from the same session' + }, + cacheTTLOverride: { + label: 'Cache TTL Override', + hint: 'Force all cache creation tokens to be billed as the selected TTL tier (5m or 1h)', + target: 'Target TTL', + targetHint: 'Select the TTL tier for billing' + }, + customBaseUrl: { + label: 'Custom Relay URL', + hint: 'Forward requests to a custom relay service. Proxy URL will be passed as a query parameter.', + urlHint: 'Relay service URL (e.g., https://relay.example.com)', + }, + clientAffinity: { + label: 'Client Affinity Scheduling', + hint: 'When enabled, new sessions prefer accounts previously used by this client to reduce account switching' + } + }, + affinityNoClients: 'No affinity clients', + affinityClients: '{count} affinity clients:', + affinitySection: 'Client Affinity', + affinitySectionHint: 'Control how clients are distributed across accounts. Configure zone thresholds to balance load.', + affinityToggle: 'Enable Client Affinity', + affinityToggleHint: 'New sessions prefer accounts previously used by this client', + affinityBase: 'Base Limit (Green Zone)', + affinityBasePlaceholder: 'Empty = no limit', + affinityBaseHint: 'Max clients in green zone (full priority scheduling)', + affinityBaseOffHint: 'No green zone limit. All clients receive full priority scheduling.', + affinityBuffer: 'Buffer (Yellow Zone)', + affinityBufferPlaceholder: 'e.g. 3', + affinityBufferHint: 'Additional clients allowed in the yellow zone (degraded priority)', + affinityBufferInfinite: 'Unlimited', + expired: 'Expired', + proxy: 'Proxy', + noProxy: 'No Proxy', + concurrency: 'Concurrency', + loadFactor: 'Load Factor', + loadFactorHint: 'Higher load factor increases scheduling frequency', + priority: 'Priority', + priorityHint: 'Lower value accounts are used first', + billingRateMultiplier: 'Billing Rate Multiplier', + billingRateMultiplierHint: '0 = free, affects account billing only', + expiresAt: 'Expires At', + expiresAtHint: 'Leave empty for no expiration', + higherPriorityFirst: 'Lower value means higher priority', + mixedScheduling: 'Use in /v1/messages', + mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling', + mixedSchedulingTooltip: + '!! WARNING !! Antigravity Claude and Anthropic Claude cannot be used in the same context. If you have both Anthropic and Antigravity accounts, enabling this option will cause frequent 400 errors. When enabled, please use the group feature to isolate Antigravity accounts from Anthropic accounts. Make sure you understand this before enabling!!', + aiCreditsBalance: 'AI Credits', + allowOverages: 'Allow Overages (AI Credits)', + allowOveragesTooltip: + 'Only use AI Credits after free quota is explicitly exhausted. Ordinary concurrent 429 rate limits will not switch to overages.', + creating: 'Creating...', + updating: 'Updating...', + accountCreated: 'Account created successfully', + accountUpdated: 'Account updated successfully', + failedToCreate: 'Failed to create account', + failedToUpdate: 'Failed to update account', + pleaseSelectStatus: 'Please select a valid account status', + mixedChannelWarningTitle: 'Mixed Channel Warning', + mixedChannelWarning: 'Warning: Group "{groupName}" contains both {currentPlatform} and {otherPlatform} accounts. Mixing different channels may cause thinking block signature validation issues, which will fallback to non-thinking mode. Are you sure you want to continue?', + pleaseEnterAccountName: 'Please enter account name', + pleaseEnterApiKey: 'Please enter API Key', + bedrockAccessKeyId: 'AWS Access Key ID', + bedrockSecretAccessKey: 'AWS Secret Access Key', + bedrockSessionToken: 'AWS Session Token', + bedrockRegion: 'AWS Region', + bedrockRegionHint: 'e.g. us-east-1, us-west-2, eu-west-1', + bedrockForceGlobal: 'Force Global cross-region inference', + bedrockForceGlobalHint: 'When enabled, model IDs use the global. prefix (e.g. global.anthropic.claude-...), routing requests to any supported region worldwide for higher availability', + bedrockAccessKeyIdRequired: 'Please enter AWS Access Key ID', + bedrockSecretAccessKeyRequired: 'Please enter AWS Secret Access Key', + bedrockRegionRequired: 'Please select AWS Region', + bedrockSessionTokenHint: 'Optional, for temporary credentials', + bedrockSecretKeyLeaveEmpty: 'Leave empty to keep current key', + bedrockAuthMode: 'Authentication Mode', + bedrockAuthModeSigv4: 'SigV4 Signing', + bedrockAuthModeApikey: 'Bedrock API Key', + bedrockApiKeyLabel: 'Bedrock API Key', + bedrockApiKeyDesc: 'Bearer Token', + bedrockApiKeyInput: 'API Key', + bedrockApiKeyRequired: 'Please enter Bedrock API Key', + bedrockApiKeyLeaveEmpty: 'Leave empty to keep current key', + apiKeyIsRequired: 'API Key is required', + leaveEmptyToKeep: 'Leave empty to keep current key', + // Upstream type + upstream: { + baseUrl: 'Upstream Base URL', + baseUrlHint: 'The address of the upstream Antigravity service, e.g., https://cloudcode-pa.googleapis.com', + apiKey: 'Upstream API Key', + apiKeyHint: 'API Key for the upstream service', + pleaseEnterBaseUrl: 'Please enter upstream Base URL', + pleaseEnterApiKey: 'Please enter upstream API Key' + }, + // OAuth flow + oauth: { + title: 'Claude Account Authorization', + authMethod: 'Authorization Method', + manualAuth: 'Manual Authorization', + cookieAutoAuth: 'Cookie Auto-Auth', + cookieAutoAuthDesc: + 'Use claude.ai sessionKey to automatically complete OAuth authorization without manually opening browser.', + sessionKey: 'sessionKey', + keysCount: '{count} keys', + batchCreateAccounts: 'Will batch create {count} accounts', + sessionKeyPlaceholder: + 'One sessionKey per line, e.g.:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...', + sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...', + howToGetSessionKey: 'How to get sessionKey', + step1: 'Login to claude.ai in your browser', + step2: 'Press F12 to open Developer Tools', + step3: 'Go to Application tab', + step4: 'Find Cookies → https://claude.ai', + step5: 'Find the row with key sessionKey', + step6: 'Copy the Value', + sessionKeyFormat: 'sessionKey usually starts with sk-ant-sid01-', + startAutoAuth: 'Start Auto-Auth', + authorizing: 'Authorizing...', + followSteps: 'Follow these steps to authorize your Claude account:', + step1GenerateUrl: 'Click the button below to generate the authorization URL', + generateAuthUrl: 'Generate Auth URL', + generating: 'Generating...', + regenerate: 'Regenerate', + step2OpenUrl: 'Open the URL in your browser and complete authorization', + openUrlDesc: + 'Open the authorization URL in a new tab, log in to your Claude account and authorize.', + proxyWarning: + 'Note: If you configured a proxy, make sure your browser uses the same proxy to access the authorization page.', + step3EnterCode: 'Enter the Authorization Code', + authCodeDesc: + 'After authorization is complete, the page will display an Authorization Code. Copy and paste it below:', + authCode: 'Authorization Code', + authCodePlaceholder: 'Paste the Authorization Code from Claude page...', + authCodeHint: 'Paste the Authorization Code copied from the Claude page', + completeAuth: 'Complete Authorization', + verifying: 'Verifying...', + pleaseEnterSessionKey: 'Please enter at least one valid sessionKey', + authFailed: 'Authorization failed', + cookieAuthFailed: 'Cookie authorization failed', + keyAuthFailed: 'Key {index}: {error}', + successCreated: 'Successfully created {count} account(s)', + batchSuccess: 'Successfully created {count} account(s)', + batchPartialSuccess: 'Partial success: {success} succeeded, {failed} failed', + batchFailed: 'Batch creation failed', + // OpenAI specific + openai: { + title: 'OpenAI Account Authorization', + followSteps: 'Follow these steps to complete OpenAI account authorization:', + step1GenerateUrl: 'Click the button below to generate the authorization URL', + generateAuthUrl: 'Generate Auth URL', + step2OpenUrl: 'Open the URL in your browser and complete authorization', + openUrlDesc: + 'Open the authorization URL in a new tab, log in to your OpenAI account and authorize.', + importantNotice: + 'Important: The page may take a while to load after authorization. Please wait patiently. When the browser address bar changes to http://localhost..., the authorization is complete.', + step3EnterCode: 'Enter Authorization URL or Code', + authCodeDesc: + 'After authorization is complete, when the page URL becomes http://localhost:xxx/auth/callback?code=...:', + authCode: 'Authorization URL or Code', + authCodePlaceholder: + 'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value', + authCodeHint: + 'You can copy the entire URL or just the code parameter value, the system will auto-detect', + failedToGenerateUrl: 'Failed to generate OpenAI auth URL', + failedToExchangeCode: 'Failed to exchange OpenAI auth code', + failedToValidateRT: 'Failed to validate refresh token', + errors: { + OPENAI_OAUTH_PROXY_REQUIRED: + 'No proxy is configured and this server could not reach OpenAI directly, so the OpenAI OAuth request failed. Select a proxy that can access OpenAI and retry; if the authorization code has expired, regenerate the authorization URL.' + }, + // Refresh Token auth + refreshTokenAuth: 'Manual RT Input', + refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', + refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line', + codexSessionAuth: 'Codex JSON / AT Batch Input', + codexSessionDesc: 'Paste Codex JSON or an accessToken. Accounts use the step 1 settings.', + codexSessionInputLabel: 'Codex JSON or accessToken', + codexSessionPlaceholder: 'Multiple lines supported, one token or JSON per line', + codexSessionHint: 'sessionToken will not be saved as refresh_token. Without refresh_token, the account expires with the accessToken expiry; import is rejected if the expiry cannot be parsed and step 1 has no expiration.', + codexSessionImportAndCreate: 'Import & Create Account', + codexSessionEmpty: 'Please enter Codex JSON or accessToken', + codexSessionImportFailed: 'Failed to import Codex account', + codexSessionImportSuccess: 'Import completed: created {created}, updated {updated}, skipped {skipped}', + codexSessionImportPartial: 'Partial success: created {created}, updated {updated}, skipped {skipped}, failed {failed}', + codexPatAuth: 'Codex Personal Access Token', + codexPatDesc: 'Enter a Codex at- personal access token. The system validates it with OpenAI whoami before creating the account.', + codexPatInputLabel: 'Codex PAT', + codexPatPlaceholder: 'at-...', + codexPatHint: 'This is a separate auth mode. It does not save refresh_token or write an OAuth access_token expiration.', + codexPatImportAndCreate: 'Validate & Create Codex PAT Account', + codexPatEmpty: 'Please enter a Codex personal access token', + codexPatImportFailed: 'Failed to create Codex PAT account', + sessionTokenAuth: 'Manual ST Input', + sessionTokenDesc: 'Enter your existing Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', + sessionTokenPlaceholder: 'Paste your Session Token...\nSupports multiple, one per line', + sessionTokenRawLabel: 'Raw Input', + sessionTokenRawPlaceholder: 'Paste /api/auth/session raw payload or Session Token...', + sessionTokenRawHint: 'You can paste full JSON. The system will auto-parse ST and AT.', + openSessionUrl: 'Open Fetch URL', + copySessionUrl: 'Copy URL', + sessionUrlHint: 'This URL usually returns AT. If sessionToken is absent, copy __Secure-next-auth.session-token from browser cookies as ST.', + parsedSessionTokensLabel: 'Parsed ST', + parsedSessionTokensEmpty: 'No ST parsed. Please check your input.', + parsedAccessTokensLabel: 'Parsed AT', + validating: 'Validating...', + validateAndCreate: 'Validate & Create Account', + pleaseEnterRefreshToken: 'Please enter Refresh Token', + pleaseEnterSessionToken: 'Please enter Session Token' + }, + grok: { + title: 'Grok Account Authorization', + followSteps: 'Follow these steps to authorize your xAI/Grok account:', + step1GenerateUrl: 'Generate the xAI authorization URL', + generateAuthUrl: 'Generate Auth URL', + step2OpenUrl: 'Open the URL in your browser and complete authorization', + openUrlDesc: 'Open the authorization URL in a new tab, sign in to xAI, and authorize API access.', + importantNotice: 'When the browser reaches the local callback URL, copy the full URL or the code query parameter back here.', + step3EnterCode: 'Enter Authorization URL or Code', + authCodeDesc: 'After authorization, paste the callback URL, query string, or authorization code:', + authCode: 'Authorization URL or Code', + authCodePlaceholder: 'Paste the full callback URL, ?code=... query string, or code value', + authCodeHint: 'Full callback URLs, query strings, and bare codes are accepted.', + refreshTokenAuth: 'Manual RT Input', + refreshTokenDesc: 'Enter existing xAI refresh token(s). Supports batch input, one per line.', + refreshTokenPlaceholder: 'Paste your xAI refresh token...\nSupports multiple, one per line', + validating: 'Validating...', + validateAndCreate: 'Validate & Create Account', + pleaseEnterRefreshToken: 'Please enter Refresh Token', + failedToGenerateUrl: 'Failed to generate Grok auth URL', + missingExchangeParams: 'Missing authorization code, state, or OAuth session', + failedToExchangeCode: 'Failed to exchange Grok authorization code', + failedToValidateRT: 'Failed to validate Grok refresh token', + oauthOnlyHint: 'Initial Grok support is OAuth subscription-backed Responses API text and reasoning traffic only.' + }, + // Gemini specific + gemini: { + title: 'Gemini Account Authorization', + followSteps: 'Follow these steps to authorize your Gemini account:', + step1GenerateUrl: 'Generate the authorization URL', + generateAuthUrl: 'Generate Auth URL', + projectIdLabel: 'Project ID (optional)', + projectIdPlaceholder: 'e.g. my-gcp-project or cloud-ai-companion-xxxxx', + projectIdHint: + 'Leave empty to auto-detect after code exchange. If auto-detection fails, fill it in and re-generate the auth URL to try again.', + howToGetProjectId: 'How to get', + step2OpenUrl: 'Open the URL in your browser and complete authorization', + openUrlDesc: + 'Open the authorization URL in a new tab, log in to your Google account and authorize.', + step3EnterCode: 'Enter Authorization URL or Code', + authCodeDesc: + 'After authorization, copy the callback URL (recommended) or just the code and paste it below.', + authCode: 'Callback URL or Code', + authCodePlaceholder: + 'Option 1 (recommended): Paste the callback URL\nOption 2: Paste only the code value', + authCodeHint: 'The system will auto-extract code/state from the URL.', + redirectUri: 'Redirect URI', + redirectUriHint: + 'This must be configured in your Google OAuth client and must match exactly.', + confirmRedirectUri: + 'I have configured this Redirect URI in the Google OAuth client (must match exactly)', + invalidRedirectUri: 'Redirect URI must be a valid http(s) URL', + redirectUriNotConfirmed: 'Please confirm the Redirect URI is configured correctly', + missingRedirectUri: 'Missing redirect URI', + failedToGenerateUrl: 'Failed to generate Gemini auth URL', + missingExchangeParams: 'Missing auth code, session ID, or state', + failedToExchangeCode: 'Failed to exchange Gemini auth code', + missingProjectId: 'GCP Project ID retrieval failed: Your Google account is not linked to an active GCP project. Please activate GCP and bind a credit card in Google Cloud Console, or manually enter the Project ID during authorization.', + modelPassthrough: 'Gemini Model Passthrough', + modelPassthroughDesc: + 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', + stateWarningTitle: 'Note', + stateWarningDesc: 'Recommended: paste the full callback URL (includes code & state).', + oauthTypeLabel: 'OAuth Type', + needsProjectId: 'Built-in OAuth (Code Assist)', + needsProjectIdDesc: 'Requires GCP project and Project ID', + noProjectIdNeeded: 'Custom OAuth (AI Studio)', + noProjectIdNeededDesc: 'Requires admin-configured OAuth client', + aiStudioNotConfiguredShort: 'Not configured', + aiStudioNotConfiguredTip: + 'AI Studio OAuth is not configured: set GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and add Redirect URI: http://localhost:1455/auth/callback (Consent screen scopes must include https://www.googleapis.com/auth/generative-language.retriever)', + aiStudioNotConfigured: + 'AI Studio OAuth is not configured: set GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and add Redirect URI: http://localhost:1455/auth/callback' + }, + // Antigravity specific + antigravity: { + title: 'Antigravity Account Authorization', + followSteps: 'Follow these steps to authorize your Antigravity account:', + step1GenerateUrl: 'Generate the authorization URL', + generateAuthUrl: 'Generate Auth URL', + step2OpenUrl: 'Open the URL in your browser and complete authorization', + openUrlDesc: 'Open the authorization URL in a new tab, log in to your Google account and authorize.', + importantNotice: + 'Important: The page may take a while to load after authorization. Please wait patiently. When the browser address bar shows http://localhost..., authorization is complete.', + step3EnterCode: 'Enter Authorization URL or Code', + authCodeDesc: + 'After authorization, when the page URL becomes http://localhost:xxx/auth/callback?code=...:', + authCode: 'Authorization URL or Code', + authCodePlaceholder: + 'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value', + authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect', + failedToGenerateUrl: 'Failed to generate Antigravity auth URL', + missingExchangeParams: 'Missing code, session ID, or state', + failedToExchangeCode: 'Failed to exchange Antigravity auth code', + // Refresh Token auth + refreshTokenAuth: 'Manual RT', + refreshTokenDesc: 'Enter your existing Antigravity Refresh Token. Supports batch input (one per line). The system will automatically validate and create accounts.', + refreshTokenPlaceholder: 'Paste your Antigravity Refresh Token...\nSupports multiple tokens, one per line', + validating: 'Validating...', + validateAndCreate: 'Validate & Create', + pleaseEnterRefreshToken: 'Please enter Refresh Token', + failedToValidateRT: 'Failed to validate Refresh Token' + } + }, // Gemini specific (platform-wide) + gemini: { + helpButton: 'Help', + helpDialog: { + title: 'Gemini Usage Guide', + apiKeySection: 'API Key Links' + }, + modelPassthrough: 'Gemini Model Passthrough', + modelPassthroughDesc: + 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', + baseUrlHint: 'Leave default for official Gemini API', + apiKeyHint: 'Your Gemini API Key (starts with AIza)', + tier: { + label: 'Account Tier', + hint: 'Tip: The system will try to auto-detect the tier first; if auto-detection is unavailable or fails, your selected tier is used as a fallback (simulated quota).', + aiStudioHint: + 'AI Studio quotas are per-model (Pro/Flash are limited independently). If billing is enabled, choose Pay-as-you-go.', + googleOne: { + free: 'Google One Free', + pro: 'Google One Pro', + ultra: 'Google One Ultra' + }, + gcp: { + standard: 'GCP Standard', + enterprise: 'GCP Enterprise' + }, + aiStudio: { + free: 'Google AI Free', + paid: 'Google AI Pay-as-you-go' + } + }, + accountType: { + oauthTitle: 'OAuth (Gemini)', + oauthDesc: 'Authorize with your Google account and choose an OAuth type.', + apiKeyTitle: 'API Key (AI Studio)', + apiKeyDesc: 'Fastest setup. Use an AIza API key.', + apiKeyNote: + 'Best for light testing. Free tier has strict rate limits and data may be used for training.', + apiKeyLink: 'Get API Key', + quotaLink: 'Quota guide' + }, + oauthType: { + builtInTitle: 'Built-in OAuth (Gemini CLI / Code Assist)', + builtInDesc: 'Uses Google built-in client ID. No admin configuration required.', + builtInRequirement: 'Requires a GCP project and Project ID.', + googleOneDesc: 'Personal account with Google One subscription quota', + codeAssistDesc: 'Enterprise-grade, requires a GCP project', + codeAssistRequirement: 'Requires an active GCP project with billing enabled', + showAdvanced: 'Show advanced options (custom OAuth Client)', + hideAdvanced: 'Hide advanced options (custom OAuth Client)', + gcpProjectLink: 'Create project', + customTitle: 'Custom OAuth (AI Studio OAuth)', + customDesc: 'Uses admin-configured OAuth client for org management.', + customRequirement: 'Admin must configure Client ID and add you as a test user.', + badges: { + recommended: 'Recommended', + highConcurrency: 'High concurrency', + individuals: 'Recommended for individuals', + noGcp: 'No GCP required', + enterprise: 'Enterprise users', + noAdmin: 'No admin setup', + orgManaged: 'Org managed', + adminRequired: 'Admin required' + } + }, + setupGuide: { + title: 'Gemini Setup Checklist', + checklistTitle: 'Checklist', + checklistItems: { + usIp: 'Use a US IP and ensure your account country is set to US.', + age: 'Account must be 18+.' + }, + activationTitle: 'One-click Activation', + activationItems: { + geminiWeb: 'Activate Gemini Web to avoid User not initialized.', + gcpProject: 'Activate a GCP project and get the Project ID for Code Assist.' + }, + links: { + countryCheck: 'Check country association', + countryChange: 'Change country association', + geminiWebActivation: 'Activate Gemini Web', + gcpProject: 'Open GCP Console' + } + }, + quotaPolicy: { + title: 'Gemini Quota & Limit Policy (Reference)', + note: 'Note: Gemini does not provide an official quota inquiry API. The "Daily Quota" shown here is an estimate simulated by the system based on account tiers for scheduling reference only. Please refer to official Google errors for actual limits.', + columns: { + channel: 'Auth Channel', + account: 'Account Status', + limits: 'Limit Policy', + docs: 'Official Docs' + }, + docs: { + codeAssist: 'Code Assist Quotas', + aiStudio: 'AI Studio Pricing', + vertex: 'Vertex AI Quotas' + }, + simulatedNote: 'Simulated quota, for reference only', + rows: { + googleOne: { + channel: 'Google One OAuth (Individuals / Code Assist for Individuals)', + limitsFree: 'Shared pool: 1000 RPD / 60 RPM', + limitsPro: 'Shared pool: 1500 RPD / 120 RPM', + limitsUltra: 'Shared pool: 2000 RPD / 120 RPM' + }, + gcp: { + channel: 'GCP Code Assist OAuth (Enterprise)', + limitsStandard: 'Shared pool: 1500 RPD / 120 RPM', + limitsEnterprise: 'Shared pool: 2000 RPD / 120 RPM' + }, + cli: { + channel: 'Gemini CLI (Official Google Login / Code Assist)', + free: 'Free Google Account', + premium: 'Google One AI Premium', + limitsFree: 'RPD ~1000; RPM ~60 (soft)', + limitsPremium: 'RPD ~1500+; RPM ~60+ (priority queue)' + }, + gcloud: { + channel: 'GCP Code Assist (gcloud auth)', + account: 'No Code Assist subscription', + limits: 'RPD ~1000; RPM ~60 (preview)' + }, + aiStudio: { + channel: 'AI Studio API Key / OAuth', + free: 'No billing (free tier)', + paid: 'Billing enabled (pay-as-you-go)', + limitsFree: 'RPD 50; RPM 2 (Pro) / 15 (Flash)', + limitsPaid: 'RPD unlimited; RPM 1000 (Pro) / 2000 (Flash) (per model)' + }, + customOAuth: { + channel: 'Custom OAuth Client (GCP)', + free: 'Project not billed', + paid: 'Project billed', + limitsFree: 'RPD 50; RPM 2 (project quota)', + limitsPaid: 'RPD unlimited; RPM 1000+ (project quota)' + } + } + }, + rateLimit: { + ok: 'Not rate limited', + unlimited: 'Unlimited', + limited: 'Rate limited {time}', + now: 'now' + } + }, + // Re-Auth Modal + reAuthorizeAccount: 'Re-Authorize Account', + claudeCodeAccount: 'Claude Code Account', + openaiAccount: 'OpenAI Account', + geminiAccount: 'Gemini Account', + antigravityAccount: 'Antigravity Account', + grokAccount: 'Grok Account', + inputMethod: 'Input Method', + reAuthorizedSuccess: 'Account re-authorized successfully', + // Test Modal + testAccountConnection: 'Test Account Connection', + account: 'Account', + readyToTest: 'Ready to test. Click "Start Test" to begin...', + connectingToApi: 'Connecting to API...', + testCompleted: 'Test completed successfully!', + testFailed: 'Test failed', + connectedToApi: 'Connected to API', + usingModel: 'Using model: {model}', + sendingTestMessage: 'Sending test message: "hi"', + sendingImageRequest: 'Sending image generation test request...', + response: 'Response:', + startTest: 'Start Test', + testing: 'Testing...', + retry: 'Retry', + copyOutput: 'Copy output', + outputCopied: 'Output copied', + startingTestForAccount: 'Starting test for account: {name}', + testAccountTypeLabel: 'Account type: {type}', + selectTestModel: 'Select Test Model', + testModel: 'Test model', + testPrompt: 'Prompt: "hi"', + imagePromptLabel: 'Image prompt', + imagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.', + imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.', + imageTestHint: 'When an image model is selected, this test sends a real image-generation request and previews the returned image below.', + imageTestMode: 'Mode: Image generation test', + imagePreview: 'Generated images:', + imageReceived: 'Received test image #{count}', + // Stats Modal + viewStats: 'View Stats', + usageStatistics: 'Usage Statistics', + last30DaysUsage: 'Last 30 days usage statistics (based on actual usage days)', + stats: { + totalCost: '30-Day Total Cost', + accumulatedCost: 'Accumulated cost', + standardCost: 'Standard', + totalRequests: '30-Day Total Requests', + totalCalls: 'Total API calls', + avgDailyCost: 'Daily Avg Cost', + basedOnActualDays: 'Based on {days} actual usage days', + avgDailyRequests: 'Daily Avg Requests', + avgDailyUsage: 'Average daily usage', + todayOverview: 'Today Overview', + cost: 'Cost', + requests: 'Requests', + tokens: 'Tokens', + highestCostDay: 'Highest Cost Day', + highestRequestDay: 'Highest Request Day', + date: 'Date', + accumulatedTokens: 'Accumulated Tokens', + totalTokens: '30-Day Total', + dailyAvgTokens: 'Daily Average', + performance: 'Performance', + avgResponseTime: 'Avg Response', + daysActive: 'Days Active', + recentActivity: 'Recent Activity', + todayRequests: 'Today Requests', + todayTokens: 'Today Tokens', + todayCost: 'Today Cost', + usageTrend: '30-Day Cost & Request Trend', + noData: 'No usage data available for this account' + }, + usageWindow: { + statsTitle: '5-Hour Window Usage Statistics', + statsTitleDaily: 'Daily Usage Statistics', + geminiProDaily: 'Pro', + geminiFlashDaily: 'Flash', + gemini3Pro: 'G3P', + gemini3Flash: 'G3F', + gemini3Image: 'G31FI', + claude: 'Claude', + grokRequests: 'Req', + grokTokens: 'Tok', + grokUnknown: 'Grok quota is unknown until the first upstream response includes xAI rate-limit headers.', + grokRetryAfter: 'Retry after {time}', + grokProbe: 'Probe', + grokProbeTooltip: 'Send a minimal xAI Responses probe and read quota headers', + grokResetUnsupported: 'Reset unsupported', + grokResetUnsupportedTooltip: 'xAI does not expose reset credits for Grok OAuth accounts', + grokNoHeaders: 'No quota headers observed', + grokLastStatus: 'Status {status}', + grokLastProbe: 'Probe {time}', + grokLastHeadersSeen: 'Headers {time}', + passiveSampled: 'Passive', + activeQuery: 'Query' + }, + openaiQuotaReset: { + count: 'Credits', + reset: 'Reset', + countTooltipLoad: 'Click to load the available reset-credit count', + countTooltipRefresh: 'Click to refresh the available reset-credit count', + resetTooltipReady: 'Consume 1 reset credit to immediately restore the window', + resetTooltipNeedQuery: 'Click Credits first to load the available count', + resetTooltipNoCredits: 'No reset credits available', + resetTooltipShadow: 'Spark shadow accounts cannot reset credits; reset on the parent account', + expiresAt: 'Expires {time}', + expiresAtFull: 'Reset credit expires at {time}', + expandExpirations: 'Expand the other {count} reset credit expiration(s)', + collapseExpirations: 'Collapse reset credit expirations', + expirationDetails: 'Reset credit expiration details', + noCreditsAvailable: 'No reset credits available', + resetSuccess: 'Reset {windows} window(s)', + confirmTitle: 'Confirm Weekly Limit Reset', + confirmMessage: 'This will consume 1 reset credit to immediately restore the current window ({count} remaining). This action cannot be undone. Continue?' + }, + tier: { + free: 'Free', + pro: 'Pro', + ultra: 'Ultra', + aiPremium: 'AI Premium', + standard: 'Standard', + basic: 'Basic', + personal: 'Personal', + unlimited: 'Unlimited' + }, + ineligibleWarning: + 'This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.', + forbidden: 'Forbidden', + forbiddenValidation: 'Verification Required', + forbiddenViolation: 'Violation Ban', + openVerification: 'Open Verification Link', + copyLink: 'Copy Link', + linkCopied: 'Link Copied', + needsReauth: 'Re-auth Required', + rateLimited: 'Rate Limited', + usageError: 'Fetch Error' + }, + + // Scheduled Tests +} diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts new file mode 100644 index 0000000000..399b9f3208 --- /dev/null +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -0,0 +1,714 @@ +export default { + availableChannels: { + title: 'Available Channels', + description: 'Aggregated view: each channel with its linked groups and supported models (wildcards expanded)', + searchPlaceholder: 'Search channels or models...', + columns: { + name: 'Channel', + status: 'Status', + billingSource: 'Billing Model Source', + groups: 'Linked Groups', + supportedModels: 'Supported Models' + }, + empty: 'No data', + noGroups: 'No linked groups', + noModels: 'No model mapping configured', + noPricing: 'Pricing not configured', + statusActive: 'Active', + statusDisabled: 'Disabled', + billingSource: { + requested: 'Requested model', + upstream: 'Upstream model', + channel_mapped: 'Channel-mapped model' + }, + pricing: { + billingMode: 'Billing Mode', + billingModeToken: 'Per Token', + billingModePerRequest: 'Per Request', + billingModeImage: 'Per Image', + inputPrice: 'Input', + outputPrice: 'Output', + cacheWritePrice: 'Cache Write', + cacheReadPrice: 'Cache Read', + imageOutputPrice: 'Image Output', + perRequestPrice: 'Per Request', + intervals: 'Tiered Pricing', + unitPerMillion: '/ 1M tokens', + unitPerRequest: '/ request' + } + }, + + // Channel Management + channels: { + title: 'Channel Management', + description: 'Manage channels and custom model pricing', + searchChannels: 'Search channels...', + createChannel: 'Create Channel', + editChannel: 'Edit Channel', + deleteChannel: 'Delete Channel', + statusActive: 'Active', + statusDisabled: 'Disabled', + allStatus: 'All Status', + groupsUnit: 'groups', + pricingUnit: 'pricing rules', + noChannelsYet: 'No Channels Yet', + createFirstChannel: 'Create your first channel to manage model pricing', + loadError: 'Failed to load channels', + createSuccess: 'Channel created', + updateSuccess: 'Channel updated', + deleteSuccess: 'Channel deleted', + createError: 'Failed to create channel', + updateError: 'Failed to update channel', + deleteError: 'Failed to delete channel', + nameRequired: 'Please enter a channel name', + duplicateModels: 'Model "{0}" appears in multiple pricing entries', + modelConflict: "Model patterns '{model1}' and '{model2}' conflict: overlapping match range. Model names are matched case-insensitively, so an existing entry already covers all case variants — no need to add the variant separately.", + mappingConflict: "Mapping source patterns '{model1}' and '{model2}' conflict: overlapping match range. Source patterns are matched case-insensitively, so an existing entry already covers all case variants.", + intervalValidation: { + negativeMin: 'Interval #{index}: minimum token count ({value}) cannot be negative', + maxPositive: 'Interval #{index}: maximum token count ({value}) must be greater than 0', + maxGreaterThanMin: 'Interval #{index}: maximum token count ({max}) must be greater than minimum token count ({min})', + negativePrice: 'Interval #{index}: {field} cannot be negative', + unboundedLast: 'Interval #{index}: an unbounded interval (empty maximum token count) must be last', + overlap: 'Intervals #{previousIndex} and #{currentIndex} overlap: previous upper bound ({previousMax}) is greater than current lower bound ({currentMin})', + price: { + inputPrice: 'input price', + outputPrice: 'output price', + cacheWritePrice: 'cache write price', + cacheReadPrice: 'cache read price', + perRequestPrice: 'per-request price' + } + }, + deleteConfirm: 'Are you sure you want to delete channel "{name}"? This cannot be undone.', + columns: { + name: 'Name', + description: 'Description', + status: 'Status', + groups: 'Groups', + pricing: 'Pricing', + createdAt: 'Created', + actions: 'Actions' + }, + billingMode: { + token: 'Token', + perRequest: 'Per Request', + image: 'Image (Per Request)' + }, + form: { + name: 'Name', + namePlaceholder: 'Enter channel name', + description: 'Description', + descriptionPlaceholder: 'Optional description', + status: 'Status', + groups: 'Associated Groups', + noGroupsAvailable: 'No groups available', + inOtherChannel: 'In "{name}"', + modelPricing: 'Model Pricing', + models: 'Models', + modelsPlaceholder: 'Type full model name and press Enter', + modelInputHint: 'Press Enter to add, supports paste for batch import.', + billingMode: 'Billing Mode', + defaultPrices: 'Default prices (fallback when no interval matches)', + inputPrice: 'Input', + outputPrice: 'Output', + cacheWritePrice: 'Cache Write', + cacheReadPrice: 'Cache Read', + cacheWritePriceShort: 'Cache W', + cacheReadPriceShort: 'Cache R', + imageTokenPrice: 'Image Output', + imageOutputPrice: 'Image Output Price', + pricePlaceholder: 'Default', + intervals: 'Context Intervals (optional)', + minTokens: 'Min', + maxTokens: 'Max', + inclusive: '(inclusive)', + addInterval: 'Add Interval', + requestTiers: 'Request Tiers', + imageTiers: 'Image Tiers (Per Request)', + addTier: 'Add Tier', + noTiersYet: 'No tiers yet. Click add to configure per-request pricing.', + noPricingRules: 'No pricing rules yet. Click "Add" to create one.', + perRequestPrice: 'Price per Request', + perRequestPriceRequired: 'Per-request price or billing tiers required for per-request/image billing mode', + tierLabel: 'Tier', + resolution: 'Resolution', + modelMapping: 'Model Mapping', + modelMappingHint: 'Map request model names to actual model names. Runs before account-level mapping.', + noMappingRules: 'No mapping rules. Click "Add" to create one.', + mappingSource: 'Source model', + mappingTarget: 'Target model', + billingModelSource: 'Billing Model', + billingModelSourceChannelMapped: 'Bill by channel-mapped model', + billingModelSourceRequested: 'Bill by requested model', + billingModelSourceUpstream: 'Bill by final upstream model', + billingModelSourceHint: 'Controls which model name is used for pricing lookup', + selectedCount: '{count} selected', + searchGroups: 'Search groups...', + noGroupsMatch: 'No groups match your search', + restrictModels: 'Restrict Models', + restrictModelsHint: 'When enabled, only models in the pricing list are allowed. Others will be rejected.', + defaultPerRequestPrice: 'Default per-request price (fallback when no tier matches)', + defaultImagePrice: 'Default image price (fallback when no tier matches)', + platformConfig: 'Platform Configuration', + webSearchEmulation: 'Web Search Emulation', + webSearchEmulationHint: '⚠️ When enabled, all accounts in this channel\'s Anthropic groups will intercept web_search requests. Use with caution.', + webSearchEmulationGlobalDisabled: 'Please enable the global switch first in Settings → Gateway → Web Search Emulation', + codexImageGenerationBridge: 'Codex Image Generation Bridge', + codexImageGenerationBridgeHint: 'When enabled, Codex /responses text requests in OpenAI groups may be automatically given the image_generation tool. Keep off unless the routed accounts support image generation.', + bedrockCCCompat: 'Bedrock CC Compatibility', + bedrockCCCompatHint: '⚠️ When enabled, requests to Bedrock accounts in this channel will be transformed for Claude Code compatibility (thinking type conversion, tool_use ID sanitization).', + basicSettings: 'Basic Settings', + addPlatform: 'Add Platform', + noPlatforms: 'Click "Add Platform" to start configuring the channel', + mappingCount: 'mappings', + pricingEntry: 'Pricing Entry', + noModels: 'No models added', + applyPricingToAccountStats: 'Apply Pricing to Account Stats', + applyPricingToAccountStatsDesc: 'When enabled, requests not matched by custom rules will use standard model pricing for account stats calculation', + accountStatsPricingRules: 'Custom Account Stats Pricing Rules', + addRule: 'Add Rule', + noRulesConfigured: 'No custom rules configured. Channel model pricing above will be used.', + ruleName: 'Rule name (optional)', + ruleGroups: 'Groups', + ruleAccounts: 'Accounts', + searchAccountPlaceholder: 'Search accounts...', + ruleAccountsHint: 'Leave empty to match all accounts', + ruleModelPricing: 'Model Pricing', + noGroupsInChannel: 'No groups selected in platform tabs above', + unnamed: 'Unnamed', + syncLatestModels: 'Sync Latest Models', + syncingModels: 'Syncing...', + syncModelsSuccess: 'Synced {count} new model(s)', + syncModelsAlreadyUpToDate: 'Models already up to date', + syncModelsError: 'Failed to sync models' + } + }, + + riskControl: { + title: 'Risk Control', + description: 'Configure content moderation and review audit records', + loadFailed: 'Failed to load risk control', + saveFailed: 'Failed to save content moderation config', + logsFailed: 'Failed to load audit records', + saved: 'Content moderation config saved', + refresh: 'Refresh', + config: 'Content Moderation Config', + configHint: 'Use OpenAI Moderations to score request content and handle threshold hits by mode.', + openSettings: 'Moderation Settings', + settingsTitle: 'Content Moderation Settings', + refreshStatus: 'Refresh Status', + records: 'Audit Records', + recordsHint: 'Shows hits, blocks, errors, and sampled records.', + saveConfig: 'Save Moderation Config', + statusFailed: 'Failed to load runtime status', + enabled: 'Enable Content Moderation', + enabledHint: 'When off, gateway requests are not moderated even if the menu is enabled.', + mode: 'Global Mode', + modePreBlock: 'Pre-Block', + modePreBlockDesc: 'Synchronously reviews the latest user input before every request and rejects hits immediately.', + modeObserve: 'Observe Only', + modeObserveDesc: 'Requests pass through while the latest user input is queued for async review; hits are recorded, notified, and counted.', + modeOff: 'Off', + modeOffDesc: 'Content moderation is disabled and no audit records are written.', + baseUrl: 'OpenAI Base URL', + model: 'Model', + apiKey: 'OpenAI API Key', + apiKeys: 'OpenAI API Keys', + apiKeyCount: '{count} keys', + apiKeyPlaceholder: 'Enter API Key', + apiKeysPlaceholder: 'Add API Keys, one per line. They will be appended on save.', + apiKeysPlaceholderReplace: 'Replace API Keys, one per line. Stored keys will be replaced on save.', + apiKeysPlaceholderKeep: 'Add API Keys, one per line. They will be appended on save.', + apiKeysHint: '{count} keys are currently stored. This input only adds keys; save appends and de-duplicates them.', + apiKeysWriteMode: 'Write mode', + apiKeysModeAppend: 'Add', + apiKeysModeReplace: 'Replace', + apiKeysModeAppendHint: 'Default: save appends input keys and keeps stored keys.', + apiKeysModeReplaceHint: 'Replace mode: save replaces all stored keys with input keys.', + apiKeysReplaceWarning: 'Replace mode', + apiKeysReplaceNoInput: 'Replace mode requires at least 1 API Key', + apiKeyPlaceholderKeep: 'Leave empty to keep current key', + apiKeyWillClear: 'Configured key will be cleared on save', + apiKeyConfigured: 'Configured', + apiKeyTemporary: 'Pending', + apiKeyPendingDelete: 'Pending delete', + apiKeyPendingDeleteCount: '{count} keys pending deletion', + deleteApiKey: 'Delete this key', + undoDeleteApiKey: 'Undo delete', + inputApiKeyCount: '{count} keys in input', + storedApiKeyCount: '{count} stored keys', + testInputApiKeys: 'Test input keys', + testStoredApiKeys: 'Test stored keys', + testContentWithStoredApiKey: 'Test content with stored key', + testingApiKeys: 'Testing', + apiKeyTestNoInput: 'Enter OpenAI API Keys to test first', + apiKeyTestDone: 'Key test completed for {count} keys', + apiKeyTestFailed: 'Failed to test OpenAI API Keys', + apiKeyHealth: 'Key Availability', + apiKeyFreezeRule: '400 does not freeze; 401/403 freeze for 10 minutes; 429/529 freeze for 1 minute; other HTTP errors freeze for 10 seconds.', + apiKeyRows: '{count} keys', + apiKeyRowsCollapsed: '{count} keys hidden', + apiKeyRowsExpanded: 'Showing all {count} keys', + expandApiKeyRows: 'Expand', + collapseApiKeyRows: 'Collapse', + apiKeyHealthEmpty: 'No key status yet', + apiKeyHealthEmptyHint: 'Save keys or test input keys to see availability.', + apiKeyStatusOk: 'Available', + apiKeyStatusError: 'Error', + apiKeyStatusFrozen: 'Frozen', + apiKeyStatusUnknown: 'Untested', + apiKeyFailureCount: '{count} failures', + apiKeyLatency: '{ms} ms', + apiKeyHTTPStatus: 'HTTP {status}', + apiKeyFrozenUntil: 'Frozen until {time}', + apiKeyLastChecked: 'Checked at {time}', + apiKeyNotTested: 'Not tested', + auditTestInput: 'Audit Test Input', + auditTestInputHint: 'Enter a prompt and upload or paste images; images are sent as base64 and are not stored.', + auditTestPromptPlaceholder: 'Enter a user prompt to test; leave empty to only test key availability.', + auditTestImages: 'Test Images', + auditTestImagesHint: 'Upload, drag, or paste images. Up to 1 image, 8MB each.', + addAuditTestImage: 'Add image', + clearAuditTest: 'Clear test', + auditTestImageLimit: 'You can add up to {count} test images', + auditTestImageTooLarge: 'Each test image must be 8MB or smaller', + auditTestImageReadFailed: 'Failed to read test image', + auditTestResult: 'Audit Test Result', + auditTestHighest: 'Top category {category}, score {score}', + auditTestComposite: 'Composite score', + auditTestFlagged: 'Threshold hit', + auditTestPassed: 'Pass', + notConfigured: 'Not configured', + clearApiKey: 'Clear stored key', + keepApiKey: 'Keep stored key', + timeoutMs: 'HTTP Timeout (ms)', + retryCount: 'Retry Count', + sampleRate: 'Sample Rate', + recordNonHits: 'Record Non-Hits', + recordNonHitsHint: 'When enabled, sampled non-hit request summaries are redacted before storage.', + preHashCheck: 'Enable Pre-Hash Check', + preHashCheckHint: 'Hashes from async hits are blocked before moderation; this does not send email or increment ban counters.', + flaggedHashCount: 'Current hash collection size: {count}', + flaggedHashHint: 'Hashes are stored permanently in Redis; paste a full 64-character hash to remove a false block, or clear all stored hashes.', + flaggedHashPlaceholder: 'Paste full 64-character input hash', + deleteFlaggedHash: 'Delete hash', + clearFlaggedHashes: 'Clear all', + clearFlaggedHashesConfirm: 'Clear all risk input hashes? This does not delete audit records, but removes all historical hash blocks.', + flaggedHashDeleted: 'Risk hash deleted', + flaggedHashNotFound: 'Risk hash not found', + flaggedHashDeleteFailed: 'Failed to delete risk hash', + flaggedHashesCleared: 'Cleared {count} risk hashes', + flaggedHashesClearFailed: 'Failed to clear risk hashes', + workerCount: 'Worker Count', + queueSize: 'Async Queue Size', + blockStatus: 'Block HTTP Status', + blockMessage: 'Custom Block Message', + defaultBlockMessage: 'Content audit matched a risk rule. Please adjust your input and try again.', + emailOnHit: 'Email on Hit', + emailOnHitHint: 'When enabled, send a risk-control email on every hit; auto-ban notices are always sent.', + autoBan: 'Auto Ban User', + autoBanHint: 'Disable the user, invalidate auth cache, and send a ban notice after the hit threshold is reached.', + cyberPolicyExcludeBan: 'Exclude Cyber Policy Hits from Ban Count', + cyberPolicyExcludeBanHint: 'When enabled, cyber_policy hits no longer count toward auto-ban violations: no ban judgment on the hit itself, and history rows are excluded from the rolling count. Logs and notice emails are unaffected.', + violationNotCounted: 'Not counted', + banThreshold: 'Ban Threshold', + violationWindowHours: 'Count Window (hours)', + hitRetentionDays: 'Hit Record Retention (days)', + nonHitRetentionDays: 'Non-Hit Record Retention (days, max 3)', + violationCount: '{count} hits', + emailSent: 'Email sent', + emailNotSent: 'No email', + autoBanned: 'Banned', + unbanUser: 'Unban', + unbanSuccess: 'User has been unbanned', + unbanFailed: 'Failed to unban user', + inputDetailTitle: 'Input Summary Detail', + inputDetailContent: 'Full Content', + matchedKeyword: 'Matched Keyword', + queueDelay: 'Queued {ms} ms', + allGroups: 'All Groups', + allGroupsHint: 'Auditing all groups', + selectedGroupsHint: 'Auditing selected groups', + groupScope: 'Audit Groups', + groupScopeHint: 'Switch on for all groups, or turn off to choose specific groups.', + selectedGroups: 'Selected Groups', + searchGroups: 'Search group name or platform', + noGroups: 'No groups available', + modelFilter: 'Model scope', + modelFilterHint: 'Moderate by the client-requested model name; channel model mappings do not change this match.', + modelFilterAll: 'All models', + modelFilterAllDesc: 'All model requests go through content moderation.', + modelFilterInclude: 'Only selected', + modelFilterIncludeDesc: 'Only listed models go through content moderation.', + modelFilterExclude: 'Exclude selected', + modelFilterExcludeDesc: 'Listed models skip content moderation; other models are moderated.', + modelFilterModels: 'Model list', + modelFilterModelCount: '{count} models configured', + modelFilterModelsRequired: 'This model scope requires at least 1 model', + modelFilterAllSummary: 'Applies to all models', + modelFilterIncludeSummary: 'Applies to {count} models', + modelFilterExcludeSummary: 'Excludes {count} models', + emptyLogs: 'No audit records', + preBlockSyncStatus: 'Pre-Block Sync Status', + preBlockSyncHint: 'Live counters for the synchronous moderation path, excluding async record tasks.', + preBlockActive: 'Sync Processing', + preBlockActiveHint: 'Currently checking', + preBlockChecked: 'Checked', + preBlockCheckedHint: 'Entered pre-block path', + preBlockAllowed: 'Allowed', + preBlockAllowedHint: 'No block triggered', + preBlockBlocked: 'Blocked', + preBlockBlockedHint: 'Rejected after hit', + preBlockErrors: 'Audit Errors', + preBlockErrorsHint: 'Failed or no usable key', + preBlockAvgLatency: 'Avg Latency', + preBlockAvgLatencyHint: 'Synchronous path average', + preBlockAPIKeyLoad: 'Audit Key Load', + preBlockAPIKeyLoadHint: 'Synchronous pre-block checks round-robin usable audit keys directly.', + preBlockAPIKeyLoadSummary: 'Sync active {active} / usable keys {available}, {total} total, worker: {workerActive} / {workerTotal}', + preBlockAPIKeyTotals: 'Total {total}, success {success}, errors {errors}', + preBlockAPIKeyLoadEmpty: 'No audit key load data yet', + preBlockKeyActiveShort: 'Active', + preBlockKeyTotalShort: 'Total', + preBlockKeyAvgShort: 'Avg', + preBlockKeyLastShort: 'Last', + workerStatus: 'Worker Runtime', + workerStatusHint: 'Queue and worker pool status for async audit tasks and pre-block record tasks, excluding synchronous pre-block checks.', + workerPool: 'Worker Pool', + workerPoolMeta: '{active} processing, {idle} idle and ready, {total} total', + queueUsage: 'Queue Usage', + activeWorkers: 'Processing', + idleWorkers: 'Idle Ready', + workerActive: 'Processing an async audit or record task', + workerIdle: 'Started, idle and ready', + workerDisabled: 'Risk control or content audit is disabled', + processed: 'Processed', + droppedErrors: 'Dropped / Errors', + autoRefresh: 'Auto refresh every 15s', + lastCleanup: 'Last cleanup: {time}', + cleanupStats: 'Last cleanup deleted {hit} hits and {nonHit} non-hits', + riskSwitchOff: 'System switch off', + riskThresholds: 'Risk Thresholds', + riskThresholdsHint: 'Adjust hit thresholds by OpenAI Moderations category. Scores greater than or equal to the threshold count as hits.', + riskThresholdDefault: 'Default {value}', + riskThresholdReset: 'Restore defaults', + riskThresholdPercent: 'Threshold percentage', + tabs: { + basic: 'Basic', + scope: 'Scope', + runtime: 'Runtime', + response: 'Hit Notice', + riskThresholds: 'Risk Thresholds', + keywords: 'Keyword Block', + retention: 'Retention', + }, + blockedKeywords: 'Blocked keywords', + blockedKeywordsPlaceholder: 'One keyword per line, e.g.:\nbadword1\nbadword2', + blockedKeywordsDescription: 'Matching is case-insensitive. Whether the upstream moderation API is invoked after a hit depends on the strategy below.', + blockedKeywordsPreBlockHint: 'Keyword blocking only takes effect in "Pre-block" mode.', + blockedKeywordsModeWarning: 'Current mode is "{mode}". Keyword blocking will not run until you switch to "Pre-block" mode.', + blockedKeywordCount: '{count} keywords configured', + blockedKeywordsLimit: 'Up to {max} keywords, each no longer than 200 characters. Duplicates are removed automatically.', + keywordBlockingMode: 'Moderation strategy', + keywordModeKeywordAndApi: 'Keyword + API', + keywordModeKeywordAndApiDesc: 'Block on keyword hit; otherwise fall through to the upstream moderation API.', + keywordModeKeywordOnly: 'Keyword only', + keywordModeKeywordOnlyDesc: 'Decide using keywords only; misses are allowed without calling the API, saving upstream cost.', + keywordModeKeywordOnlyNotice: 'Keyword-only strategy: requests that do not match any keyword are allowed without calling the upstream moderation API.', + keywordModeApiOnly: 'API only', + keywordModeApiOnlyDesc: 'Use the upstream moderation API only; the keyword list configured here is not consulted.', + keywordModeApiOnlyNotice: 'API-only strategy: the keyword list is not consulted; all requests go through the upstream moderation API.', + overview: { + status: 'Status', + enabled: 'Enabled', + disabled: 'Disabled', + apiKey: 'API Key', + groupScope: 'Scope', + logs: 'Audit Records', + currentFilter: 'Current filter', + }, + filters: { + search: 'Search user/key/summary', + from: 'From', + to: 'To', + allGroups: 'All Groups', + allEndpoints: 'All Endpoints', + }, + table: { + time: 'Time', + group: 'Group', + user: 'User', + apiKey: 'API Key', + endpoint: 'Endpoint', + result: 'Result', + highest: 'Highest', + actionMeta: 'Action', + latency: 'Latency', + input: 'Input Summary', + }, + result: { + all: 'All Results', + hit: 'Hit', + blocked: 'Blocked', + pass: 'Pass', + error: 'Error', + }, + action: { + block: 'Blocked', + keywordBlock: 'Keyword Blocked', + cyberPolicy: 'Cyber policy', + error: 'Error', + }, + }, + + // Channel Monitor + channelMonitor: { + title: 'Channel Monitor', + description: 'Monitor channel availability, latency and status', + searchPlaceholder: 'Search monitor name...', + allProviders: 'All Providers', + allStatus: 'All Status', + enabledFilter: 'Enabled', + onlyEnabled: 'Enabled only', + onlyDisabled: 'Disabled only', + createButton: 'Create Monitor', + createTitle: 'Create Channel Monitor', + editTitle: 'Edit Channel Monitor', + runNow: 'Run Now', + runSuccess: 'Check completed', + runFailed: 'Check failed', + apiKeyDecryptFailed: 'API Key decryption failed. Please re-edit this monitor with a fresh key.', + createSuccess: 'Monitor created', + updateSuccess: 'Monitor updated', + deleteSuccess: 'Monitor deleted', + loadError: 'Failed to load monitors', + deleteConfirm: 'Are you sure you want to delete monitor "{name}"? This action cannot be undone.', + nameRequired: 'Please enter a monitor name', + primaryModelRequired: 'Please enter a primary model', + columns: { + name: 'Name', + provider: 'Provider', + primaryModel: 'Primary Model', + availability7d: '7d Availability', + latency: 'Latency (ms)', + enabled: 'Enabled', + actions: 'Actions' + }, + form: { + name: 'Name', + namePlaceholder: 'Enter monitor name', + provider: 'Platform', + apiMode: 'OpenAI protocol', + apiModeChatCompletions: 'OpenAI Compatible', + apiModeChatCompletionsHint: 'Use /v1/chat/completions with messages; works for most compatible providers.', + apiModeResponses: 'Responses API', + apiModeResponsesHint: 'Use /v1/responses with default instructions + input; best for self-check/Codex paths.', + endpoint: 'Endpoint', + endpointPlaceholder: 'https://api.example.com', + useCurrentDomain: 'Use current service', + apiKey: 'API Key', + apiKeyPlaceholder: 'Enter API Key', + apiKeyEditPlaceholder: 'Leave blank to keep current key', + useMyKey: 'Use my key', + selectKeyTitle: 'Select my API Key', + selectKeyHint: 'Only your active, non-expired keys are listed.', + noActiveKey: 'No active API keys available', + primaryModel: 'Primary Model', + primaryModelPlaceholder: 'gpt-4o-mini', + extraModels: 'Extra Models', + extraModelsPlaceholder: 'Press Enter to add extra model', + groupName: 'Group Name', + groupNamePlaceholder: 'Optional, used to group rows in user view', + intervalSeconds: 'Interval (seconds)', + intervalSecondsHint: 'Range: 15 - 3600 seconds', + jitterSeconds: 'Random Jitter (± seconds)', + jitterSecondsHint: 'Each check fires at interval ± a random offset within this value; 0 means fixed interval. Interval minus jitter must be ≥ 15s', + enabled: 'Enable monitor', + kindRequired: 'Please select a provider' + }, + runResultTitle: 'Check Result', + noMonitorsYet: 'No monitors yet', + createFirstMonitor: 'Create your first monitor to track channel availability', + advanced: { + section: 'Advanced (optional)', + sectionHint: 'Customize request headers and body to bypass upstream client-detection (e.g. "only Claude Code clients allowed").', + headers: 'Custom request headers', + headersPlaceholder: 'User-Agent: claude-cli/1.0.83 (external, cli)\nx-app: cli\nanthropic-beta: claude-code-20250219', + headerNamePlaceholder: 'Header name', + headerValuePlaceholder: 'Value', + headerAddRow: 'Add header', + headerNameInvalid: 'Header name cannot contain whitespace or colon: {name}', + headersHint: 'Merged on top of adapter defaults (user wins). Hop-by-hop headers (Host / Content-Length / ...) are ignored.', + headersParseError: 'Cannot parse line: {line}', + bodyMode: 'Body handling', + bodyModeOff: 'Default', + bodyModeMerge: 'Merge', + bodyModeReplace: 'Replace', + bodyModeHintOff: 'Use the adapter default body (includes challenge validation).', + bodyModeHintMerge: 'Shallow-merge with the default body; user fields win but model / messages / contents are protected (use Replace to change those).', + bodyModeHintReplace: 'Use the JSON below as the complete body. Challenge validation is skipped; HTTP 2xx + non-empty response text is treated as operational.', + bodyJson: 'Body JSON', + bodyJsonFormat: 'Format', + bodyJsonHint: 'Parsed on blur. Empty means no override.', + bodyJsonError: 'JSON parse failed', + bodyJsonObjectError: 'Body must be a JSON object (no arrays or primitives)' + }, + templateField: { + label: 'Request template', + none: 'No template', + placeholder: 'Pick a template (filtered by current provider)', + applyHint: 'Picking a template copies its headers and body to this monitor (snapshot). Later template edits are not auto-synced.' + }, + template: { + manageButton: 'Templates', + managerTitle: 'Request template manager', + createButton: 'New template', + emptyState: 'No templates for this provider yet', + missingName: 'Template name is required', + createSuccess: 'Template created', + updateSuccess: 'Template updated', + deleteSuccess: 'Template deleted', + applyButton: 'Apply to monitors', + applyTooltip: 'Overwrite snapshot fields on associated monitors', + applyTitle: 'Apply template', + applyConfirm: 'Apply', + applyConfirmMessage: 'Overwrite {n} associated monitor(s) with the current configuration of "{name}"? Any local customizations on those monitors will be discarded.', + applySuccess: 'Applied to {n} monitor(s)', + applyPickerTitle: 'Apply template "{name}"', + applyPickerHint: 'Select which monitors to overwrite (all selected by default). Any local customizations will be discarded.', + applyPickerEmpty: 'No monitors are currently associated to this template', + applyPickerConfirm: 'Apply to {n} monitor(s)', + selectNone: 'Select none', + selectedCount: 'Selected {n} / {total}', + deleteConfirm: 'Delete template "{name}"? {n} associated monitor(s) will be disassociated but keep their current snapshot and continue running.', + associatedCount: '{n} associated monitor(s)', + headersSummary: '{n} custom header(s)', + form: { + name: 'Template name', + namePlaceholder: 'e.g. Claude Code mimicry', + description: 'Description', + descriptionPlaceholder: 'Optional: what this template is for, capture date, etc.' + } + } + }, + + // Subscriptions + subscriptions: { + title: 'Subscription Management', + description: 'Manage user subscriptions and quota limits', + assignSubscription: 'Assign Subscription', + adjustSubscription: 'Adjust Subscription', + revokeSubscription: 'Revoke Subscription', + restoreSubscription: 'Restore Subscription', + allStatus: 'All Status', + allGroups: 'All Groups', + allPlatforms: 'All Platforms', + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + noLimits: 'No limits configured', + unlimited: 'Unlimited', + resetNow: 'Resetting soon', + windowNotActive: 'Window not active', + resetInMinutes: 'Resets in {minutes}m', + resetInHoursMinutes: 'Resets in {hours}h {minutes}m', + resetInDaysHours: 'Resets in {days}d {hours}h', + quotaEndsInMinutes: 'Quota ends in {minutes}m', + quotaEndsInHoursMinutes: 'Quota ends in {hours}h {minutes}m', + quotaEndsInDaysHours: 'Quota ends in {days}d {hours}h', + daysRemaining: 'days remaining', + remainingDays: 'Remaining days', + noExpiration: 'No expiration', + status: { + active: 'Active', + expired: 'Expired', + revoked: 'Revoked', + suspended: 'Suspended' + }, + columns: { + user: 'User', + group: 'Group', + usage: 'Usage', + expires: 'Expires', + status: 'Status', + actions: 'Actions' + }, + form: { + user: 'User', + group: 'Subscription Group', + validityDays: 'Validity (Days)', + adjustDays: 'Adjust by (Days)' + }, + selectUser: 'Select a user', + selectGroup: 'Select a subscription group', + groupHint: 'Only groups with subscription billing type are shown', + validityHint: 'Number of days the subscription will be valid', + adjustingFor: 'Adjusting subscription for', + currentExpiration: 'Current expiration', + adjustDaysPlaceholder: 'Positive to extend, negative to shorten', + adjustHint: 'Enter positive number to extend, negative to shorten (remaining days must be > 0)', + assign: 'Assign', + assigning: 'Assigning...', + adjust: 'Adjust', + adjusting: 'Adjusting...', + revoke: 'Revoke', + restore: 'Restore', + resetQuota: 'Reset Quota', + resetQuotaTitle: 'Reset Usage Quota', + resetQuotaConfirm: "Reset the daily, weekly, and monthly usage quota for '{user}'? Usage will be zeroed and windows restarted from today.", + quotaResetSuccess: 'Quota reset successfully', + failedToResetQuota: 'Failed to reset quota', + noSubscriptionsYet: 'No subscriptions yet', + assignFirstSubscription: 'Assign a subscription to get started.', + subscriptionAssigned: 'Subscription assigned successfully', + subscriptionAdjusted: 'Subscription adjusted successfully', + subscriptionRevoked: 'Subscription revoked successfully', + subscriptionRestored: 'Subscription restored successfully', + failedToLoad: 'Failed to load subscriptions', + failedToAssign: 'Failed to assign subscription', + failedToAdjust: 'Failed to adjust subscription', + failedToRevoke: 'Failed to revoke subscription', + failedToRestore: 'Failed to restore subscription', + adjustWouldExpire: 'Remaining days after adjustment must be greater than 0', + adjustOutOfRange: 'Adjustment days must be between -36500 and 36500', + pleaseSelectUser: 'Please select a user', + pleaseSelectGroup: 'Please select a group', + validityDaysRequired: 'Please enter a valid number of days (at least 1)', + revokeConfirm: + "Are you sure you want to revoke the subscription for '{user}'? You can restore it later from the revoked list.", + restoreConfirm: + "Restore the subscription for '{user}'? If the original subscription has expired, it will be restored as expired.", + guide: { + title: 'Subscription Management Guide', + subtitle: 'Subscription mode lets you assign time-based usage quotas to users, with daily/weekly/monthly limits. Follow these steps to get started.', + showGuide: 'Usage Guide', + step1: { + title: 'Create a Subscription Group', + line1: 'Go to "Group Management" page, click "Create Group"', + line2: 'Set billing type to "Subscription", configure daily/weekly/monthly quota limits', + line3: 'Save the group and ensure its status is "Active"', + link: 'Go to Group Management' + }, + step2: { + title: 'Assign Subscription to User', + line1: 'Click the "Assign Subscription" button in the top right', + line2: 'Search for a user by email and select them', + line3: 'Choose a subscription group, set validity days, then click "Assign"' + }, + step3: { + title: 'Manage Existing Subscriptions' + }, + actions: { + adjust: 'Adjust', + adjustDesc: 'Extend or shorten the subscription validity period', + resetQuota: 'Reset Quota', + resetQuotaDesc: 'Reset daily/weekly/monthly usage to zero', + revoke: 'Revoke', + revokeDesc: 'Immediately terminate the subscription (restorable from the revoked list)' + }, + tip: 'Tip: Only groups with billing type "Subscription" and status "Active" appear in the group dropdown. If no options are available, create one in Group Management first.' + } + }, + + // Accounts +} diff --git a/frontend/src/i18n/locales/en/admin/index.ts b/frontend/src/i18n/locales/en/admin/index.ts new file mode 100644 index 0000000000..e4e9bba438 --- /dev/null +++ b/frontend/src/i18n/locales/en/admin/index.ts @@ -0,0 +1,15 @@ +import overview from './overview' +import channels from './channels' +import accounts from './accounts' +import resources from './resources' +import ops from './ops' +import settings from './settings' + +export default { + ...overview, + ...channels, + ...accounts, + ...resources, + ...ops, + ...settings, +} diff --git a/frontend/src/i18n/locales/en/admin/ops.ts b/frontend/src/i18n/locales/en/admin/ops.ts new file mode 100644 index 0000000000..1bcbc42775 --- /dev/null +++ b/frontend/src/i18n/locales/en/admin/ops.ts @@ -0,0 +1,803 @@ +export default { + ops: { + title: 'Ops Monitoring', + description: 'Operational monitoring and troubleshooting', + // Dashboard + systemHealth: 'System Health', + overview: 'Overview', + noSystemMetrics: 'No system metrics collected yet.', + collectedAt: 'Collected at:', + window: 'window', + memory: 'Memory', + db: 'DB', + goroutines: 'Goroutines', + jobs: 'Jobs', + jobsHelp: 'Click “Details” to view job heartbeats and recent errors', + active: 'active', + idle: 'idle', + waiting: 'waiting', + conns: 'conns', + queue: 'queue', + accountSwitches: 'Account switches', + ok: 'ok', + lastRun: 'last_run:', + lastSuccess: 'last_success:', + lastError: 'last_error:', + noData: 'No data.', + loadingText: 'loading', + ready: 'ready', + autoRefreshRemaining: 'Remaining {seconds}s', + systemLogs: { + title: 'System Logs', + description: 'Newest logs are shown first. Filter, search, and clean up by condition.', + queue: 'Queue', + written: 'Written', + dropped: 'Dropped', + failed: 'Failed', + runtimeConfig: 'Runtime Log Configuration (applies immediately)', + all: 'All', + level: 'Level', + stacktraceThreshold: 'Stacktrace threshold', + samplingInitial: 'Sampling initial', + samplingThereafter: 'Sampling thereafter', + retentionDays: 'Retention days', + caller: 'caller', + sampling: 'sampling', + saveAndApply: 'Save and apply', + resetDefaults: 'Reset defaults', + latestWriteError: 'Latest write error:', + timeRange: 'Time range', + startTime: 'Start time (optional)', + endTime: 'End time (optional)', + component: 'Component', + componentPlaceholder: 'e.g. http.access', + keyId: 'KEY ID', + platform: 'Platform', + model: 'Model', + keyword: 'Keyword', + keywordPlaceholder: 'message/request_id', + search: 'Search', + cleanCurrentFilters: 'Clean current filters', + refreshHealth: 'Refresh health', + empty: 'No system logs', + time: 'Time', + logDetails: 'Log Details', + loadFailed: 'Failed to load system logs', + runtimeConfigActive: 'Runtime log configuration is active', + runtimeConfigSaveFailed: 'Failed to save log configuration', + resetRuntimeConfigConfirm: 'Reset to startup configuration (env/yaml) and apply immediately?', + runtimeConfigReset: 'Reset to startup log configuration', + runtimeConfigResetFailed: 'Failed to reset log configuration', + cleanupConfirm: 'Clean up system logs matching the current filters? This cannot be undone.', + cleanupSuccess: 'Cleanup complete. Deleted {count} log entries.', + cleanupFailed: 'Failed to clean up system logs' + }, + requestsTotal: 'Requests (total)', + slaScope: 'SLA scope:', + tokens: 'Tokens', + tps: 'TPS:', + current: 'current', + peak: 'peak', + average: 'average', + totalRequests: 'Total Requests', + avgQps: 'Avg QPS', + avgTps: 'Avg TPS', + avgLatency: 'Avg Request Duration', + avgTtft: 'Avg TTFT', + exceptions: 'Exceptions', + requestErrors: 'Request Errors', + errorCount: 'Error Count', + upstreamErrors: 'Upstream Errors', + errorCountExcl429529: 'Error Count (excl 429/529)', + sla: 'SLA (excl business limits)', + businessLimited: 'business_limited:', + errors: 'Errors', + errorRate: 'error_rate:', + upstreamRate: 'upstream_rate:', + latencyDuration: 'Request Duration', + ttftLabel: 'TTFT (first_token_ms)', + p50: 'p50:', + p90: 'p90:', + p95: 'p95:', + p99: 'p99:', + avg: 'avg:', + max: 'max:', + requests: 'Requests', + requestsTitle: 'Requests', + upstream: 'Upstream', + client: 'Client', + system: 'System', + other: 'Other', + errorsSla: 'Errors (SLA scope)', + upstreamExcl429529: 'Upstream (excl 429/529)', + failedToLoadData: 'Failed to load ops data.', + failedToLoadOverview: 'Failed to load overview', + failedToLoadThroughputTrend: 'Failed to load throughput trend', + failedToLoadSwitchTrend: 'Failed to load avg account switches trend', + failedToLoadLatencyHistogram: 'Failed to load request duration histogram', + failedToLoadErrorTrend: 'Failed to load error trend', + failedToLoadErrorDistribution: 'Failed to load error distribution', + failedToLoadErrorDetail: 'Failed to load error detail', + retryFailed: 'Retry failed', + tpsK: 'TPS (K)', + top: 'Top:', + throughputTrend: 'Throughput Trend', + switchRateTrend: 'Avg Account Switches', + latencyHistogram: 'Request Duration Histogram', + errorTrend: 'Error Trend', + errorDistribution: 'Error Distribution', + switchRate: 'Avg switches', + // Health Score & Diagnosis + health: 'Health', + healthCondition: 'Health Condition', + healthHelp: 'Overall system health score based on SLA, error rate, and resource usage', + healthyStatus: 'Healthy', + riskyStatus: 'At Risk', + idleStatus: 'Idle', + timeRange: { + '5m': 'Last 5 minutes', + '30m': 'Last 30 minutes', + '1h': 'Last 1 hour', + '1d': 'Last 1 day', + '15d': 'Last 15 days', + '6h': 'Last 6 hours', + '24h': 'Last 24 hours', + '7d': 'Last 7 days', + '30d': 'Last 30 days' + }, + openaiTokenStats: { + title: 'OpenAI Token Request Stats', + viewModeTopN: 'TopN', + viewModePagination: 'Pagination', + prevPage: 'Previous', + nextPage: 'Next', + pageInfo: 'Page {page}/{total}', + totalModels: 'Total models: {total}', + failedToLoad: 'Failed to load OpenAI token stats', + empty: 'No OpenAI token stats for the current filters', + table: { + model: 'Model', + requestCount: 'Requests', + avgTokensPerSec: 'Avg Tokens/sec', + avgFirstTokenMs: 'Avg First Token Latency (ms)', + totalOutputTokens: 'Total Output Tokens', + avgDurationMs: 'Avg Duration (ms)', + requestsWithFirstToken: 'Requests With First Token' + } + }, + fullscreen: { + enter: 'Enter Fullscreen' + }, + diagnosis: { + title: 'Smart Diagnosis', + footer: 'Automated diagnostic suggestions based on current metrics', + idle: 'System is currently idle', + idleImpact: 'No active traffic', + // Resource diagnostics + dbDown: 'Database connection failed', + dbDownImpact: 'All database operations will fail', + dbDownAction: 'Check database service status, network connectivity, and connection configuration', + redisDown: 'Redis connection failed', + redisDownImpact: 'Cache functionality degraded, performance may decline', + redisDownAction: 'Check Redis service status and network connectivity', + cpuCritical: 'CPU usage critically high ({usage}%)', + cpuCriticalImpact: 'System response slowing, may affect all requests', + cpuCriticalAction: 'Check CPU-intensive tasks, consider scaling or code optimization', + cpuHigh: 'CPU usage elevated ({usage}%)', + cpuHighImpact: 'System load is high, needs attention', + cpuHighAction: 'Monitor CPU trends, prepare scaling plan', + memoryCritical: 'Memory usage critically high ({usage}%)', + memoryCriticalImpact: 'May trigger OOM, system stability threatened', + memoryCriticalAction: 'Check for memory leaks, consider increasing memory or optimizing usage', + memoryHigh: 'Memory usage elevated ({usage}%)', + memoryHighImpact: 'Memory pressure is high, needs attention', + memoryHighAction: 'Monitor memory trends, check for memory leaks', + ttftHigh: 'Time to first token elevated ({ttft}ms)', + ttftHighImpact: 'User perceived latency increased', + ttftHighAction: 'Optimize request processing flow, reduce pre-processing time', + // Error rate diagnostics + upstreamCritical: 'Upstream error rate critically high ({rate}%)', + upstreamCriticalImpact: 'May affect many user requests', + upstreamCriticalAction: 'Check upstream service health, enable fallback strategies', + upstreamHigh: 'Upstream error rate elevated ({rate}%)', + upstreamHighImpact: 'Recommend checking upstream service status', + upstreamHighAction: 'Contact upstream service team, prepare fallback plan', + errorHigh: 'Error rate too high ({rate}%)', + errorHighImpact: 'Many requests failing', + errorHighAction: 'Check error logs, identify root cause, urgent fix required', + errorElevated: 'Error rate elevated ({rate}%)', + errorElevatedImpact: 'Recommend checking error logs', + errorElevatedAction: 'Analyze error types and distribution, create fix plan', + // SLA diagnostics + slaCritical: 'SLA critically below target ({sla}%)', + slaCriticalImpact: 'User experience severely degraded', + slaCriticalAction: 'Urgently investigate errors and latency, consider rate limiting', + slaLow: 'SLA below target ({sla}%)', + slaLowImpact: 'Service quality needs attention', + slaLowAction: 'Analyze SLA decline causes, optimize system performance', + // Health score diagnostics + healthCritical: 'Overall health score critically low ({score})', + healthCriticalImpact: 'Multiple metrics may be degraded; prioritize error rate and latency investigation', + healthCriticalAction: 'Comprehensive system check, prioritize critical-level issues', + healthLow: 'Overall health score low ({score})', + healthLowImpact: 'May indicate minor instability; monitor SLA and error rates', + healthLowAction: 'Monitor metric trends, prevent issue escalation', + healthy: 'All system metrics normal', + healthyImpact: 'Service running stable' + }, + // Error Log + errorLog: { + timeId: 'Time / ID', + commonErrors: { + contextDeadlineExceeded: 'context deadline exceeded', + connectionRefused: 'connection refused', + rateLimit: 'rate limit' + }, + time: 'Time', + type: 'Type', + context: 'Context', + platform: 'Platform', + model: 'Model', + group: 'Group', + user: 'User', + userId: 'User ID', + apiKey: 'API Key', + keyDeletedBadge: 'Key Deleted', + account: 'Account', + accountId: 'Account ID', + status: 'Status', + message: 'Message', + ip: 'IP', + latency: 'Request Duration', + action: 'Action', + noErrors: 'No errors in this window.', + grp: 'GRP:', + acc: 'ACC:', + details: 'Details', + phase: 'Phase', + id: 'ID:', + typeUpstream: 'Upstream', + typeRequest: 'Request', + typeAuth: 'Auth', + typeRouting: 'Routing', + typeInternal: 'Internal', + endpoint: 'Endpoint', + requestType: 'Type', + requestTypeSync: 'Sync', + requestTypeStream: 'Stream', + requestTypeWs: 'WS' + }, + // Error Details Modal + errorDetails: { + upstreamErrors: 'Upstream Errors', + requestErrors: 'Request Errors', + unresolved: 'Unresolved', + resolved: 'Resolved', + viewErrors: 'Errors', + viewExcluded: 'Excluded', + statusCodeOther: 'Other', + owner: { + provider: 'Provider', + client: 'Client', + platform: 'Platform' + }, + phase: { + request: 'Request', + auth: 'Auth', + routing: 'Routing', + upstream: 'Upstream', + network: 'Network', + internal: 'Internal' + }, + total: 'Total:', + searchPlaceholder: 'Search request_id / client_request_id / message', + }, + // Error Detail Modal + errorDetail: { + title: 'Error Detail', + titleWithId: 'Error #{id}', + noErrorSelected: 'No error selected.', + resolution: 'Resolved:', + failedToUpdateResolvedStatus: 'Failed to update resolved status', + classificationKeys: { + phase: 'Phase', + owner: 'Owner', + source: 'Source', + resolvedAt: 'Resolved At', + resolvedBy: 'Resolved By' + }, + source: { + upstream_http: 'Upstream HTTP' + }, + upstreamKeys: { + status: 'Status', + message: 'Message', + detail: 'Detail', + upstreamErrors: 'Upstream Errors' + }, + upstreamEvent: { + account: 'Account', + status: 'Status', + requestId: 'Request ID' + }, + responsePreview: { + expand: 'Response (click to expand)', + collapse: 'Response (click to collapse)' + }, + loading: 'Loading…', + requestId: 'Request ID', + time: 'Time', + phase: 'Phase', + status: 'Status', + message: 'Message', + basicInfo: 'Basic Info', + platform: 'Platform', + model: 'Model', + group: 'Group', + user: 'User', + account: 'Account', + latency: 'Request Duration', + businessLimited: 'Business Limited', + requestPath: 'Request Path', + inboundEndpoint: 'Inbound Endpoint', + upstreamEndpoint: 'Upstream Endpoint', + requestedModel: 'Requested Model', + upstreamModel: 'Upstream Model', + requestType: 'Request Type', + requestTypeUnknown: 'Unknown', + requestTypeSync: 'Sync', + requestTypeStream: 'Stream', + requestTypeWs: 'WebSocket', + modelMapping: 'Model Mapping', + timings: 'Timings', + auth: 'Auth', + routing: 'Routing', + upstream: 'Upstream', + response: 'Response', + classification: 'Classification', + errorBody: 'Error Body', + trimmed: 'trimmed', + markResolved: 'Mark resolved', + markUnresolved: 'Mark unresolved', + tabOverview: 'Overview', + tabRequest: 'Request', + tabResponse: 'Response', + responseBody: 'Response', + compareA: 'Compare A', + compareB: 'Compare B', + suggestion: 'Suggestion', + suggestUpstream: 'Upstream instability: check account status or consider switching accounts', + suggestRequest: 'Client request error: ask customer to fix request parameters', + suggestAuth: 'Auth failed: verify API key/credentials', + suggestPlatform: 'Platform error: prioritize investigation and fix', + suggestGeneric: 'See details for more context', + apiKeyPrefix: 'Key Prefix', + attemptedKeyPrefix: 'Attempted Key Prefix', + deletedKeyOwner: 'Deleted Key Owner', + keyDeletedBadge: 'Key Deleted' + }, + requestDetails: { + title: 'Request Details', + details: 'Details', + rangeLabel: 'Window: {range}', + rangeMinutes: '{n} minutes', + rangeHours: '{n} hours', + empty: 'No requests in this window.', + emptyHint: 'Try a different time range or remove filters.', + failedToLoad: 'Failed to load request details', + requestIdCopied: 'Request ID copied', + copyFailed: 'Copy failed', + copy: 'Copy', + viewError: 'View Error', + kind: { + success: 'SUCCESS', + error: 'ERROR' + }, + table: { + time: 'Time', + kind: 'Kind', + platform: 'Platform', + model: 'Model', + duration: 'Duration', + status: 'Status', + requestId: 'Request ID', + actions: 'Actions' + } + }, + alertEvents: { + title: 'Alert Events', + description: 'Recent alert firing/resolution records (email-only)', + loading: 'Loading...', + empty: 'No alert events', + loadFailed: 'Failed to load alert events', + status: { + firing: 'FIRING', + resolved: 'RESOLVED', + manualResolved: 'MANUAL RESOLVED' + }, + detail: { + title: 'Alert Detail', + loading: 'Loading detail...', + empty: 'No detail', + loadFailed: 'Failed to load alert detail', + manualResolve: 'Mark as Resolved', + manualResolvedSuccess: 'Marked as manually resolved', + manualResolvedFailed: 'Failed to mark as manually resolved', + silence: 'Ignore Alert', + silenceSuccess: 'Alert silenced', + silenceFailed: 'Failed to silence alert', + viewRule: 'View Rule', + viewLogs: 'View Logs', + firedAt: 'Fired At', + resolvedAt: 'Resolved At', + ruleId: 'Rule ID', + dimensions: 'Dimensions', + historyTitle: 'History', + historyHint: 'Recent events with same rule + dimensions', + historyLoading: 'Loading history...', + historyEmpty: 'No history' + }, + table: { + time: 'Time', + status: 'Status', + severity: 'Severity', + platform: 'Platform', + ruleId: 'Rule ID', + title: 'Title', + duration: 'Duration', + metric: 'Metric / Threshold', + dimensions: 'Dimensions', + email: 'Email Sent', + emailSent: 'Sent', + emailIgnored: 'Ignored' + } + }, + alertRules: { + title: 'Alert Rules', + description: 'Create and manage threshold-based system alerts (email-only)', + loading: 'Loading...', + empty: 'No alert rules', + loadFailed: 'Failed to load alert rules', + saveFailed: 'Failed to save alert rule', + saveSuccess: 'Alert rule saved successfully', + deleteFailed: 'Failed to delete alert rule', + deleteSuccess: 'Alert rule deleted successfully', + manage: 'Manage Alert Rules', + create: 'Create Rule', + createTitle: 'Create Alert Rule', + editTitle: 'Edit Alert Rule', + deleteConfirmTitle: 'Delete this rule?', + deleteConfirmMessage: 'This will remove the rule and its related events. Continue?', + metricGroups: { + system: 'System Metrics', + group: 'Group-level Metrics (requires group_id)', + account: 'Account-level Metrics' + }, + metrics: { + successRate: 'Success Rate (%)', + errorRate: 'Error Rate (%)', + upstreamErrorRate: 'Upstream Error Rate (%)', + p95: 'P95 Latency (ms)', + p99: 'P99 Latency (ms)', + cpu: 'CPU Usage (%)', + memory: 'Memory Usage (%)', + queueDepth: 'Concurrency Queue Depth', + groupAvailableAccounts: 'Group Available Accounts', + groupAvailableRatio: 'Group Available Ratio (%)', + groupRateLimitRatio: 'Group Rate Limit Ratio (%)', + accountRateLimitedCount: 'Rate-limited Accounts', + accountErrorCount: 'Error Accounts (excluding temporarily unschedulable)', + accountErrorRatio: 'Error Account Ratio (%)', + accountTempUnscheduledCount: 'Temporarily Unschedulable Accounts', + overloadAccountCount: 'Overloaded Accounts' + }, + metricDescriptions: { + successRate: 'Percentage of successful requests in the window (0-100).', + errorRate: 'Percentage of failed requests in the window (0-100).', + upstreamErrorRate: 'Percentage of upstream failures in the window (0-100).', + p95: 'P95 request latency within the window (ms).', + p99: 'P99 request latency within the window (ms).', + cpu: 'Current instance CPU usage (0-100).', + memory: 'Current instance memory usage (0-100).', + queueDepth: 'Concurrency queue depth within the window (queued requests).', + groupAvailableAccounts: 'Number of available accounts in the selected group (requires group_id).', + groupAvailableRatio: 'Available account ratio in the selected group (0-100, requires group_id).', + groupRateLimitRatio: 'Rate-limited account ratio in the selected group (0-100, requires group_id).', + accountRateLimitedCount: 'Number of rate-limited accounts within the window.', + accountErrorCount: 'Number of error accounts within the window (excluding temporarily unschedulable).', + accountErrorRatio: 'Error account ratio within the window (0-100).', + accountTempUnscheduledCount: 'Number of accounts currently temporarily unschedulable (e.g. proxy/credential failure auto-eviction).', + overloadAccountCount: 'Number of overloaded accounts within the window.' + }, + hints: { + recommended: 'Recommended: operator {operator}, threshold {threshold}{unit}', + groupRequired: 'This is a group-level metric; selecting a group (group_id) is required.', + groupOptional: 'Optional: limit the rule to a specific group via group_id.' + }, + table: { + name: 'Name', + metric: 'Metric', + severity: 'Severity', + enabled: 'Enabled', + actions: 'Actions' + }, + form: { + name: 'Name', + description: 'Description', + metric: 'Metric', + operator: 'Operator', + groupId: 'Group (group_id)', + groupPlaceholder: 'Select a group', + allGroups: 'All groups', + threshold: 'Threshold', + severity: 'Severity', + window: 'Window (minutes)', + sustained: 'Sustained (samples)', + cooldown: 'Cooldown (minutes)', + enabled: 'Enabled', + notifyEmail: 'Send email notifications' + }, + validation: { + title: 'Please fix the following issues', + invalid: 'Invalid rule', + nameRequired: 'Name is required', + metricRequired: 'Metric is required', + groupIdRequired: 'group_id is required for group-level metrics', + operatorRequired: 'Operator is required', + thresholdRequired: 'Threshold must be a number', + windowRange: 'Window must be one of: 1, 5, 60 minutes', + sustainedRange: 'Sustained must be between 1 and 1440 samples', + cooldownRange: 'Cooldown must be between 0 and 1440 minutes' + } + }, + runtime: { + title: 'Ops Runtime Settings', + description: 'Stored in database; changes take effect without editing config files.', + loading: 'Loading...', + noData: 'No runtime settings available', + loadFailed: 'Failed to load runtime settings', + saveSuccess: 'Runtime settings saved', + saveFailed: 'Failed to save runtime settings', + alertTitle: 'Alert Evaluator', + groupAvailabilityTitle: 'Group Availability Monitor', + evalIntervalSeconds: 'Evaluation Interval (seconds)', + silencing: { + title: 'Alert Silencing (Maintenance Mode)', + enabled: 'Enable silencing', + globalUntil: 'Silence until (RFC3339)', + untilHint: 'Leave empty to only toggle silencing without an expiry (not recommended).', + reason: 'Reason', + reasonPlaceholder: 'e.g., planned maintenance', + entries: { + title: 'Advanced: targeted silencing', + hint: 'Optional: silence only certain rules or severities. Leave fields empty to match all.', + add: 'Add Entry', + empty: 'No targeted entries', + entryTitle: 'Entry #{n}', + ruleId: 'Rule ID (optional)', + ruleIdPlaceholder: 'e.g., 1', + severities: 'Severities (optional)', + severitiesPlaceholder: 'e.g., P0,P1 (empty = all)', + until: 'Until (RFC3339)', + reason: 'Reason', + validation: { + untilRequired: 'Entry until time is required', + untilFormat: 'Entry until time must be a valid RFC3339 timestamp', + ruleIdPositive: 'Entry rule_id must be a positive integer', + severitiesFormat: 'Entry severities must be a comma-separated list of P0..P3' + } + }, + validation: { + timeFormat: 'Silence time must be a valid RFC3339 timestamp' + } + }, + lockEnabled: 'Distributed Lock Enabled', + lockKey: 'Distributed Lock Key', + lockTTLSeconds: 'Distributed Lock TTL (seconds)', + showAdvancedDeveloperSettings: 'Show advanced developer settings (Distributed Lock)', + advancedSettingsSummary: 'Advanced settings (Distributed Lock)', + evalIntervalHint: 'How often the evaluator runs. Keeping the default is recommended.', + validation: { + title: 'Please fix the following issues', + invalid: 'Invalid settings', + evalIntervalRange: 'Evaluation interval must be between 1 and 86400 seconds', + lockKeyRequired: 'Distributed lock key is required when lock is enabled', + lockKeyPrefix: 'Distributed lock key must start with "{prefix}"', + lockKeyHint: 'Recommended: start with "{prefix}" to avoid conflicts', + lockTtlRange: 'Distributed lock TTL must be between 1 and 86400 seconds', + slaMinPercentRange: 'SLA minimum percentage must be between 0 and 100', + ttftP99MaxRange: 'TTFT P99 maximum must be a number ≥ 0', + requestErrorRateMaxRange: 'Request error rate maximum must be between 0 and 100', + upstreamErrorRateMaxRange: 'Upstream error rate maximum must be between 0 and 100' + } + }, + email: { + title: 'Email Notification', + description: 'Configure alert/report email notifications (stored in database).', + loading: 'Loading...', + noData: 'No email notification config', + loadFailed: 'Failed to load email notification config', + saveSuccess: 'Email notification config saved', + saveFailed: 'Failed to save email notification config', + alertTitle: 'Alert Emails', + reportTitle: 'Report Emails', + recipients: 'Recipients', + recipientsHint: 'If empty, the system may fallback to the first admin email.', + minSeverity: 'Min Severity', + minSeverityAll: 'All severities', + rateLimitPerHour: 'Rate limit per hour', + batchWindowSeconds: 'Batch window (seconds)', + includeResolved: 'Include resolved alerts', + dailySummary: 'Daily summary', + weeklySummary: 'Weekly summary', + errorDigest: 'Error digest', + errorDigestMinCount: 'Min errors for digest', + accountHealth: 'Account health', + accountHealthThreshold: 'Error rate threshold (%)', + cronPlaceholder: 'Cron expression', + reportHint: 'Schedules use cron syntax; leave empty to use defaults.', + validation: { + title: 'Please fix the following issues', + invalid: 'Invalid email notification config', + alertRecipientsRequired: 'Alert emails are enabled but no recipients are configured', + reportRecipientsRequired: 'Report emails are enabled but no recipients are configured', + invalidRecipients: 'One or more recipient emails are invalid', + rateLimitRange: 'Rate limit per hour must be a number ≥ 0', + batchWindowRange: 'Batch window must be between 0 and 86400 seconds', + cronRequired: 'A cron expression is required when schedule is enabled', + cronFormat: 'Cron expression format looks invalid (expected at least 5 parts)', + digestMinCountRange: 'Min errors for digest must be a number ≥ 0', + accountHealthThresholdRange: 'Account health threshold must be between 0 and 100' + } + }, + settings: { + title: 'Ops Monitoring Settings', + loadFailed: 'Failed to load settings', + saveSuccess: 'Ops monitoring settings saved successfully', + saveFailed: 'Failed to save settings', + dataCollection: 'Data Collection', + evaluationInterval: 'Evaluation Interval (seconds)', + evaluationIntervalHint: 'Frequency of detection tasks, recommended to keep default', + alertConfig: 'Alert Configuration', + enableAlert: 'Enable Alerts', + alertRecipients: 'Alert Recipient Emails', + emailPlaceholder: 'Enter email address', + recipientsHint: 'If empty, the system will use the first admin email as default recipient', + minSeverity: 'Minimum Severity', + reportConfig: 'Report Configuration', + enableReport: 'Enable Reports', + reportRecipients: 'Report Recipient Emails', + dailySummary: 'Daily Summary', + weeklySummary: 'Weekly Summary', + metricThresholds: 'Metric Thresholds', + metricThresholdsHint: 'Configure alert thresholds for metrics, values exceeding thresholds will be displayed in red', + slaMinPercent: 'SLA Minimum Percentage', + slaMinPercentHint: 'SLA below this value will be displayed in red (default: 99.5%)', + ttftP99MaxMs: 'TTFT P99 Maximum (ms)', + ttftP99MaxMsHint: 'TTFT P99 above this value will be displayed in red (default: 500ms)', + requestErrorRateMaxPercent: 'Request Error Rate Maximum (%)', + requestErrorRateMaxPercentHint: 'Request error rate above this value will be displayed in red (default: 5%)', + upstreamErrorRateMaxPercent: 'Upstream Error Rate Maximum (%)', + upstreamErrorRateMaxPercentHint: 'Upstream error rate above this value will be displayed in red (default: 5%)', + advancedSettings: 'Advanced Settings', + dataRetention: 'Data Retention Policy', + enableCleanup: 'Enable Data Cleanup', + cleanupSchedule: 'Cleanup Schedule (Cron)', + cleanupScheduleHint: 'Example: 0 2 * * * means 2 AM daily', + errorLogRetentionDays: 'Error Log Retention Days', + minuteMetricsRetentionDays: 'Minute Metrics Retention Days', + hourlyMetricsRetentionDays: 'Hourly Metrics Retention Days', + retentionDaysHint: 'Recommended 7-90 days; longer periods consume more storage. Set to 0 to wipe all history on every scheduled cleanup', + aggregation: 'Pre-aggregation Tasks', + enableAggregation: 'Enable Pre-aggregation', + aggregationHint: 'Pre-aggregation improves query performance for long time windows', + openaiQuotaAutoPause: 'OpenAI Account Quota Auto-pause', + openaiQuotaAutoPauseHint: 'When an OpenAI account reaches its 5h / 7d usage threshold, the scheduler skips it automatically and resumes once the window rolls over. Per-account thresholds take precedence over this global default.', + openaiQuotaAutoPauseDefault5h: 'Default 5h usage threshold (%)', + openaiQuotaAutoPauseDefault7d: 'Default 7d usage threshold (%)', + openaiQuotaAutoPauseThresholdHint: 'Value 0-100; leave blank or 0 to disable the global default threshold.', + errorFiltering: 'Error Filtering', + ignoreCountTokensErrors: 'Ignore count_tokens errors', + ignoreCountTokensErrorsHint: 'When enabled, errors from count_tokens requests will not be written to the error log.', + ignoreContextCanceled: 'Ignore client disconnect errors', + ignoreContextCanceledHint: 'When enabled, client disconnect (context canceled) errors will not be written to the error log.', + ignoreNoAvailableAccounts: 'Ignore no available accounts errors', + ignoreNoAvailableAccountsHint: 'When enabled, "No available accounts" errors will not be written to the error log (not recommended; usually a config issue).', + ignoreInvalidApiKeyErrors: 'Ignore invalid API key errors', + ignoreInvalidApiKeyErrorsHint: 'When enabled, invalid or missing API key errors (INVALID_API_KEY, API_KEY_REQUIRED) will not be written to the error log.', + ignoreInsufficientBalanceErrors: 'Ignore Insufficient Balance Errors', + ignoreInsufficientBalanceErrorsHint: 'When enabled, insufficient account balance errors will not be written to the error log.', + autoRefresh: 'Auto Refresh', + enableAutoRefresh: 'Enable auto refresh', + enableAutoRefreshHint: 'Automatically refresh dashboard data at a fixed interval.', + refreshInterval: 'Refresh Interval', + refreshInterval15s: '15 seconds', + refreshInterval30s: '30 seconds', + refreshInterval60s: '60 seconds', + dashboardCards: 'Dashboard Cards', + displayAlertEvents: 'Display alert events', + displayAlertEventsHint: 'Show or hide the recent alert events card on the ops dashboard. Enabled by default.', + displayOpenAITokenStats: 'Display OpenAI token request stats', + displayOpenAITokenStatsHint: 'Show or hide the OpenAI token request stats card on the ops dashboard. Hidden by default.', + autoRefreshCountdown: 'Auto refresh: {seconds}s', + validation: { + title: 'Please fix the following issues', + retentionDaysRange: 'Retention days must be between 0 and 365 (0 = wipe all on every cleanup)', + slaMinPercentRange: 'SLA minimum percentage must be between 0 and 100', + ttftP99MaxRange: 'TTFT P99 maximum must be a number ≥ 0', + requestErrorRateMaxRange: 'Request error rate maximum must be between 0 and 100', + upstreamErrorRateMaxRange: 'Upstream error rate maximum must be between 0 and 100', + openaiQuotaAutoPauseRange: 'OpenAI quota auto-pause threshold must be between 0 and 100' + } + }, + concurrency: { + title: 'Concurrency / Queue', + byPlatform: 'By Platform', + byGroup: 'By Group', + byAccount: 'By Account', + byUser: 'By User', + showByUserTooltip: 'Switch to user view to see concurrency usage per user', + switchToUser: 'Switch to user view', + switchToPlatform: 'Switch to platform view', + totalRows: '{count} rows', + disabledHint: 'Realtime monitoring is disabled in settings.', + empty: 'No data', + queued: 'Queue {count}', + rateLimited: 'Rate-limited {count}', + errorAccounts: 'Errors {count}', + loadFailed: 'Failed to load concurrency data' + }, + realtime: { + title: 'Realtime', + connected: 'Realtime connected', + connecting: 'Realtime connecting', + reconnecting: 'Realtime reconnecting', + offline: 'Realtime offline', + closed: 'Realtime closed', + reconnectIn: 'retry in {seconds}s' + }, + queryMode: { + auto: 'Auto', + raw: 'Raw', + preagg: 'Preagg' + }, + accountAvailability: { + available: 'Available', + unavailable: 'Unavailable', + accountError: 'Error' + }, + tooltips: { + totalRequests: 'Total number of requests (including both successful and failed requests) in the selected time window.', + throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.', + switchRateTrend: 'Trend of account switches / total requests over the last 5 hours (avg switches).', + latencyHistogram: 'Request duration distribution (ms) for successful requests.', + errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).', + errorDistribution: 'Error distribution by status code (SLA scope, excluding business limits).', + goroutines: + 'Number of Go runtime goroutines (lightweight threads). There is no absolute "safe" number—use your historical baseline. Heuristic: <2k is common; 2k–8k watch; >8k plus rising queue/latency often suggests blocking/leaks.', + cpu: 'CPU usage percentage, showing system processor load.', + memory: 'Memory usage, including used and total available memory.', + db: 'Database connection pool status, including active, idle, and waiting connections.', + redis: 'Redis connection pool status, showing active and idle connections.', + jobs: 'Background job execution status, including last run time, success time, and error information.', + qps: 'Queries Per Second (QPS) and Tokens Per Second (TPS), real-time system throughput.', + tokens: 'Total number of tokens processed in the current time window.', + sla: 'Service Level Agreement success rate, excluding business limits (e.g., insufficient balance, quota exceeded).', + errors: 'Error statistics, including total errors, error rate, and upstream error rate.', + upstreamErrors: 'Upstream error statistics, excluding rate limit errors (429/529).', + latency: 'Request duration statistics, including p50, p90, p95, p99 percentiles.', + ttft: 'Time To First Token, measuring the speed of first token return in streaming responses.', + health: 'System health score (0-100), considering SLA, error rate, and resource usage.' + }, + charts: { + emptyRequest: 'No requests in this window.', + emptyError: 'No errors in this window.', + resetZoom: 'Reset', + resetZoomHint: 'Reset zoom (if enabled)', + downloadChart: 'Download', + downloadChartHint: 'Download chart as image' + } + }, + + // Settings +} diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts new file mode 100644 index 0000000000..57e7f21137 --- /dev/null +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -0,0 +1,949 @@ +export default { + // Dashboard + dashboard: { + title: 'Admin Dashboard', + description: 'System overview and real-time statistics', + apiKeys: 'API Keys', + accounts: 'Accounts', + users: 'Users', + todayRequests: 'Today Requests', + newUsersToday: 'New Users Today', + todayTokens: 'Today Tokens', + totalTokens: 'Total Tokens', + cacheToday: 'Cache (Today)', + performance: 'Performance', + avgResponse: 'Avg Response', + active: 'active', + ok: 'ok', + err: 'err', + activeUsers: 'active users', + create: 'Create', + timeRange: 'Time Range', + granularity: 'Granularity', + day: 'Day', + hour: 'Hour', + modelDistribution: 'Model Distribution', + groupDistribution: 'Group Usage Distribution', + metricTokens: 'By Tokens', + metricActualCost: 'By Actual Cost', + tokenUsageTrend: 'Token Usage Trend', + userUsageTrend: 'User Usage Trend (Top 12)', + model: 'Model', + group: 'Group', + noGroup: 'No Group', + requests: 'Requests', + tokens: 'Tokens', + actual: 'Actual', + standard: 'Standard', + accountCost: 'Cost', + noDataAvailable: 'No data available', + recentUsage: 'Recent Usage', + viewModelDistribution: 'Model Distribution', + viewSpendingRanking: 'User Spending Ranking', + spendingRankingTitle: 'User Spending Ranking', + spendingRankingUser: 'User', + spendingRankingRequests: 'Requests', + spendingRankingTokens: 'Tokens', + spendingRankingSpend: 'Spend', + spendingRankingOther: 'Others', + spendingRankingUsage: 'Usage', + spendShort: 'Spend', + requestsShort: 'Req', + tokensShort: 'Tok', + quickActions: 'Quick Actions', + batchImage: 'Batch Image', + batchImageDesc: 'Submit jobs and copy agent instructions', + groupPricing: 'Group Pricing', + groupPricingDesc: 'Configure batch discount and hold ratio', + failedToLoad: 'Failed to load dashboard statistics' + }, + + backup: { + title: 'Database Backup', + description: 'Full database backup to S3-compatible storage with scheduled backup and restore', + s3: { + title: 'S3 Storage Configuration', + description: 'Configure S3-compatible storage (supports Cloudflare R2)', + descriptionPrefix: 'Configure S3-compatible storage (supports', + descriptionSuffix: ')', + enabled: 'Enable S3 Storage', + endpoint: 'Endpoint', + region: 'Region', + bucket: 'Bucket', + prefix: 'Key Prefix', + accessKeyId: 'Access Key ID', + secretAccessKey: 'Secret Access Key', + secretConfigured: 'Already configured, leave empty to keep', + forcePathStyle: 'Force Path Style', + testConnection: 'Test Connection', + testSuccess: 'S3 connection test successful', + testFailed: 'S3 connection test failed', + saved: 'S3 configuration saved' + }, + schedule: { + title: 'Scheduled Backup', + description: 'Configure automatic scheduled backups', + enabled: 'Enable Scheduled Backup', + cronExpr: 'Cron Expression', + cronHint: 'e.g. "0 2 * * *" means every day at 2:00 AM', + retainDays: 'Backup Expire Days', + retainDaysHint: 'Backup files auto-delete after this many days, 0 = never expire', + retainCount: 'Max Retain Count', + retainCountHint: 'Maximum number of backups to keep, 0 = unlimited', + saved: 'Schedule configuration saved' + }, + operations: { + title: 'Backup Records', + description: 'Create manual backups and manage existing backup records', + createBackup: 'Create Backup', + backing: 'Backing up...', + backupCreated: 'Backup created successfully', + expireDays: 'Expire Days', + alreadyInProgress: 'A backup is already in progress', + backupRunning: 'Backup in progress...', + backupFailed: 'Backup failed', + restoreRunning: 'Restore in progress...', + restoreFailed: 'Restore failed', + }, + columns: { + status: 'Status', + fileName: 'File Name', + size: 'Size', + expiresAt: 'Expires At', + triggeredBy: 'Triggered By', + startedAt: 'Started At', + actions: 'Actions' + }, + status: { + pending: 'Pending', + running: 'Running', + completed: 'Completed', + failed: 'Failed' + }, + progress: { + pending: 'Preparing', + dumping: 'Dumping database', + uploading: 'Uploading', + }, + trigger: { + manual: 'Manual', + scheduled: 'Scheduled' + }, + neverExpire: 'Never', + empty: 'No backup records', + actions: { + download: 'Download', + restore: 'Restore', + restoreConfirm: 'Are you sure you want to restore from this backup? This will overwrite the current database!', + restorePasswordPrompt: 'Please enter your admin password to confirm the restore operation', + restoreSuccess: 'Database restored successfully', + deleteConfirm: 'Are you sure you want to delete this backup?', + deleted: 'Backup deleted' + }, + r2Guide: { + title: 'Cloudflare R2 Setup Guide', + intro: 'Cloudflare R2 provides S3-compatible object storage with a free tier of 10GB storage + 1M Class A requests/month, ideal for database backups.', + step1: { + title: 'Create an R2 Bucket', + line1: 'Log in to the Cloudflare Dashboard (dash.cloudflare.com), select "R2 Object Storage" from the sidebar', + line2: 'Click "Create bucket", enter a name (e.g. sub2api-backups), choose a region', + line3: 'Click create to finish' + }, + step2: { + title: 'Create an API Token', + line1: 'On the R2 page, click "Manage R2 API Tokens" in the top right', + line2: 'Click "Create API token", set permission to "Object Read & Write"', + line3: 'Recommended: restrict to specific bucket for better security', + line4: 'After creation, you will see the Access Key ID and Secret Access Key', + warning: 'The Secret Access Key is only shown once — copy and save it immediately!' + }, + step3: { + title: 'Get the S3 Endpoint', + desc: 'Find your Account ID on the R2 overview page (in the URL or the right panel). The endpoint format is:', + accountId: 'your_account_id' + }, + step4: { + title: 'Fill in the Configuration', + checkEnabled: 'Checked', + bucketValue: 'Your bucket name', + fromStep2: 'Value from Step 2', + unchecked: 'Unchecked' + }, + freeTier: 'R2 Free Tier: 10GB storage + 1M Class A requests + 10M Class B requests per month — more than enough for database backups.' + } + }, + + dataManagement: { + title: 'Data Management', + description: 'Manage data management agent status, object storage settings, and backup jobs in one place', + agent: { + title: 'Data Management Agent Status', + description: 'The system probes a fixed Unix socket and enables data management only when reachable.', + enabled: 'Data management agent is ready. Data management operations are available.', + disabled: 'Data management agent is unavailable. Only diagnostic information is available now.', + socketPath: 'Socket Path', + version: 'Version', + status: 'Status', + uptime: 'Uptime', + reasonLabel: 'Unavailable Reason', + reason: { + DATA_MANAGEMENT_AGENT_SOCKET_MISSING: 'Data management socket file is missing', + DATA_MANAGEMENT_AGENT_UNAVAILABLE: 'Data management agent is unreachable', + BACKUP_AGENT_SOCKET_MISSING: 'Backup socket file is missing', + BACKUP_AGENT_UNAVAILABLE: 'Backup agent is unreachable', + UNKNOWN: 'Unknown reason' + } + }, + sections: { + config: { + title: 'Backup Configuration', + description: 'Configure backup source, retention policy, and S3 settings.' + }, + s3: { + title: 'S3 Object Storage', + description: 'Configure and test uploads of backup artifacts to a standard S3-compatible storage.' + }, + backup: { + title: 'Backup Operations', + description: 'Trigger PostgreSQL, Redis, and full backup jobs.' + }, + history: { + title: 'Backup History', + description: 'Review backup job status, errors, and artifact metadata.' + } + }, + form: { + sourceMode: 'Source Mode', + backupRoot: 'Backup Root', + activePostgresProfile: 'Active PostgreSQL Profile', + activeRedisProfile: 'Active Redis Profile', + activeS3Profile: 'Active S3 Profile', + retentionDays: 'Retention Days', + keepLast: 'Keep Last Jobs', + uploadToS3: 'Upload to S3', + useActivePostgresProfile: 'Use Active PostgreSQL Profile', + useActiveRedisProfile: 'Use Active Redis Profile', + useActiveS3Profile: 'Use Active Profile', + idempotencyKey: 'Idempotency Key (Optional)', + secretConfigured: 'Configured already, leave empty to keep unchanged', + source: { + profileID: 'Profile ID (Unique)', + profileName: 'Profile Name', + setActive: 'Set as active after creation' + }, + postgres: { + title: 'PostgreSQL', + host: 'Host', + port: 'Port', + user: 'User', + password: 'Password', + database: 'Database', + sslMode: 'SSL Mode', + containerName: 'Container Name (docker_exec mode)' + }, + redis: { + title: 'Redis', + addr: 'Address (host:port)', + username: 'Username', + password: 'Password', + db: 'Database Index', + containerName: 'Container Name (docker_exec mode)' + }, + s3: { + enabled: 'Enable S3 Upload', + profileID: 'Profile ID (Unique)', + profileName: 'Profile Name', + endpoint: 'Endpoint (Optional)', + region: 'Region', + bucket: 'Bucket', + accessKeyID: 'Access Key ID', + secretAccessKey: 'Secret Access Key', + prefix: 'Object Prefix', + forcePathStyle: 'Force Path Style', + useSSL: 'Use SSL', + setActive: 'Set as active after creation' + } + }, + sourceProfiles: { + createTitle: 'Create Source Profile', + editTitle: 'Edit Source Profile', + empty: 'No source profiles yet, create one first', + deleteConfirm: 'Delete source profile {profileID}?', + columns: { + profile: 'Profile', + active: 'Active', + connection: 'Connection', + database: 'Database', + updatedAt: 'Updated At', + actions: 'Actions' + } + }, + s3Profiles: { + createTitle: 'Create S3 Profile', + editTitle: 'Edit S3 Profile', + empty: 'No S3 profiles yet, create one first', + editHint: 'Click "Edit" to modify profile details in the right drawer.', + deleteConfirm: 'Delete S3 profile {profileID}?', + columns: { + profile: 'Profile', + active: 'Active', + storage: 'Storage', + updatedAt: 'Updated At', + actions: 'Actions' + } + }, + history: { + total: '{count} jobs', + empty: 'No backup jobs yet', + columns: { + jobID: 'Job ID', + type: 'Type', + status: 'Status', + triggeredBy: 'Triggered By', + pgProfile: 'PostgreSQL Profile', + redisProfile: 'Redis Profile', + s3Profile: 'S3 Profile', + finishedAt: 'Finished At', + artifact: 'Artifact', + error: 'Error' + }, + status: { + queued: 'Queued', + running: 'Running', + succeeded: 'Succeeded', + failed: 'Failed', + partial_succeeded: 'Partial Succeeded' + } + }, + actions: { + refresh: 'Refresh Status', + disabledHint: 'Start datamanagementd first and ensure the socket is reachable.', + reloadConfig: 'Reload Config', + reloadSourceProfiles: 'Reload Source Profiles', + reloadProfiles: 'Reload Profiles', + newSourceProfile: 'New Source Profile', + saveConfig: 'Save Config', + configSaved: 'Configuration saved', + testS3: 'Test S3 Connection', + s3TestOK: 'S3 connection test succeeded', + s3TestFailed: 'S3 connection test failed', + newProfile: 'New Profile', + saveProfile: 'Save Profile', + activateProfile: 'Activate', + profileIDRequired: 'Profile ID is required', + profileNameRequired: 'Profile name is required', + profileSelectRequired: 'Select a profile to edit first', + profileCreated: 'S3 profile created', + profileSaved: 'S3 profile saved', + profileActivated: 'S3 profile activated', + profileDeleted: 'S3 profile deleted', + sourceProfileCreated: 'Source profile created', + sourceProfileSaved: 'Source profile saved', + sourceProfileActivated: 'Source profile activated', + sourceProfileDeleted: 'Source profile deleted', + createBackup: 'Create Backup Job', + jobCreated: 'Backup job created: {jobID} ({status})', + refreshJobs: 'Refresh Jobs', + loadMore: 'Load More' + } + }, + + affiliates: { + invitesDescription: 'View site-wide inviter and invitee relationships', + rebatesDescription: 'View recharge orders that generated affiliate rebates', + transfersDescription: 'View affiliate quota transfers into account balance', + errors: { + loadFailed: 'Failed to load affiliate records' + }, + records: { + search: 'Search', + searchPlaceholder: 'Email, username, user ID, or order number', + startAt: 'Start date', + endAt: 'End date', + inviter: 'Inviter', + invitee: 'Invitee', + user: 'User', + affCode: 'Invite Code', + order: 'Order', + totalRebate: 'Total Rebate', + orderAmount: 'Top-up Amount', + payAmount: 'Paid Amount', + rebateAmount: 'Rebate Amount', + paymentType: 'Payment Method', + orderStatus: 'Order Status', + transferAmount: 'Transfer Amount', + balanceAfter: 'Balance After', + availableQuotaAfter: 'Available After', + frozenQuotaAfter: 'Frozen After', + historyQuotaAfter: 'Historical Rebate After', + invitedAt: 'Invited At', + rebatedAt: 'Rebated At', + transferredAt: 'Transferred At' + }, + overview: { + title: 'Affiliate User Overview', + affCode: 'Invite Code', + rebateRate: 'Rebate Rate', + invitedCount: 'Invited Users', + rebatedInviteeCount: 'Rebated Invitees', + availableQuota: 'Available Quota', + historyQuota: 'Historical Rebate' + } + }, + + // Users + users: { + title: 'User Management', + description: 'Manage users and their permissions', + createUser: 'Create User', + editUser: 'Edit User', + deleteUser: 'Delete User', + searchUsers: 'Search by email, username, notes, or API key...', + allRoles: 'All Roles', + allStatus: 'All Status', + allGroups: 'All Groups', + searchGroups: 'Search groups...', + fuzzySearch: 'Fuzzy search', + apiKeyGroupFilter: 'API Key Group', + apiKeyGroupExclusive: 'Exclusive Groups', + apiKeyGroupPublic: 'Public Groups', + apiKeyGroupSubscription: 'Subscription Groups', + apiKeyGroupDisabled: 'Disabled Groups', + authorizedGroupFilter: 'Authorized Group', + allAuthorizedGroups: 'All Authorized Groups', + searchAuthorizedGroups: 'Search authorized groups...', + allApiKeyGroups: 'All API Key Groups', + searchApiKeyGroups: 'Search API Key groups...', + admin: 'Admin', + user: 'User', + disabled: 'Disabled', + email: 'Email', + password: 'Password', + username: 'Username', + notes: 'Notes', + enterEmail: 'Enter email', + enterPassword: 'Enter password', + enterUsername: 'Enter username (optional)', + enterNotes: 'Enter notes (admin only)', + notesHint: 'This note is only visible to administrators', + enterNewPassword: 'Enter new password (optional)', + leaveEmptyToKeep: 'Leave empty to keep current password', + generatePassword: 'Generate random password', + copyPassword: 'Copy password', + creating: 'Creating...', + updating: 'Updating...', + form: { + rpmLimit: 'Requests Per Minute (RPM)', + rpmLimitPlaceholder: '0 = unlimited', + rpmLimitHint: 'Max requests per minute for this user; 0 = unlimited. Acts as a fallback only when the group has no rpm_limit set.' + }, + columns: { + user: 'User', + id: 'ID', + email: 'Email', + username: 'Username', + notes: 'Notes', + role: 'Role', + groups: 'Groups', + subscriptions: 'Subscriptions', + balance: 'Balance', + balancePlatformQuota: 'Balance (Platform Quota)', + usage: 'Usage', + usageAnthropic: 'Usage (Claude)', + usageOpenAI: 'Usage (OpenAI)', + usageGemini: 'Usage (Gemini)', + usageAntigravity: 'Usage (Antigravity)', + concurrency: 'Concurrency', + status: 'Status', + lastActive: 'Last Active', + lastUsed: 'Last Used', + created: 'Created', + actions: 'Actions' + }, + today: 'Today', + total: 'Last 30d', + sortBy: 'Sort By', + sortCurrentPageOnly: 'Sorts current page only', + noSubscription: 'No subscription', + publicGroupCount: '+{count} public', + exclusiveLabel: 'exclusive', + publicLabel: 'public', + daysRemaining: '{days}d', + expired: 'Expired', + disable: 'Disable', + enable: 'Enable', + disableUser: 'Disable User', + enableUser: 'Enable User', + viewApiKeys: 'View API Keys', + groups: 'Groups', + apiKeys: 'API Keys', + userApiKeys: 'User API Keys', + noApiKeys: 'This user has no API keys', + group: 'Group', + none: 'None', + groupChangedSuccess: 'Group updated successfully', + groupChangedWithGrant: 'Group updated. User auto-granted access to "{group}"', + groupChangeFailed: 'Failed to update group', + noUsersYet: 'No users yet', + createFirstUser: 'Create your first user to get started.', + userCreated: 'User created successfully', + userUpdated: 'User updated successfully', + userDeleted: 'User deleted successfully', + userEnabled: 'User enabled successfully', + userDisabled: 'User disabled successfully', + failedToLoad: 'Failed to load users', + failedToCreate: 'Failed to create user', + failedToUpdate: 'Failed to update user', + failedToDelete: 'Failed to delete user', + failedToToggle: 'Failed to update user status', + failedToLoadApiKeys: 'Failed to load user API keys', + emailRequired: 'Please enter email', + concurrencyMin: 'Concurrency must be at least 1', + soraStorageQuota: 'Sora Storage Quota', + soraStorageQuotaHint: 'In GB, 0 means use group or system default quota', + amountRequired: 'Please enter a valid amount', + insufficientBalance: 'Insufficient balance', + deleteConfirm: "Are you sure you want to delete '{email}'? This action cannot be undone.", + setAllowedGroups: 'Set Allowed Groups', + allowedGroupsHint: + 'Select which standard groups this user can use. Subscription groups are managed separately.', + noStandardGroups: 'No standard groups available', + allowAllGroups: 'Allow All Groups', + allowAllGroupsHint: 'User can use any non-exclusive group', + allowedGroupsUpdated: 'Allowed groups updated successfully', + failedToLoadGroups: 'Failed to load groups', + failedToUpdateAllowedGroups: 'Failed to update allowed groups', + // User Group Configuration + groupConfig: 'User Group Configuration', + groupConfigHint: 'Configure custom rate multipliers for user {email} (overrides group defaults)', + exclusiveGroups: 'Exclusive Groups', + publicGroups: 'Public Groups (Default Available)', + defaultRate: 'Default Rate', + customRate: 'Custom Rate', + useDefaultRate: 'Use Default', + customRatePlaceholder: 'Leave empty for default', + groupConfigUpdated: 'Group configuration updated successfully', + replaceGroup: 'Replace Group', + clickToReplace: 'Click to replace', + replaceGroupTitle: 'Replace Exclusive Group', + replaceGroupHint: 'Select a new group to replace "{old}". Keys will be migrated and permissions updated automatically.', + replaceGroupConfirm: 'Confirm Replace', + replaceGroupSuccess: 'Group replaced successfully, {count} key(s) migrated', + selectNewGroup: 'Select target group', + noOtherGroups: 'No other exclusive groups available', + deposit: 'Deposit', + withdraw: 'Withdraw', + depositAmount: 'Deposit Amount', + withdrawAmount: 'Withdraw Amount', + withdrawAll: 'All', + currentBalance: 'Current Balance', + depositNotesPlaceholder: + 'e.g., New user registration bonus, promotional credit, compensation, etc.', + withdrawNotesPlaceholder: + 'e.g., Service issue refund, incorrect charge reversal, account closure refund, etc.', + notesOptional: 'Notes are optional but helpful for record keeping', + amountHint: 'Please enter a positive amount', + newBalance: 'New Balance', + depositing: 'Depositing...', + withdrawing: 'Withdrawing...', + confirmDeposit: 'Confirm Deposit', + confirmWithdraw: 'Confirm Withdraw', + depositSuccess: 'Deposit successful', + withdrawSuccess: 'Withdraw successful', + failedToDeposit: 'Failed to deposit', + failedToWithdraw: 'Failed to withdraw', + useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance', + // Balance History + balanceHistory: 'Recharge History', + balanceHistoryTip: 'Click to open recharge history', + columnAlwaysVisible: 'This column is always visible', + // Per-platform usage breakdown (hover tooltip) + platformBreakdown: 'Per-platform breakdown', + platformBreakdownEmpty: 'No platform usage yet', + platformBreakdownHint: 'Hover for per-platform usage', + platformOther: 'Other', + balanceHistoryTitle: 'User Recharge & Concurrency History', + noBalanceHistory: 'No records found for this user', + allTypes: 'All Types', + typeBalance: 'Balance (Redeem)', + typeAffiliateBalance: 'Balance (Affiliate Transfer)', + typeAdminBalance: 'Balance (Admin)', + typeConcurrency: 'Concurrency (Redeem)', + typeAdminConcurrency: 'Concurrency (Admin)', + typeSubscription: 'Subscription', + failedToLoadBalanceHistory: 'Failed to load balance history', + createdAt: 'Created', + totalRecharged: 'Total Recharged', + roles: { + admin: 'Admin', + user: 'User' + }, + // Settings Dropdowns + filterSettings: 'Filter Settings', + columnSettings: 'Column Settings', + filterValue: 'Enter value', + // User Attributes + attributes: { + title: 'User Attributes', + description: 'Configure custom user attribute fields', + configButton: 'Attributes', + addAttribute: 'Add Attribute', + editAttribute: 'Edit Attribute', + deleteAttribute: 'Delete Attribute', + deleteConfirm: "Are you sure you want to delete attribute '{name}'? All user values for this attribute will be deleted.", + noAttributes: 'No custom attributes', + noAttributesHint: 'Click the button above to add custom attributes', + key: 'Attribute Key', + keyHint: 'For programmatic reference, only letters, numbers and underscores', + name: 'Display Name', + nameHint: 'Name shown in forms', + type: 'Attribute Type', + fieldDescription: 'Description', + fieldDescriptionHint: 'Description text for the attribute', + placeholder: 'Placeholder', + placeholderHint: 'Placeholder text for input field', + required: 'Required', + enabled: 'Enabled', + options: 'Options', + optionsHint: 'For select/multi-select types', + addOption: 'Add Option', + optionValue: 'Option Value', + optionLabel: 'Display Text', + validation: 'Validation Rules', + minLength: 'Min Length', + maxLength: 'Max Length', + min: 'Min Value', + max: 'Max Value', + pattern: 'Regex Pattern', + patternMessage: 'Validation Error Message', + types: { + text: 'Text', + textarea: 'Textarea', + number: 'Number', + email: 'Email', + url: 'URL', + date: 'Date', + select: 'Select', + multi_select: 'Multi-Select' + }, + created: 'Attribute created successfully', + updated: 'Attribute updated successfully', + deleted: 'Attribute deleted successfully', + reordered: 'Attribute order updated successfully', + failedToLoad: 'Failed to load attributes', + failedToCreate: 'Failed to create attribute', + failedToUpdate: 'Failed to update attribute', + keyRequired: 'Please enter attribute key', + nameRequired: 'Please enter display name', + optionsRequired: 'Please add at least one option', + failedToDelete: 'Failed to delete attribute', + failedToReorder: 'Failed to update order', + keyExists: 'Attribute key already exists', + dragToReorder: 'Drag to reorder' + }, + platformQuota: { + menuItem: 'Platform Quotas', + title: 'Platform Quotas', + subtitle: 'Configure daily / weekly / monthly USD usage limits for each upstream platform for user {email}', + columns: { + platform: 'Platform', + daily: 'Daily (USD)', + weekly: 'Weekly (USD)', + monthly: 'Monthly (USD, 30-day rolling)', + usage: 'Current Usage', + }, + placeholder: 'unlimited', + save: 'Save', + saving: 'Saving...', + cancel: 'Cancel', + clearAll: 'Clear All (remove all limits)', + clearAllConfirm: 'Clear daily / weekly / monthly limits for ALL platforms? All platforms will become "unlimited" with no local undo — you must manually re-enter values before saving.', + reset: { + button: 'Reset window', + confirm: 'Reset the {window} usage for {platform} for this user? This is effective immediately.', + success: 'Reset {platform} {window} usage', + failed: 'Reset failed', + }, + updateSuccess: 'Platform quotas updated', + updateFailed: 'Save failed', + loadFailed: 'Load failed', + hint: 'Empty = no limit for that window.', + windowDaily: 'daily', + windowWeekly: 'weekly', + windowMonthly: 'monthly', + cellNotConfigured: 'Not configured', + cellColumnTooltip: 'Only platforms with a limit are shown', + subscriptionWarning: 'This user has an active subscription. Platform quotas only apply to balance (standard) mode requests; subscription mode requests are not subject to these limits.', + invalidNumber: 'The following fields contain invalid numbers. Please fix them before saving: {fields}', + } + }, + + // Groups + groups: { + title: 'Group Management', + description: 'Manage API key groups and rate multipliers', + searchGroups: 'Search groups...', + createGroup: 'Create Group', + editGroup: 'Edit Group', + deleteGroup: 'Delete Group', + sortOrder: 'Sort', + columnSettings: 'Column Settings', + sortOrderHint: 'Drag groups to adjust display order, groups at the top will be displayed first', + sortOrderUpdated: 'Sort order updated', + failedToUpdateSortOrder: 'Failed to update sort order', + allPlatforms: 'All Platforms', + allStatus: 'All Status', + allGroups: 'All Groups', + exclusive: 'Exclusive', + nonExclusive: 'Non-Exclusive', + public: 'Public', + columns: { + name: 'Name', + platform: 'Platform', + rateMultiplier: 'Rate Multiplier', + rpmOverride: 'RPM Override', + rpmOverrideHint: 'Per-user RPM cap in this group; empty = group default; 0 = unlimited', + rateDefault: 'default', + rpmDefault: 'default', + type: 'Type', + accounts: 'Accounts', + capacity: 'Capacity', + usage: 'Usage', + status: 'Status', + actions: 'Actions', + billingType: 'Billing Type', + userName: 'Username', + userEmail: 'Email', + userNotes: 'Notes', + userStatus: 'Status' + }, + usageToday: 'Today', + usageTotal: 'Total', + accountsAvailable: 'Avail:', + accountsRateLimited: 'Limited:', + accountsTotal: 'Total:', + accountsUnit: '', + rateAndAccounts: '{rate}x rate · {count} accounts', + accountsCount: '{count} accounts', + rateLabel: 'rate', + accountFilters: { + title: 'Account Filter Controls', + oauthOnly: 'Only allow OAuth accounts', + oauthOnlyEnabled: 'Enabled — API Key accounts will be excluded', + privacySetOnly: 'Only allow accounts with privacy protection set', + privacySetOnlyEnabled: 'Enabled — accounts with unset Privacy will be excluded', + disabled: 'Disabled' + }, + form: { + name: 'Name', + description: 'Description', + platform: 'Platform', + rateMultiplier: 'Rate Multiplier', + status: 'Status', + exclusive: 'Exclusive Group', + rpmLimit: 'Requests Per Minute (RPM)', + rpmLimitPlaceholder: '0 = unlimited', + rpmLimitHint: 'Max requests per minute for each user in this group; 0 = unlimited. Once set, it takes over per-user rate limiting in this group (overrides the user-level rpm_limit fallback).' + }, + enterGroupName: 'Enter group name', + optionalDescription: 'Optional description', + platformHint: 'Select the platform this group is associated with', + platformNotEditable: 'Platform cannot be changed after creation', + rateMultiplierHint: 'Cost multiplier for this group (e.g., 1.5 = 150% of base cost)', + exclusiveHint: 'Exclusive group, manually assign to specific users', + exclusiveTooltip: { + title: 'What is an exclusive group?', + description: 'When enabled, users cannot see this group when creating API Keys. Only after an admin manually assigns a user to this group can they use it.', + example: 'Use case:', + exampleContent: 'Public group rate is 0.8. Create an exclusive group with 0.7 rate, manually assign VIP users to give them better pricing.' + }, + noGroupsYet: 'No groups yet', + createFirstGroup: 'Create your first group to organize API keys.', + creating: 'Creating...', + updating: 'Updating...', + limitDay: 'd', + limitWeek: 'w', + limitMonth: 'mo', + groupCreated: 'Group created successfully', + groupUpdated: 'Group updated successfully', + groupDeleted: 'Group deleted successfully', + failedToLoad: 'Failed to load groups', + failedToCreate: 'Failed to create group', + failedToUpdate: 'Failed to update group', + failedToDelete: 'Failed to delete group', + nameRequired: 'Please enter group name', + rateMultipliers: 'Rate Multipliers', + rateMultipliersTitle: 'Group Rate Multipliers', + addUserRate: 'Add User Rate Multiplier', + rpmOverrides: 'RPM Overrides', + rpmOverridesTitle: 'Group RPM Overrides', + addUserRpm: 'Add User RPM Override', + noRpmOverrides: 'No users have an RPM override yet', + rpmSaved: 'RPM overrides saved', + groupRpmDefault: 'Group default RPM', + searchUserPlaceholder: 'Search user email...', + noRateMultipliers: 'No user rate multipliers configured', + rateUpdated: 'Rate multiplier updated', + rateDeleted: 'Rate multiplier removed', + rateAdded: 'Rate multiplier added', + clearAll: 'Clear All', + confirmClearAll: 'Are you sure you want to clear all rate multiplier settings for this group? This cannot be undone.', + rateCleared: 'All rate multipliers cleared', + batchAdjust: 'Batch Adjust Rates', + multiplierFactor: 'Factor', + applyMultiplier: 'Apply', + rateAdjusted: 'Rates adjusted successfully', + rateSaved: 'Rate multipliers saved', + finalRate: 'Final Rate', + unsavedChanges: 'Unsaved changes', + revertChanges: 'Revert', + userInfo: 'User Info', + platforms: { + all: 'All Platforms', + anthropic: 'Anthropic', + openai: 'OpenAI', + gemini: 'Gemini', + antigravity: 'Antigravity', + grok: 'Grok', + }, + deleteConfirm: + "Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.", + deleteConfirmSubscription: + "Are you sure you want to delete subscription group '{name}'? This will invalidate all API keys bound to this subscription and delete all related subscription records. This action cannot be undone.", + subscription: { + title: 'Subscription Settings', + type: 'Billing Type', + typeHint: + 'Standard billing deducts from user balance. Subscription mode uses quota limits instead.', + typeNotEditable: 'Billing type cannot be changed after group creation.', + standard: 'Standard (Balance)', + subscription: 'Subscription (Quota)', + dailyLimit: 'Daily Limit (USD)', + weeklyLimit: 'Weekly Limit (USD)', + monthlyLimit: 'Monthly Limit (USD)', + defaultValidityDays: 'Default Validity (Days)', + validityHint: 'Number of days the subscription is valid when assigned to a user', + noLimit: 'No limit' + }, + imagePricing: { + title: 'Image Generation Pricing', + description: 'Configure image generation access and base image prices. Leave empty to use default prices.', + allowImageGeneration: 'Allow image generation for this group', + allowBatchImageGeneration: 'Allow batch image generation for this group', + independentMultiplier: 'Use independent image multiplier', + imageMultiplier: 'Image multiplier', + batchDiscountMultiplier: 'Batch image discount', + batchHoldMultiplier: 'Batch hold price ratio', + batchSectionHint: 'Batch image settings only apply to batch jobs: settlement applies the batch discount, and the upfront hold is normal image price × batch hold price ratio. Reference images also create upstream input-token usage, so a batch image discount above 0.5 is recommended.', + batchDisabledHint: 'Enable image generation for this group before enabling batch image generation.', + batchGeminiOnlyHint: 'Batch image generation is currently available only for Gemini groups.', + modeHint: 'By default, image billing uses image price × current effective group multiplier. Independent mode uses image price × image multiplier.', + finalPricePreview: 'Final per-image price preview', + notConfigured: 'Not configured' + }, + peakRate: { + enable: 'Enable peak rate multiplier', + peakStart: 'Peak start', + peakEnd: 'Peak end', + peakMultiplier: 'Peak multiplier', + multiplierHint: 'Applies to token billing multiplier; image tokens in token billing are also affected. 0 means peak token requests are billed at 0x.' + }, + modelsList: { + title: 'Custom /v1/models Model List', + hint: 'Only changes the /v1/models response. Whitelist model calls and account routing are unchanged.', + loading: 'Loading model list...', + empty: 'No displayable models', + selectedSummary: 'Selected {selected} / {total}', + selectAll: 'Select all', + invertSelection: 'Invert' + }, + claudeCode: { + title: 'Claude Code Client Restriction', + tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.', + enabled: 'Claude Code Only', + disabled: 'Allow All Clients', + fallbackGroup: 'Fallback Group', + fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.', + noFallback: 'No Fallback (Reject)' + }, + openaiMessages: { + title: 'OpenAI Messages Dispatch', + allowDispatch: 'Allow /v1/messages dispatch', + allowDispatchHint: 'When enabled, API keys in this OpenAI group can dispatch requests through /v1/messages endpoint', + familyMappingTitle: 'Family Default Mapping', + familyMappingHint: 'Requests that match the Opus, Sonnet, or Haiku families will prefer the target model configured here.', + opusModel: 'Opus Target Model', + opusModelPlaceholder: 'e.g., gpt-5.4', + sonnetModel: 'Sonnet Target Model', + sonnetModelPlaceholder: 'e.g., gpt-5.3-codex', + haikuModel: 'Haiku Target Model', + haikuModelPlaceholder: 'e.g., gpt-5.4-mini', + exactMappingTitle: 'Exact Model Overrides', + exactMappingHint: 'Exact Claude model overrides take priority over the family defaults and can route a specific Claude model to a different target model.', + noExactMappings: 'No exact model overrides yet', + addExactMapping: 'Add Exact Mapping', + claudeModel: 'Claude Model', + claudeModelPlaceholder: 'e.g., claude-sonnet-4-5-20250929', + targetModel: 'Target Model', + targetModelPlaceholder: 'e.g., gpt-5.4', + removeExactMapping: 'Remove Exact Mapping' + }, + invalidRequestFallback: { + title: 'Invalid Request Fallback Group', + hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.', + noFallback: 'No Fallback' + }, + copyAccounts: { + title: 'Copy Accounts from Groups', + tooltip: 'Select one or more groups of the same platform. After creation, all accounts from these groups will be automatically bound to the new group (deduplicated).', + tooltipEdit: 'Select one or more groups of the same platform. After saving, current group accounts will be replaced with accounts from these groups (deduplicated).', + selectPlaceholder: 'Select groups to copy accounts from...', + hint: 'Multiple groups can be selected, accounts will be deduplicated', + hintEdit: '⚠️ Warning: This will replace all existing account bindings' + }, + modelRouting: { + title: 'Model Routing', + tooltip: 'Configure specific model requests to be routed to designated accounts. Supports wildcard matching, e.g., claude-opus-* matches all opus models.', + enabled: 'Enabled', + disabled: 'Disabled', + disabledHint: 'Routing rules will only take effect when enabled', + addRule: 'Add Routing Rule', + modelPattern: 'Model Pattern', + modelPatternPlaceholder: 'claude-opus-*', + modelPatternHint: 'Supports * wildcard, e.g., claude-opus-* matches all opus models', + accounts: 'Priority Accounts', + selectAccounts: 'Select accounts', + noAccounts: 'No accounts in this group', + loadingAccounts: 'Loading accounts...', + removeRule: 'Remove Rule', + noRules: 'No routing rules', + noRulesHint: 'Add routing rules to route specific model requests to designated accounts', + searchAccountPlaceholder: 'Search accounts...', + accountsHint: 'Select accounts to prioritize for this model pattern' + }, + mcpXml: { + title: 'MCP XML Protocol Injection', + tooltip: 'When enabled, if the request contains MCP tools, an XML format call protocol prompt will be injected into the system prompt. Disable this to avoid interference with certain clients.', + enabled: 'Enabled', + disabled: 'Disabled' + }, + claudeMaxSimulation: { + title: 'Claude Max Usage Simulation', + tooltip: + 'When enabled, for Claude models without upstream cache-write usage, the system deterministically maps tokens to a small input plus 1h cache creation while keeping total tokens unchanged.', + enabled: 'Enabled (simulate 1h cache)', + disabled: 'Disabled', + hint: 'Only token categories in usage billing logs are adjusted. No per-request mapping state is persisted.' + }, + supportedScopes: { + title: 'Supported Model Families', + tooltip: 'Select the model families this group supports. Unchecked families will not be routed to this group.', + claude: 'Claude', + geminiText: 'Gemini Text', + geminiImage: 'Gemini Image', + hint: 'Select at least one model family' + } + }, + + // Available Channels (aggregated read-only view) +} diff --git a/frontend/src/i18n/locales/en/admin/resources.ts b/frontend/src/i18n/locales/en/admin/resources.ts new file mode 100644 index 0000000000..30b05e5e6b --- /dev/null +++ b/frontend/src/i18n/locales/en/admin/resources.ts @@ -0,0 +1,520 @@ +export default { + scheduledTests: { + title: 'Scheduled Tests', + addPlan: 'Add Plan', + editPlan: 'Edit Plan', + deletePlan: 'Delete Plan', + model: 'Model', + cronExpression: 'Cron Expression', + enabled: 'Enabled', + lastRun: 'Last Run', + nextRun: 'Next Run', + maxResults: 'Max Results', + noPlans: 'No scheduled test plans', + confirmDelete: 'Are you sure you want to delete this plan?', + createSuccess: 'Plan created successfully', + updateSuccess: 'Plan updated successfully', + deleteSuccess: 'Plan deleted successfully', + results: 'Test Results', + noResults: 'No test results yet', + responseText: 'Response', + errorMessage: 'Error', + success: 'Success', + failed: 'Failed', + running: 'Running', + schedule: 'Schedule', + cronHelp: 'Standard 5-field cron expression (e.g., */30 * * * *)', + cronTooltipTitle: 'Cron expression examples:', + cronTooltipMeaning: 'Defines when the test runs automatically. The 5 fields are: minute, hour, day, month, and weekday.', + cronTooltipExampleEvery30Min: '*/30 * * * *: run every 30 minutes', + cronTooltipExampleHourly: '0 * * * *: run at the start of every hour', + cronTooltipExampleDaily: '0 9 * * *: run every day at 09:00', + cronTooltipExampleWeekly: '0 9 * * 1: run every Monday at 09:00', + cronTooltipRange: 'Recommended range: use standard 5-field cron. For health checks, start with a moderate frequency such as every 30 minutes, every hour, or once a day instead of running too often.', + maxResultsTooltipTitle: 'What Max Results means:', + maxResultsTooltipMeaning: 'Sets how many historical test results are kept for a single plan so the result list does not grow without limit.', + maxResultsTooltipBody: 'Only the newest test results are kept. Once the number of saved results exceeds this value, older records are pruned automatically so the history list and storage stay under control.', + maxResultsTooltipExample: 'For example, 100 means keeping at most the latest 100 test results. When the 101st result is saved, the oldest one is removed.', + maxResultsTooltipRange: 'Recommended range: usually 20 to 200. Use 20-50 when you only care about recent health status, or 100-200 if you want a longer trend history.', + autoRecover: 'Auto Recover', + autoRecoverHelp: 'Automatically recover account from error/rate-limited state on successful test' + }, + + // Proxies + proxies: { + title: 'Proxy Management', + description: 'Manage proxy servers for accounts', + createProxy: 'Create Proxy', + editProxy: 'Edit Proxy', + deleteProxy: 'Delete Proxy', + ad: { + inline: 'Need proxy IP?' + }, + dataImport: 'Import', + dataExportSelected: 'Export Selected', + dataImportTitle: 'Import Proxies', + dataImportHint: 'Upload the exported proxy JSON file to import proxies in bulk.', + dataImportWarning: 'Import will create or reuse proxies, keep their status, and trigger latency checks after completion.', + dataImportFile: 'Data File', + dataImportButton: 'Start Import', + dataImporting: 'Importing...', + dataImportSelectFile: 'Please select a data file', + dataImportParseFailed: 'Failed to parse data', + dataImportFailed: 'Failed to import data', + dataImportResult: 'Import Result', + dataImportResultSummary: 'Created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}', + dataImportErrors: 'Failure Details', + dataImportSuccess: 'Import completed: created {proxy_created}, reused {proxy_reused}', + dataImportCompletedWithErrors: 'Import completed with errors: failed {proxy_failed}', + dataExport: 'Export', + dataExportConfirmMessage: 'The exported data contains sensitive proxy information. Store it securely.', + dataExportConfirm: 'Confirm Export', + dataExported: 'Data exported successfully', + dataExportFailed: 'Failed to export data', + copyProxyUrl: 'Copy Proxy URL', + urlCopied: 'Proxy URL copied', + searchProxies: 'Search proxies...', + allProtocols: 'All Protocols', + allStatus: 'All Status', + protocols: { + http: 'HTTP', + https: 'HTTPS', + socks5: 'SOCKS5', + socks5h: 'SOCKS5H (Remote DNS)' + }, + columns: { + name: 'Name', + protocol: 'Protocol', + address: 'Address', + auth: 'Auth', + location: 'Location', + status: 'Status', + accounts: 'Accounts', + latency: 'Latency', + expiry: 'Validity', + createdAt: 'Created', + actions: 'Actions' + }, + testConnection: 'Test Connection', + qualityCheck: 'Quality Check', + batchQualityCheck: 'Batch Quality Check', + batchTest: 'Test All Proxies', + testFailed: 'Failed', + latencyFailed: 'Connection failed', + batchTestEmpty: 'No proxies available for testing', + batchTestDone: 'Batch test completed for {count} proxies', + batchTestFailed: 'Batch test failed', + batchDeleteAction: 'Delete', + batchDelete: 'Batch delete', + batchDeleteConfirm: 'Delete {count} selected proxies? In-use ones will be skipped.', + batchDeleteDone: 'Deleted {deleted} proxies, skipped {skipped}', + batchDeleteSkipped: 'Skipped {skipped} proxies', + batchDeleteFailed: 'Batch delete failed', + deleteBlockedInUse: 'This proxy is in use and cannot be deleted', + accountsTitle: 'Accounts using this IP', + accountsEmpty: 'No accounts are using this proxy', + accountsFailed: 'Failed to load accounts list', + accountName: 'Account', + accountPlatform: 'Platform', + accountNotes: 'Notes', + name: 'Name', + protocol: 'Protocol', + host: 'Host', + port: 'Port', + username: 'Username (Optional)', + password: 'Password (Optional)', + status: 'Status', + enterProxyName: 'Enter proxy name', + leaveEmptyToKeep: 'Leave empty to keep current', + optionalAuth: 'Optional authentication', + form: { + hostPlaceholder: 'proxy.example.com', + portPlaceholder: '8080' + }, + noProxiesYet: 'No proxies yet', + createFirstProxy: 'Create your first proxy to route traffic through it.', + // Batch import + standardAdd: 'Standard Add', + batchAdd: 'Quick Add', + batchInput: 'Proxy List', + batchInputPlaceholder: + "Enter one proxy per line in the following formats:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443", + batchInputHint: + "Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port", + parsedCount: '{count} valid', + invalidCount: '{count} invalid', + duplicateCount: '{count} duplicate', + importing: 'Importing...', + importProxies: 'Import {count} proxies', + batchImportSuccess: 'Successfully imported {created} proxies, skipped {skipped} duplicates', + batchImportAllSkipped: 'All {skipped} proxies already exist, skipped import', + failedToImport: 'Failed to batch import', + // Other messages + creating: 'Creating...', + updating: 'Updating...', + proxyCreated: 'Proxy created successfully', + proxyUpdated: 'Proxy updated successfully', + proxyDeleted: 'Proxy deleted successfully', + proxyWorking: 'Proxy is working!', + proxyWorkingWithLatency: 'Proxy is working! Latency: {latency}ms', + proxyTestFailed: 'Proxy test failed', + qualityCheckDone: 'Quality check completed: score {score} ({grade})', + qualityCheckFailed: 'Failed to run proxy quality check', + batchQualityDone: + 'Batch quality check completed for {count} proxies: healthy {healthy}, warn {warn}, challenge {challenge}, abnormal {failed}', + batchQualityFailed: 'Batch quality check failed', + batchQualityEmpty: 'No proxies available for quality check', + qualityReportTitle: 'Proxy Quality Report', + qualityGrade: 'Grade {grade}', + qualityExitIP: 'Exit IP', + qualityCountry: 'Exit Region', + qualityBaseLatency: 'Base Latency', + qualityCheckedAt: 'Checked At', + qualityTableTarget: 'Target', + qualityTableStatus: 'Status', + qualityTableLatency: 'Latency', + qualityTableMessage: 'Message', + qualityInline: 'Quality {grade}/{score}', + qualityStatusHealthy: 'Healthy', + qualityStatusPass: 'Pass', + qualityStatusWarn: 'Warn', + qualityStatusFail: 'Fail', + qualityStatusChallenge: 'Challenge', + qualityTargetBase: 'Base Connectivity', + failedToLoad: 'Failed to load proxies', + failedToCreate: 'Failed to create proxy', + failedToUpdate: 'Failed to update proxy', + failedToDelete: 'Failed to delete proxy', + failedToTest: 'Failed to test proxy', + nameRequired: 'Please enter proxy name', + hostRequired: 'Please enter host address', + portInvalid: 'Port must be between 1-65535', + deleteConfirm: + "Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed.", + neverExpires: 'Never', + expired: 'Expired', + overdueDays: 'Overdue {days}d', + expiringInDays: 'Expires in {days}d', + remainingDays: '{days}d left', + expiresAt: 'Validity', + nDays: '{days}d', + expiryDaysPlaceholder: 'Custom days, empty = never', + expiryWarnDays: 'Expiry warning (days)', + fallbackMode: 'Failure fallback', + fallbackNone: 'No fallback', + fallbackProxy: 'Backup proxy', + fallbackDirect: 'Direct connection', + backupProxy: 'Backup proxy', + }, + + // Redeem Codes + redeem: { + title: 'Redeem Code Management', + description: 'Generate and manage redeem codes', + generateCodes: 'Generate Codes', + searchCodes: 'Search codes or email...', + allTypes: 'All Types', + allStatus: 'All Status', + balance: 'Balance', + concurrency: 'Concurrency', + subscription: 'Subscription', + invitation: 'Invitation', + invitationHint: 'Invitation codes are used to restrict user registration. They are automatically marked as used after use.', + unused: 'Unused', + used: 'Used', + columns: { + code: 'Code', + type: 'Type', + value: 'Value', + status: 'Status', + usedBy: 'Used By', + usedAt: 'Used At', + expiresAt: 'Expires At', + actions: 'Actions' + }, + userPrefix: 'User #{id}', + exportCsv: 'Export CSV', + batchUpdate: 'Batch Update', + batchUpdateTitle: 'Batch Update Redeem Codes', + selectedCount: '{count} redeem code(s) selected', + clearSelection: 'Clear selection', + selectCodesFirst: 'Select redeem codes first', + noBatchFieldsSelected: 'Select at least one field to update', + batchUpdateSuccess: 'Updated {count} redeem code(s)', + failedToBatchUpdate: 'Failed to batch update redeem codes', + batchFields: { + status: 'Status', + expiresAt: 'Expires At', + notes: 'Notes', + group: 'Group' + }, + batchNotesPlaceholder: 'Enter the new note, or leave blank to clear it', + clearGroup: 'Clear group', + deleteAllUnused: 'Delete All Unused Codes', + deleteCode: 'Delete Redeem Code', + deleteCodeConfirm: + 'Are you sure you want to delete this redeem code? This action cannot be undone.', + deleteAllUnusedConfirm: + 'Are you sure you want to delete all unused (active) redeem codes? This action cannot be undone.', + deleteAll: 'Delete All', + generateCodesTitle: 'Generate Redeem Codes', + generatedSuccessfully: 'Generated Successfully', + codesCreated: '{count} redeem code(s) created', + codeType: 'Code Type', + amount: 'Amount ($)', + value: 'Value', + count: 'Count', + generating: 'Generating...', + generate: 'Generate', + copyAll: 'Copy All', + copied: 'Copied!', + download: 'Download', + codesExported: 'Codes exported successfully', + codeDeleted: 'Redeem code deleted successfully', + codesDeleted: 'Successfully deleted {count} unused code(s)', + noUnusedCodes: 'No unused codes to delete', + failedToLoad: 'Failed to load redeem codes', + failedToGenerate: 'Failed to generate codes', + failedToExport: 'Failed to export codes', + failedToDelete: 'Failed to delete code', + failedToDeleteUnused: 'Failed to delete unused codes', + failedToCopy: 'Failed to copy codes', + types: { + balance: 'Balance', + concurrency: 'Concurrency', + subscription: 'Subscription', + invitation: 'Invitation', + // Admin adjustment types (created when admin modifies user balance/concurrency) + admin_balance: 'Balance (Admin)', + admin_concurrency: 'Concurrency (Admin)' + }, + selectGroup: 'Select Group', + selectGroupPlaceholder: 'Choose a subscription group', + validityDays: 'Validity Days', + codeExpiry: 'Code Expiry', + neverExpires: 'Never expires', + expiryPresetDays: '{days} days', + customExpiry: 'Custom', + customExpiryDays: 'Custom days', + expiryDaysRequired: 'Please enter a valid expiry day count', + groupRequired: 'Please select a subscription group', + days: ' days', + status: { + unused: 'Unused', + used: 'Used', + expired: 'Expired', + disabled: 'Disabled' + } + }, + + // Announcements + announcements: { + title: 'Announcements', + description: 'Create announcements and target by conditions', + createAnnouncement: 'Create Announcement', + editAnnouncement: 'Edit Announcement', + deleteAnnouncement: 'Delete Announcement', + searchAnnouncements: 'Search announcements...', + status: 'Status', + allStatus: 'All Status', + columns: { + title: 'Title', + status: 'Status', + notifyMode: 'Notify Mode', + targeting: 'Targeting', + timeRange: 'Schedule', + createdAt: 'Created At', + actions: 'Actions' + }, + statusLabels: { + draft: 'Draft', + active: 'Active', + archived: 'Archived' + }, + notifyModeLabels: { + silent: 'Silent', + popup: 'Popup' + }, + form: { + title: 'Title', + content: 'Content (Markdown supported)', + status: 'Status', + notifyMode: 'Notify Mode', + notifyModeHint: 'Popup mode will show a popup notification to users', + startsAt: 'Starts At', + endsAt: 'Ends At', + startsAtHint: 'Leave empty to start immediately', + endsAtHint: 'Leave empty to never expire', + targetingMode: 'Targeting', + targetingAll: 'All users', + targetingCustom: 'Custom rules', + addOrGroup: 'Add OR group', + addAndCondition: 'Add AND condition', + conditionType: 'Condition type', + conditionSubscription: 'Subscription', + conditionBalance: 'Balance', + operator: 'Operator', + balanceValue: 'Balance threshold', + selectPackages: 'Select packages' + }, + operators: { + gt: '>', + gte: '≥', + lt: '<', + lte: '≤', + eq: '=' + }, + targetingSummaryAll: 'All users', + targetingSummaryCustom: 'Custom ({groups} groups)', + timeImmediate: 'Immediate', + timeNever: 'Never', + readStatus: 'Read Status', + eligible: 'Eligible', + readAt: 'Read at', + unread: 'Unread', + searchUsers: 'Search users...', + failedToLoad: 'Failed to load announcements', + failedToCreate: 'Failed to create announcement', + failedToUpdate: 'Failed to update announcement', + failedToDelete: 'Failed to delete announcement', + failedToLoadReadStatus: 'Failed to load read status', + deleteConfirm: 'Are you sure you want to delete this announcement? This action cannot be undone.' + }, + + // Promo Codes + promo: { + title: 'Promo Code Management', + description: 'Create and manage registration promo codes', + createCode: 'Create Promo Code', + editCode: 'Edit Promo Code', + deleteCode: 'Delete Promo Code', + searchCodes: 'Search codes...', + allStatus: 'All Status', + columns: { + code: 'Code', + bonusAmount: 'Bonus Amount', + maxUses: 'Max Uses', + usedCount: 'Used', + usage: 'Usage', + status: 'Status', + expiresAt: 'Expires At', + createdAt: 'Created At', + actions: 'Actions' + }, + // Form labels (flat structure for template usage) + code: 'Promo Code', + autoGenerate: 'auto-generate if empty', + codePlaceholder: 'Enter promo code or leave empty', + bonusAmount: 'Bonus Amount ($)', + maxUses: 'Max Uses', + zeroUnlimited: '0 = unlimited', + expiresAt: 'Expires At', + notes: 'Notes', + notesPlaceholder: 'Optional notes for this code', + status: 'Status', + neverExpires: 'Never expires', + // Status labels + statusActive: 'Active', + statusDisabled: 'Disabled', + statusExpired: 'Expired', + statusMaxUsed: 'Used Up', + // Usage records + usageRecords: 'Usage Records', + viewUsages: 'View Usages', + noUsages: 'No usage records yet', + userPrefix: 'User #{id}', + copied: 'Copied!', + // Messages + noCodesYet: 'No promo codes yet', + createFirstCode: 'Create your first promo code to offer registration bonuses.', + codeCreated: 'Promo code created successfully', + codeUpdated: 'Promo code updated successfully', + codeDeleted: 'Promo code deleted successfully', + deleteCodeConfirm: 'Are you sure you want to delete this promo code? This action cannot be undone.', + copyRegisterLink: 'Copy register link', + registerLinkCopied: 'Register link copied to clipboard', + failedToLoad: 'Failed to load promo codes', + failedToCreate: 'Failed to create promo code', + failedToUpdate: 'Failed to update promo code', + failedToDelete: 'Failed to delete promo code', + failedToLoadUsages: 'Failed to load usage records' + }, + + // Usage Records + usage: { + title: 'Usage Records', + description: 'View and manage all user usage records', + userFilter: 'User', + searchUserPlaceholder: 'Search user by email...', + searchApiKeyPlaceholder: 'Search API key by name...', + searchAccountPlaceholder: 'Search account by name...', + selectedUser: 'Selected', + user: 'User', + account: 'Account', + group: 'Group', + requestId: 'Request ID', + requestIdCopied: 'Request ID copied', + allModels: 'All Models', + allAccounts: 'All Accounts', + allGroups: 'All Groups', + allTypes: 'All Types', + inputCost: 'Input Cost', + outputCost: 'Output Cost', + cacheCreationCost: 'Cache Creation Cost', + cacheReadCost: 'Cache Read Cost', + inputTokens: 'Input Tokens', + outputTokens: 'Output Tokens', + cacheCreationTokens: 'Cache Creation Tokens', + cacheCreation5mTokens: 'Cache Write', + cacheCreation1hTokens: 'Cache Write', + cacheReadTokens: 'Cache Read Tokens', + failedToLoad: 'Failed to load usage records', + billingType: 'Billing Type', + allBillingTypes: 'All Billing Types', + billingTypeBalance: 'Balance', + billingTypeSubscription: 'Subscription', + billingMode: 'Billing Mode', + billingModeToken: 'Token', + billingModePerRequest: 'Per Request', + billingModeImage: 'Image', + allBillingModes: 'All Billing Modes', + ipAddress: 'IP', + clickToViewBalance: 'Click to view balance history', + failedToLoadUser: 'Failed to load user info', + userDeletedBadge: 'Deleted', + cleanup: { + button: 'Cleanup', + title: 'Cleanup Usage Records', + warning: 'Cleanup is irreversible and will affect historical stats.', + submit: 'Submit Cleanup', + submitting: 'Submitting...', + confirmTitle: 'Confirm Cleanup', + confirmMessage: 'Are you sure you want to submit this cleanup task? This action cannot be undone.', + confirmSubmit: 'Confirm Cleanup', + cancel: 'Cancel', + cancelConfirmTitle: 'Confirm Cancel', + cancelConfirmMessage: 'Are you sure you want to cancel this cleanup task?', + cancelConfirm: 'Confirm Cancel', + cancelSuccess: 'Cleanup task canceled', + cancelFailed: 'Failed to cancel cleanup task', + recentTasks: 'Recent Cleanup Tasks', + loadingTasks: 'Loading tasks...', + noTasks: 'No cleanup tasks yet', + range: 'Range', + deletedRows: 'Deleted', + missingRange: 'Please select a date range', + submitSuccess: 'Cleanup task created', + submitFailed: 'Failed to create cleanup task', + loadFailed: 'Failed to load cleanup tasks', + status: { + pending: 'Pending', + running: 'Running', + succeeded: 'Succeeded', + failed: 'Failed', + canceled: 'Canceled' + } + } + }, + + // Ops Monitoring +} diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts new file mode 100644 index 0000000000..d3d3f41ac9 --- /dev/null +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -0,0 +1,1269 @@ +export default { + settings: { + title: 'System Settings', + description: 'Manage registration, email verification, default values, and SMTP settings', + tabs: { + general: 'General', + agreement: 'Agreement', + features: 'Feature Switches', + security: 'Security', + users: 'Users', + gateway: 'Gateway', + email: 'Email', + backup: 'Backup', + payment: 'Payment', + }, + features: { + channelMonitor: { + title: 'Channel Monitor', + description: 'Periodically probe configured channels and surface availability / latency to users. Turning it off stops the scheduler and returns an empty list on the user page.', + configureLink: 'Configure monitors in Channel Management > Channel Monitor', + enabled: 'Enable Channel Monitor', + enabledHint: 'Disabling stops background checks; existing history is preserved.', + defaultInterval: 'Default check interval (seconds)', + defaultIntervalHint: 'Pre-fills the interval when creating a new monitor; each monitor can override it. Range 15 – 3600.', + }, + availableChannels: { + title: 'Available Channels', + description: 'Show logged-in users an aggregate view of the channels, models and pricing they can access. Disabled by default.', + configureLink: 'Configure model pricing in Channel Management > Channel Pricing', + enabled: 'Enable Available Channels', + enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.', + }, + riskControl: { + title: 'Risk Control', + description: 'Enable the content moderation menu and gateway audit entry point. Disabled by default.', + configureLink: 'Configure content moderation in Risk Control', + enabled: 'Enable Risk Control', + enabledHint: 'When off, the admin sidebar entry is hidden and gateway moderation is skipped.', + cyberSessionBlock: 'Cyber session auto-block', + cyberSessionBlockHint: 'When enabled, sessions hit by upstream cyber_policy are blocked locally for the TTL and no longer forwarded. Only the offending session is blocked; other sessions on the same key are unaffected.', + cyberSessionBlockTTL: 'Block TTL (seconds)', + }, + affiliate: { + title: 'Affiliate (Invite Rebate)', + description: 'Existing users invite new ones; the inviter earns a percentage rebate on the invitee’s recharges. Disabled by default.', + enabled: 'Enable Affiliate', + enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.', + rebateRate: 'Global Rebate Rate', + rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).', + freezeHours: 'Rebate Freeze Period (hours)', + freezeHoursDesc: 'New rebates will be frozen for this period before becoming available for withdrawal. 0 = no freeze.', + durationDays: 'Rebate Duration (days)', + durationDaysDesc: 'Rebate relationship expires after this many days since invitee registration. 0 = permanent.', + perInviteeCap: 'Per-Invitee Rebate Cap', + perInviteeCapDesc: 'Maximum total rebate from a single invitee. 0 = no limit.', + customUsers: { + title: 'Per-User Overrides', + description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.', + addButton: 'Add Custom User', + searchPlaceholder: 'Search by email or username', + batchButton: 'Batch Set Rate ({count} selected)', + empty: 'No users with custom affiliate settings yet', + customBadge: 'custom', + useGlobal: 'use global', + resetTitle: 'Reset Custom Settings', + resetMessage: 'Reset all custom settings for {email}?\n• The exclusive rebate rate will be cleared (fall back to the global rate)\n• The invite code will be regenerated as a new system code (previously shared links will stop working)', + totalLabel: '{total} total', + col: { + email: 'Email', + username: 'Username', + code: 'Invite Code', + rate: 'Custom Rate', + actions: 'Actions', + }, + }, + modal: { + addTitle: 'Add Custom User', + editTitle: 'Edit Custom Settings', + userLabel: 'User', + userPlaceholder: 'Search by email or username', + changeUser: 'Change user', + codeLabel: 'Custom Invite Code (optional)', + codePlaceholder: 'e.g. VIP2026', + codeHint: '4-32 characters; A-Z, 0-9, underscore, dash. Leave empty to keep current. Input is upper-cased.', + rateLabel: 'Exclusive Rebate Rate (optional)', + ratePlaceholder: 'e.g. 30', + rateHint: '0-100. Leave empty (in edit mode) to clear and fall back to the global rate.', + errorBadRate: 'Please enter a number between 0 and 100', + errorEmpty: 'Fill at least one: custom invite code or exclusive rebate rate', + }, + batchModal: { + title: 'Batch Set Rate ({count} users selected)', + hint: 'Apply the same exclusive rebate rate to all selected users.', + placeholder: 'e.g. 30', + clearHint: 'Submitting empty will clear the exclusive rate for selected users.', + }, + }, + }, + emailTabDisabledTitle: 'Email Verification Not Enabled', + emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.', + registration: { + title: 'Registration Settings', + description: 'Control user registration and verification', + enableRegistration: 'Enable Registration', + enableRegistrationHint: 'Allow new users to register', + emailVerification: 'Email Verification', + emailVerificationHint: 'Require email verification for new registrations', + emailSuffixWhitelist: 'Email Domain Whitelist', + emailSuffixWhitelistHint: + "Only email addresses from the specified domains can register (for example, {'@'}qq.com, {'@'}gmail.com, *.edu.cn)", + emailSuffixWhitelistPlaceholder: "{'@'}example.com, *.edu.cn", + emailSuffixWhitelistInputHint: 'Leave empty for no restriction. Use *.edu.cn to match edu.cn and its subdomains.', + promoCode: 'Promo Code', + promoCodeHint: 'Allow users to use promo codes during registration', + invitationCode: 'Invitation Code Registration', + invitationCodeHint: 'When enabled, users must enter a valid invitation code to register', + passwordReset: 'Password Reset', + passwordResetHint: 'Allow users to reset their password via email', + frontendUrl: 'Frontend URL', + frontendUrlPlaceholder: 'https://example.com', + frontendUrlHint: 'Used to generate password reset links in emails. Example: https://example.com', + totp: 'Two-Factor Authentication (2FA)', + totpHint: 'Allow users to use authenticator apps like Google Authenticator', + totpKeyNotConfigured: + 'Please configure TOTP_ENCRYPTION_KEY in environment variables first. Generate a key with: openssl rand -hex 32' + }, + turnstile: { + title: 'Cloudflare Turnstile', + description: 'Bot protection for login and registration', + enableTurnstile: 'Enable Turnstile', + enableTurnstileHint: 'Require Cloudflare Turnstile verification', + siteKey: 'Site Key', + secretKey: 'Secret Key', + siteKeyHint: 'Get this from your Cloudflare Dashboard', + cloudflareDashboard: 'Cloudflare Dashboard', + secretKeyHint: 'Server-side verification key (keep this secret)', + secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' + }, + apiKeyAcl: { + title: 'API Key IP Access Control', + description: 'Choose which client IP is used by API Key allowlists and denylists', + trustForwardedIp: 'Trust forwarded client IP', + trustForwardedIpHint: + 'Disabled by default. Enable only when the origin is reachable only through Cloudflare or Nginx reverse proxy. When enabled, API Key IP allowlists and denylists use CF-Connecting-IP, X-Real-IP, or X-Forwarded-For, matching the request IP shown in usage records.' + }, + linuxdo: { + title: 'LinuxDo Connect Login', + description: 'Configure LinuxDo Connect OAuth for Sub2API end-user login', + enable: 'Enable LinuxDo Login', + enableHint: 'Show LinuxDo login on the login/register pages', + clientId: 'Client ID', + clientIdPlaceholder: 'e.g., hprJ5pC3...', + clientIdHint: 'Get this from Connect.Linux.Do', + clientSecret: 'Client Secret', + clientSecretPlaceholder: '********', + clientSecretHint: 'Used by backend to exchange tokens (keep it secret)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', + redirectUrl: 'Redirect URL', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/linuxdo/callback', + redirectUrlHint: + 'Must match the redirect URL configured in Connect.Linux.Do (must be an absolute http(s) URL)', + quickSetCopy: 'Generate & Copy (current site)', + redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard' + }, + dingtalk: { + title: 'DingTalk Login', + description: 'Configure DingTalk OAuth for Sub2API end-user login', + enable: 'Enable DingTalk Login (Internal Corporate App)', + enableHint: 'Show DingTalk login on the login/register pages', + clientId: 'Client ID (AppKey)', + clientIdPlaceholder: 'e.g., dingxxxxxxxxxxxxxxxx', + clientIdHint: 'Get this from the DingTalk Open Platform app details', + clientSecret: 'Client Secret (AppSecret)', + clientSecretPlaceholder: '********', + clientSecretHint: 'Used by backend to exchange tokens (keep it secret)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', + redirectUrl: 'Redirect URL', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/dingtalk/callback', + redirectUrlHint: + 'Must match the redirect URL configured in DingTalk Open Platform (must be an absolute http(s) URL)', + corpPolicy: { + label: 'Corp Restriction Policy', + hint: 'Control which DingTalk accounts (orgs) are allowed to sign in', + none: 'No restriction (all DingTalk accounts allowed)', + internalOnly: 'Internal only (single corp)' + }, + bypassRegistration: 'Enable DingTalk signup', + bypassRegistrationHint: 'Allow new users to register via DingTalk even when public registration is disabled.', + syncDisplayName: 'Sync DingTalk display name', + syncDisplayNameHint: 'Overwrite username with the DingTalk staff name on each login (also stored in the dingtalk_name attribute).', + syncCorpEmail: 'Sync corporate email', + syncCorpEmailHint: 'Write the DingTalk corporate email to the dingtalk_email attribute on each login (does not change the login email).', + syncCorpEmailPermissionHint: 'Requires the OAPI permission "Personal info incl. email (fieldEmail)" to be granted to the app on the DingTalk open platform, otherwise OAPI will not return the email field.', + syncDept: 'Sync department', + syncDeptHint: 'Write the full DingTalk department path to the dingtalk_department attribute on each login (fetched live each time).', + syncDeptPermissionHint: 'Requires the OAPI "Department info read (qyapi_get_department_list)" permission to be granted to the app on the DingTalk open platform, otherwise the department path cannot be resolved.', + syncDisplayNameTarget: 'Attribute key', + syncDisplayNameTargetHint: 'Defaults to dingtalk_name / DingTalk Name. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).', + syncCorpEmailTarget: 'Attribute key', + syncCorpEmailTargetHint: 'Defaults to dingtalk_email / DingTalk Corporate Email. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).', + syncDeptTarget: 'Attribute key', + syncDeptTargetHint: 'Defaults to dingtalk_department / DingTalk Department. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).', + syncAttrDisplayName: 'Display name' + }, + oidc: { + title: 'OIDC Login', + description: 'Configure a standard OIDC provider (for example Keycloak)', + enable: 'Enable OIDC Login', + enableHint: 'Show OIDC login on the login/register pages', + providerName: 'Provider Name', + providerNamePlaceholder: 'for example Keycloak', + clientId: 'Client ID', + clientIdPlaceholder: 'OIDC client id', + clientSecret: 'Client Secret', + clientSecretPlaceholder: '********', + clientSecretHint: 'Used by backend to exchange tokens (keep it secret)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', + issuerUrl: 'Issuer URL', + issuerUrlPlaceholder: 'https://id.example.com/realms/main', + discoveryUrl: 'Discovery URL', + discoveryUrlPlaceholder: 'Optional, leave empty to auto-derive from issuer', + authorizeUrl: 'Authorize URL', + authorizeUrlPlaceholder: 'Optional, can be discovered automatically', + tokenUrl: 'Token URL', + tokenUrlPlaceholder: 'Optional, can be discovered automatically', + userinfoUrl: 'UserInfo URL', + userinfoUrlPlaceholder: 'Optional, can be discovered automatically', + jwksUrl: 'JWKS URL', + jwksUrlPlaceholder: 'Optional, required when strict ID token validation is enabled', + scopes: 'Scopes', + scopesPlaceholder: 'openid email profile', + scopesHint: 'Must include openid', + redirectUrl: 'Backend Redirect URL', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback', + redirectUrlHint: 'Must match the callback URL configured in the OIDC provider', + quickSetCopy: 'Generate & Copy (current site)', + redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard', + frontendRedirectUrl: 'Frontend Callback Path', + frontendRedirectUrlPlaceholder: '/auth/oidc/callback', + frontendRedirectUrlHint: 'Frontend route used after backend callback', + tokenAuthMethod: 'Token Auth Method', + clockSkewSeconds: 'Clock Skew (seconds)', + allowedSigningAlgs: 'Allowed Signing Algs', + allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256', + usePkce: 'Use PKCE', + validateIdToken: 'Validate ID Token', + requireEmailVerified: 'Require Email Verified', + userinfoEmailPath: 'UserInfo Email Path', + userinfoEmailPathPlaceholder: 'for example data.email', + userinfoIdPath: 'UserInfo ID Path', + userinfoIdPathPlaceholder: 'for example data.id', + userinfoUsernamePath: 'UserInfo Username Path', + userinfoUsernamePathPlaceholder: 'for example data.username' + }, + defaults: { + title: 'Default User Settings', + description: 'Default values for new users', + defaultBalance: 'Default Balance', + defaultBalanceHint: 'Initial balance for new users', + affiliateRebateRate: 'Affiliate Rebate Rate', + affiliateRebateRateHint: + 'Rebate percentage credited to inviter after recharge (0-100%, e.g. 10 means 10%)', + defaultConcurrency: 'Default Concurrency', + defaultConcurrencyHint: 'Maximum concurrent requests for new users', + defaultUserRpmLimit: 'Default User RPM Limit', + defaultUserRpmLimitHint: 'Default max requests per minute for new users; 0 = unlimited. Only applied at new user creation.', + defaultSubscriptions: 'Default Subscriptions', + defaultSubscriptionsHint: 'Auto-assign these subscriptions when a new user is created or registered', + addDefaultSubscription: 'Add Default Subscription', + defaultSubscriptionsEmpty: 'No default subscriptions configured.', + defaultSubscriptionsDuplicate: + 'Duplicate subscription group: {groupId}. Each group can only appear once.', + subscriptionGroup: 'Subscription Group', + subscriptionValidityDays: 'Validity (days)', + defaultPlatformQuotas: 'Default Platform Quotas (on signup)', + defaultPlatformQuotasHint: 'Automatically assigned to new users on signup; existing users are not affected. Leave blank = unlimited.', + platformQuotaNotice: 'Monthly quota uses a 30-day rolling window, not a calendar month.', + }, + platformQuota: { + platform: 'Platform', + daily: 'Daily (USD)', + weekly: 'Weekly (USD)', + monthly: 'Monthly (USD, 30d rolling)', + placeholder: 'Unlimited', + }, + claudeCode: { + title: 'Claude Code Settings', + description: 'Control Claude Code client access requirements', + minVersion: 'Minimum Version', + minVersionPlaceholder: 'e.g. 2.1.63', + minVersionHint: + 'Reject Claude Code clients below this version (semver format). Leave empty to disable version check.', + maxVersion: 'Maximum Version', + maxVersionPlaceholder: 'e.g. 2.5.0', + maxVersionHint: + 'Reject Claude Code clients above this version (semver format). Leave empty to allow any version.' + }, + scheduling: { + title: 'Gateway Scheduling Settings', + description: 'Control API Key scheduling behavior', + allowUngroupedKey: 'Allow Ungrouped Key Scheduling', + allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.' + }, + gatewayForwarding: { + title: 'Request Forwarding', + description: 'Control how requests are forwarded to upstream OAuth accounts', + fingerprintUnification: 'Fingerprint Unification', + fingerprintUnificationHint: 'Unify X-Stainless-* headers across users sharing the same OAuth account. Disabling passes through each client\'s original headers.', + metadataPassthrough: 'Metadata Passthrough', + metadataPassthroughHint: 'Pass through client\'s original metadata.user_id without rewriting. May improve upstream cache hit rates.', + cchSigning: 'CCH Signing', + cchSigningHint: 'Sign the billing header in forwarded requests with CCH hash. When disabled, the placeholder is preserved.', + claudeOAuthSystemPromptInjection: 'Claude OAuth System Blocks', + claudeOAuthSystemPromptInjectionHint: 'Inject Claude Code-like system blocks for Claude OAuth requests from non-Claude-Code clients. Enabled by default.', + claudeOAuthSystemPrompt: 'Claude OAuth Expansion Prompt', + claudeOAuthSystemPromptPlaceholder: 'Leave empty to use the built-in Claude Code expansion prompt.', + claudeOAuthSystemPromptHint: 'Legacy compatibility: controls only the third injected system block.', + claudeOAuthSystemPromptBlocks: 'Claude OAuth System Blocks', + claudeOAuthSystemPromptBlocksPlaceholder: 'Leave empty to use the built-in 3 blocks. Supports an array or {"blocks": [...]}.', + claudeOAuthSystemPromptBlocksHint: 'Each block is saved as JSON with enabled, type, text, and optional cache_control. {billing_header} stays dynamic per request; the Claude Code identity and expansion prompts can be edited directly or restored from presets.', + systemBlockTitle: 'System Block {index}', + systemBlockPreset: 'Preset', + systemBlockPresetBilling: 'Billing header', + systemBlockPresetIdentity: 'Claude Code identity', + systemBlockPresetExpansion: 'Claude Code expansion', + systemBlockPresetCustom: 'Custom', + systemBlockType: 'Type', + systemBlockTypeText: 'Text', + systemBlockText: 'Content', + systemBlockCacheControl: 'Cache control', + systemBlockHide: 'Hide block details', + systemBlockShow: 'Show block details', + addSystemBlock: 'Add block', + resetSystemBlocks: 'Reset defaults', + cacheTTL5m: '5 minutes', + cacheTTL1h: '1 hour', + anthropicCacheTTL1hInjection: 'Anthropic Cache TTL Injection', + anthropicCacheTTL1hInjectionHint: 'When enabled, existing ephemeral cache_control blocks in Anthropic OAuth/Setup Token request bodies are forced to 1h; response usage is billed back as 5m by default, with account-level TTL billing override taking priority.', + rewriteMessageCacheControl: 'Rewrite Message Cache Breakpoints', + rewriteMessageCacheControlHint: 'Default off: preserve client cache_control on message content blocks. When enabled, client breakpoints are stripped and proxy breakpoints are injected for clients that do not manage caching themselves.', + clientDatelineNormalization: 'Client Dateline Normalization', + clientDatelineNormalizationHint: 'Default on. Rewrites the "Today\'s date is …" sentence in Anthropic OAuth/Setup Token requests back to a canonical ASCII apostrophe and hyphen date format, erasing steganographic fingerprint bits some clients inject when they detect a non-official base URL. Applies to system prompts and blocks only; API-Key accounts are unaffected.', + antigravityUserAgentVersion: 'Antigravity UA Version', + antigravityUserAgentVersionPlaceholder: '1.23.2', + antigravityUserAgentVersionHint: 'Leave empty to use ANTIGRAVITY_USER_AGENT_VERSION or the built-in default 1.23.2; when set, the admin setting takes precedence.', + openaiCodexUserAgent: 'OpenAI Codex UA', + openaiCodexUserAgentPlaceholder: 'codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)', + openaiCodexUserAgentHint: 'Used to bypass Cloudflare browser-UA challenges on the OpenAI upstream. Only applies when the client User-Agent is detected as a browser (Mozilla/...). Leave empty to use the built-in default.', + codexHardeningTitle: "Codex Settings", + codexClientRestrictionTitle: "Codex client restriction", + codexHardeningDesc: + "Only affects OpenAI OAuth accounts with 'Codex official clients only' enabled (global). Beyond User-Agent/Originator, harden the decision with a version range, an engine-fingerprint gate, and black/whitelists.", + minCodexVersion: "Min Codex Version", + minCodexVersionPlaceholder: "e.g. 0.142.0", + maxCodexVersion: "Max Codex Version", + maxCodexVersionPlaceholder: "e.g. 0.200.0", + codexVersionHint: + "Official clients only: checks their version against the [min, max] range. Leave a side empty to not limit it.", + codexFingerprintSignals: "Codex engine fingerprint signals", + codexFingerprintSignalsDesc: + "Define engine-fingerprint signals: every Required signal must match (AND); within a row, '/'-separated variants are OR'd. None checked = not enforced. Default checks only the x-codex- prefix. Types: header exact / header prefix / body path.", + codexFpTypeHeaderExact: "Header exact", + codexFpTypeHeaderPrefix: "Header prefix", + codexFpTypeBodyPath: "Body path", + codexFpMatchPlaceholder: "match; '/'-separate variants (e.g. session-id / session_id or x-codex-)", + codexFpRequired: "Required", + codexFingerprintNoRequiredWarn: "No signal is marked Required — the engine-fingerprint gate is inactive, allowing every candidate that passes identity/version. Check at least one signal to enable it.", + codexAllowAppServer: "Codex app-server", + codexAllowAppServerDesc: + "Allow third-party clients that embed the Codex engine and connect over the app-server protocol (e.g. Claude Code's codex plugin). Off by default; when on, such clients are allowed once they pass the engine-fingerprint gate (the signal list below); off = only official clients and the whitelist are allowed.", + codexBlacklist: "User-Agent/Originator Blacklist", + codexBlacklistDesc: + "Deny if any field matches; takes precedence over any allow. originator is exact; User-Agent is a 'contains' match (comma-separated).", + codexWhitelist: "User-Agent/Originator Whitelist", + codexWhitelistDesc: + "Allow clients outside the official set: requires exact originator and every User-Agent marker present. Still subject to the fingerprint gate unless 'Skip engine fingerprint' is checked.", + codexWhitelistSkipFingerprint: "Skip engine fingerprint", + codexWhitelistSkipFingerprintTooltip: + "Risk: when checked this entry is allowed on originator + User-Agent alone (both forgeable), with no engine-fingerprint backstop. Use only for trusted third-party clients that genuinely do not send a codex engine fingerprint.", + codexOriginatorPlaceholder: "originator (exact, e.g. opencode)", + codexUaContainsPlaceholder: "User-Agent contains markers, comma-separated (e.g. opencode/)", + codexAddRow: "Add entry", + codexRemoveRow: "Remove", + }, + webSearchEmulation: { + title: 'Web Search Emulation', + description: 'Inject web search capability for Anthropic API Key accounts that don\'t natively support it', + enabled: 'Enable Web Search Emulation', + enabledHint: 'Global switch. When disabled, web search emulation is inactive for all channels and accounts.', + providers: 'Search Providers', + addProvider: 'Add Provider', + providerType: 'Provider Type', + apiKey: 'API Key', + apiKeyPlaceholder: 'Enter API Key', + apiKeyConfigured: 'Configured', + showApiKey: 'Show', + hideApiKey: 'Hide', + copyApiKey: 'Copy', + copied: 'Copied', + quotaLimit: 'Quota Limit', + quotaLimitHint: 'Leave empty for unlimited; must be > 0 if set', + quotaLimitMustBePositive: 'Quota limit must be greater than 0', + subscribedAt: 'Subscribed At', + subscribedAtHint: 'Quota resets monthly from this date; leave empty to disable auto-reset', + quotaUsage: 'Usage', + resetUsage: 'Reset', + resetUsageConfirm: 'Reset usage counter for this provider?', + resetUsageSuccess: 'Usage counter reset', + proxy: 'Proxy', + removeProvider: 'Remove', + noProviders: 'No search providers configured', + test: 'Test', + testDefaultQuery: 'Major world events this year', + testing: 'Searching...', + testResultTitle: 'Search Results', + testResultProvider: 'Provider', + testNoResults: 'No results found', + }, + site: { + title: 'Site Settings', + description: 'Customize site branding', + backendMode: 'Backend Mode', + backendModeDescription: + 'Disables user registration, public site, and self-service features. Only admin can log in and manage the platform.', + siteName: 'Site Name', + siteNamePlaceholder: 'Sub2API', + siteNameHint: 'Displayed in emails and page titles', + siteSubtitle: 'Site Subtitle', + siteSubtitlePlaceholder: 'Subscription to API Conversion Platform', + siteSubtitleHint: 'Displayed on login and register pages', + apiBaseUrl: 'API Base URL', + apiBaseUrlPlaceholder: 'https://api.example.com', + apiBaseUrlHint: + 'Used for "Use Key", "Import to CC Switch", and callback URL suggestions. Leave empty to use current site URL.', + tablePreferencesTitle: 'Global Table Preferences', + tablePreferencesDescription: 'Configure default pagination behavior for shared table components', + tableDefaultPageSize: 'Default Rows Per Page', + tableDefaultPageSizeHint: 'Must be an integer between 5 and 1000', + tablePageSizeOptions: 'Rows Per Page Options', + tablePageSizeOptionsPlaceholder: '10, 20, 50, 100', + tablePageSizeOptionsHint: 'Use commas to separate integers between 5 and 1000; values are deduplicated and sorted on save', + tableDefaultPageSizeRangeError: 'Default rows per page must be between {min} and {max}', + tablePageSizeOptionsFormatError: 'Invalid options format. Enter comma-separated integers between {min} and {max}', + customEndpoints: { + title: 'Custom Endpoints', + description: 'Add additional API endpoint URLs for users to quickly copy on the API Keys page', + itemLabel: 'Endpoint #{n}', + name: 'Name', + namePlaceholder: 'e.g., OpenAI Compatible', + endpointUrl: 'Endpoint URL', + endpointUrlPlaceholder: 'https://api2.example.com', + descriptionLabel: 'Description', + descriptionPlaceholder: 'e.g., Supports OpenAI format requests', + add: 'Add Endpoint', + }, + contactInfo: 'Contact Info', + contactInfoPlaceholder: 'e.g., QQ: 123456789', + contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.', + docUrl: 'Documentation URL', + docUrlPlaceholder: 'https://docs.example.com', + docUrlHint: 'Link to your documentation site. Leave empty to hide the documentation link.', + siteLogo: 'Site Logo', + uploadImage: 'Upload Image', + remove: 'Remove', + logoHint: 'PNG, JPG, or SVG. Max 300KB. Recommended: 80x80px square image.', + logoSizeError: 'Image size exceeds 300KB limit ({size}KB)', + logoTypeError: 'Please select an image file', + logoReadError: 'Failed to read the image file', + homeContent: 'Home Page Content', + homeContentPlaceholder: 'Enter custom content for the home page. Supports Markdown & HTML. If a URL is entered, it will be displayed as an iframe.', + homeContentHint: 'Customize the home page content. Supports Markdown/HTML. If you enter a URL (starting with http:// or https://), it will be used as an iframe src to embed an external page. When set, the default status information will no longer be displayed.', + homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.', + hideCcsImportButton: 'Hide CCS Import Button', + hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page' + }, + purchase: { + title: 'Recharge / Subscription Page', + description: 'Show a "Recharge / Subscription" entry in the sidebar and open the configured URL in an iframe', + enabled: 'Show Recharge / Subscription Entry', + enabledHint: 'Only shown in standard mode (not simple mode)', + url: 'Recharge / Subscription URL', + urlPlaceholder: 'https://example.com/purchase', + urlHint: 'Must be an absolute http(s) URL', + iframeWarning: + '⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.', + integrationDoc: 'Payment Integration Docs', + integrationDocHint: 'Covers endpoint specs, idempotency semantics, and code samples' + }, + soraClient: { + title: 'Sora Client', + description: 'Control whether to show the Sora client entry in the sidebar', + enabled: 'Enable Sora Client', + enabledHint: 'When enabled, the Sora entry will be shown in the sidebar for users to access Sora features' + }, + customMenu: { + title: 'Custom Menu Pages', + description: 'Add custom iframe pages to the sidebar navigation. Each page can be visible to regular users or administrators.', + itemLabel: 'Menu Item #{n}', + name: 'Menu Name', + namePlaceholder: 'e.g. Help Center', + url: 'Page URL', + urlPlaceholder: 'https://example.com/page', + iconSvg: 'SVG Icon', + iconSvgPlaceholder: '...', + iconPreview: 'Icon Preview', + uploadSvg: 'Upload SVG', + removeSvg: 'Remove', + visibility: 'Visible To', + visibilityUser: 'Regular Users', + visibilityAdmin: 'Administrators', + add: 'Add Menu Item', + remove: 'Remove', + moveUp: 'Move Up', + moveDown: 'Move Down', + }, + payment: { + title: 'Payment Settings', + description: 'Configure payment system options', + configGuide: 'Configuration Guide', + enabled: 'Enable Payment', + enabledHint: 'Enable or disable the payment system', + enabledPaymentTypes: 'Enabled Providers', + enabledPaymentTypesHint: 'Disabling a provider will also disable its instances.', + findProvider: 'Looking for a suitable EasyPay provider?', + minAmount: 'Minimum Amount', + maxAmount: 'Maximum Amount', + dailyLimit: 'Daily Limit', + balanceRechargeMultiplier: 'Balance Recharge Multiplier', + balanceRechargeMultiplierHint: 'How many USD balance the user receives for each 1 CNY paid', + balanceRechargePreview: 'Preview: 1 CNY = {usd} USD', + subscriptionUsdToCnyRate: 'Subscription USD to CNY Rate', + subscriptionUsdToCnyRateHint: + 'CNY charged per 1 USD of plan price on CNY channels (e.g. 7.15). 0 or empty = disabled, plan price is charged as-is. When enabled, all plan prices must be set in USD', + subscriptionUsdToCnyRateDisabled: 'Disabled (price charged as-is)', + rechargeFeeRate: 'Recharge Fee Rate', + rechargeFeeRateHint: 'Percentage of service fee charged on top of recharge amount, 0 means no fee', + rechargeFeePreview: 'Preview: Recharge 100, fee {fee}', + orderTimeout: 'Order Timeout', + orderTimeoutHint: 'In minutes, minimum 1', + maxPendingOrders: 'Max Pending Orders', + cancelRateLimit: 'Limit Cancel Rate', + cancelRateLimitHint: 'When enabled, users who exceed the cancel limit within the time window cannot create new orders', + cancelRateLimitEvery: 'Every', + cancelRateLimitAllowMax: 'allow max', + cancelRateLimitTimes: 'cancels', + cancelRateLimitWindow: 'Window', + cancelRateLimitUnit: 'Unit', + cancelRateLimitMax: 'Max Cancels', + cancelRateLimitUnitMinute: 'Minutes', + cancelRateLimitUnitHour: 'Hours', + cancelRateLimitUnitDay: 'Days', + cancelRateLimitWindowMode: 'Window Mode', + cancelRateLimitWindowModeRolling: 'Rolling', + cancelRateLimitWindowModeFixed: 'Fixed', + alipayForceQRCode: 'Force Alipay QR Code', + alipayForceQRCodeHint: 'When enabled, mobile Alipay users always see a QR code instead of being redirected to the mobile payment page', + helpText: 'Help Text', + helpImageUrl: 'Help Image URL', + manageProviders: 'Manage Providers', + balancePaymentDisabled: 'Disable Balance Recharge', + noLimit: 'Empty = no limit', + helpImage: 'Help Image', + helpImagePlaceholder: 'Upload or enter image URL', + helpTextPlaceholder: 'Enter help text...', + providerEasypay: 'EasyPay', + providerAlipay: 'Alipay (Direct)', + providerWxpay: 'WeChat Pay (Direct)', + providerStripe: 'Stripe', + providerAirwallex: 'Airwallex', + typeDisabled: 'type disabled', + enableTypesFirst: 'Enable at least one payment type above first', + easypayRedirect: 'Redirect', + paymentMode: 'Payment Mode', + modeRedirect: 'Redirect', + modeQRCode: 'QR Code', + modePopup: 'Popup', + validationNameRequired: 'Provider name is required', + validationTypesRequired: 'Please select at least one supported payment type', + validationFieldRequired: '{field} is required', + validationEasyPayCustomMethodRequired: 'Each custom EasyPay method requires both a payment type and an upstream type', + validationEasyPayCustomMethodTypeInvalid: 'Custom EasyPay payment types may only contain lowercase letters, digits, underscores, and hyphens', + validationEasyPayCustomMethodUpstreamTypeInvalid: 'EasyPay upstream types may only contain lowercase letters, digits, underscores, and hyphens', + validationEasyPayCustomMethodReserved: 'Custom EasyPay payment types cannot use built-in alipay or wxpay', + validationEasyPayCustomMethodPrefixReserved: 'Custom EasyPay payment types cannot start with alipay or wxpay', + validationEasyPayCustomMethodDuplicate: 'Custom EasyPay payment types must be unique', + field_apiBase: 'API Base URL', + field_notifyUrl: 'Notify URL', + field_returnUrl: 'Return URL', + callbackBaseUrl: 'Callback Base URL', + field_privateKey: 'Private Key', + field_publicKey: 'Public Key', + field_mpAppId: 'MP App ID', + field_mchId: 'Merchant ID', + field_apiV3Key: 'API v3 Key', + field_publicKeyId: 'Public Key ID', + field_certSerial: 'Certificate Serial', + field_h5AppName: 'H5 App Name', + field_h5AppUrl: 'H5 App URL', + wxpayConfigHint: 'WeChat Pay usually only needs App ID. Fill MP App ID, H5 App Name, and H5 App URL only when your Official Account or H5 flow specifically requires them.', + wxpayAdvancedOptions: 'WeChat Pay Advanced Options', + field_secretKey: 'Secret Key', + field_clientId: 'Client ID', + field_apiKey: 'API Key', + field_publishableKey: 'Publishable Key', + field_webhookSecret: 'Webhook Secret', + field_countryCode: 'Country/region code', + field_currency: 'Payment currency', + field_accountId: 'Airwallex Account ID', + field_airwallexApiBaseHint: 'Must match the API key environment: use https://api-demo.airwallex.com/api/v1 for sandbox/demo keys, and https://api.airwallex.com/api/v1 for production keys. Mixed environments return credentials_invalid / Access Denied.', + field_paymentCurrencyHint: 'Default is CNY. Stripe and Airwallex can choose HKD, USD, or another listed currency supported by the account; WeChat Pay, Alipay, and EasyPay remain CNY.', + field_accountIdHint: 'Leave this empty unless you use multiple accounts, an organization-level key, or connected-account payments. A single-account scoped API key uses the selected account by default.', + field_cid: 'Channel ID', + field_cidAlipay: 'Alipay Channel ID', + field_cidWxpay: 'WeChat Channel ID', + easypayCustomMethods: 'Custom EasyPay methods', + easypayCustomMethodsHint: 'Add provider-specific methods supported by this EasyPay endpoint. The payment type is stored on Sub2API orders; the upstream type is sent as EasyPay type.', + addCustomMethod: 'Add method', + customMethodType: 'Payment type', + customMethodUpstreamType: 'Upstream type', + customMethodDisplayName: 'Display name', + stripeWebhookHint: 'Configure the following URL as a Webhook endpoint in Stripe Dashboard:', + stripeWebhookApiVersionHint: 'Set this Webhook endpoint API version to match the integrated Stripe SDK. Recommended: {version}. A mismatch can cause webhook parsing errors.', + airwallexWebhookHint: 'Configure the following URL as a Webhook endpoint in Airwallex. Select at least Payment Intent -> Succeeded (payment_intent.succeeded), preferably also Payment Intent -> Cancelled (payment_intent.cancelled). Use the account default or latest stable API version.', + airwallexGuideSummary: 'When creating an Airwallex scoped API key, select Read and Write for Payment Acceptance under account-level permissions.', + airwallexGuideNote: 'Do not grant unrelated permissions such as Spend, Payouts, Transfers, Funds Splits, or POS Terminals unless you explicitly need them. For webhooks, select at least payment_intent.succeeded, preferably also payment_intent.cancelled, and use the account default or latest stable API version.', + limitsTitle: 'Limits', + limitSingleMin: 'Min per order', + limitSingleMax: 'Max per order', + limitDaily: 'Daily limit', + limitsHint: 'All empty = use global config; partially filled = empty means no limit', + limitsUseGlobal: 'Use global', + limitsNoLimit: 'No limit', + productNamePrefix: 'Product Name Prefix', + productNameSuffix: 'Product Name Suffix', + preview: 'Preview', + loadBalanceStrategy: 'Load Balance Strategy', + strategyRoundRobin: 'Round Robin', + strategyLeastAmount: 'Least Daily Amount', + providerManagement: 'Provider Management', + providerManagementDesc: 'Manage payment provider instances', + createProvider: 'Add Provider', + editProvider: 'Edit Provider', + deleteProvider: 'Delete Provider', + deleteProviderConfirm: 'Are you sure you want to delete this provider?', + providerName: 'Provider Name', + providerKey: 'Provider Type', + selectProviderKey: 'Select Provider Type', + providerConfig: 'Credentials', + paymentGuideTrigger: 'View payment guide', + guideOpenLabel: 'Enable: ', + guideCallLabel: 'Call: ', + guideFallbackLabel: 'Fallback: ', + alipayGuideSummary: 'Desktop prefers QR precreate and falls back to cashier; mobile prefers WAP checkout.', + alipayGuideFaceToFaceTitle: 'Face-to-face / QR Payment', + alipayGuideFaceToFaceOpen: 'Enable face-to-face or QR payment capability.', + alipayGuideFaceToFaceCall: 'Desktop orders call alipay.trade.precreate first and render the QR code directly.', + alipayGuideFaceToFaceFallback: 'If unavailable or failed, the flow falls back to website checkout automatically.', + alipayGuidePagePayTitle: 'Website Payment', + alipayGuidePagePayOpen: 'Enable website payment.', + alipayGuidePagePayCall: 'When face-to-face is unavailable on desktop, the flow calls alipay.trade.page.pay and still renders the returned link as a QR code.', + alipayGuidePagePayFallback: 'The cashier link stays available so users can reopen the checkout page manually.', + alipayGuideWapTitle: 'WAP Payment', + alipayGuideWapOpen: 'Enable mobile website payment.', + alipayGuideWapCall: 'Mobile orders call alipay.trade.wap.pay first and jump to Alipay checkout.', + alipayGuideWapFallback: 'If mobile payment is unavailable or fails, the frontend switches to QR payment and shows a notice.', + wxpayGuideSummary: 'Desktop prefers Native QR; mobile routes to JSAPI or H5 based on browser context.', + wxpayGuideNote: 'The current form defaults to one shared App ID, which fits the common single-subject web, mobile, and Official Account setup.', + wxpayGuideNativeTitle: 'Native / QR Payment', + wxpayGuideNativeOpen: 'Enable Native or QR payment capability.', + wxpayGuideNativeCall: 'Desktop orders use Native by default and the frontend renders the QR payload.', + wxpayGuideNativeFallback: 'Mobile flows also fall back here when JSAPI or H5 cannot be used.', + wxpayGuideJsapiTitle: 'JSAPI / Official Account', + wxpayGuideJsapiOpen: 'Enable Official Account payment and ensure the browser is inside WeChat with an available OpenID.', + wxpayGuideJsapiCall: 'Inside WeChat, the app calls JSAPI after authorization and launches WeChat Pay directly.', + wxpayGuideJsapiFallback: 'If configuration is missing, the bridge is unavailable, or launch fails, the flow falls back to QR payment.', + wxpayGuideH5Title: 'H5 Payment', + wxpayGuideH5Open: 'Enable H5 payment.', + wxpayGuideH5Call: 'On mobile browsers outside WeChat, the app calls H5 payment when a client IP is available.', + wxpayGuideH5Fallback: 'If H5 is unavailable or order creation fails, the flow falls back to QR payment.', + noProviders: 'No provider instances configured', + supportedTypes: 'Supported Payment Types', + supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay', + refundEnabled: 'Allow Refund', + allowUserRefund: 'Allow User Refund', + enableConflict: '{method} already has an enabled provider instance: {provider}. Disable the existing instance before switching.', + }, + balanceNotify: { + title: 'Balance Low Notification', + description: 'Send email notification when user balance falls below threshold', + enabled: 'Enable Balance Low Notification', + threshold: 'Default Threshold', + thresholdHint: 'Used when user has not set a custom value', + thresholdPlaceholder: 'Enter amount', + rechargeUrl: 'Recharge Page URL', + rechargeUrlPlaceholder: 'https://example.com/payment', + rechargeUrlHint: 'A top-up button will appear in the email when set', + }, + quotaNotify: { + title: 'Account Quota Notification', + description: 'Notify admins when account quota usage reaches alert threshold', + enabled: 'Enable Account Quota Notification', + emails: 'Notification Emails', + emailsHint: 'Leave empty to disable notifications', + addEmail: 'Add Email', + emailPlaceholder: 'Enter email address', + }, + subscriptionExpiryNotify: { + title: 'Subscription Expiry Reminder', + description: 'Control whether users receive subscription expiry reminder emails.', + enabled: 'Enable Subscription Expiry Reminder', + enabledHint: 'When enabled, the system sends reminders 7, 3, and 1 day before expiry.' + }, + smtp: { + title: 'SMTP Settings', + description: 'Configure email sending for verification codes', + testConnection: 'Test Connection', + testing: 'Testing...', + host: 'SMTP Host', + hostPlaceholder: 'smtp.gmail.com', + port: 'SMTP Port', + portPlaceholder: '587', + username: 'SMTP Username', + usernamePlaceholder: "your-email{'@'}gmail.com", + password: 'SMTP Password', + passwordPlaceholder: '********', + passwordHint: 'Leave empty to keep existing password', + passwordConfiguredPlaceholder: '********', + passwordConfiguredHint: 'Password configured. Leave empty to keep the current value.', + fromEmail: 'From Email', + fromEmailPlaceholder: "noreply{'@'}example.com", + fromName: 'From Name', + fromNamePlaceholder: 'Sub2API', + useTls: 'Use TLS', + useTlsHint: 'Enable TLS encryption for SMTP connection' + }, + testEmail: { + title: 'Send Test Email', + description: 'Send a test email to verify your SMTP configuration', + recipientEmail: 'Recipient Email', + recipientEmailPlaceholder: "test{'@'}example.com", + sendTestEmail: 'Send Test Email', + sending: 'Sending...', + enterRecipientHint: 'Please enter a recipient email address' + }, + emailTemplates: { + title: 'Email Templates', + description: 'Customize notification email subjects and HTML content for each event and locale.', + event: 'Event', + locale: 'Locale', + localeEn: 'English', + localeZh: 'Chinese', + subject: 'Subject', + subjectPlaceholder: 'Enter the email subject', + html: 'HTML Template', + htmlPlaceholder: 'Edit the email HTML template', + placeholders: 'Available Placeholders', + placeholdersHelp: 'Click a placeholder to copy it. The backend replaces these values when sending emails.', + livePreview: 'Live Preview', + previewSecurityHint: 'Preview HTML is generated by the backend preview endpoint and displayed in a sandboxed iframe with scripts disabled.', + preview: 'Preview / Refresh', + previewing: 'Previewing...', + save: 'Save Template', + saving: 'Saving...', + restoreOfficial: 'Restore Official', + restoring: 'Restoring...', + restoreConfirm: 'Restore the official template for this event and locale? Your custom version will be replaced.', + restoreSuccess: 'Official template restored', + saveSuccess: 'Email template saved', + placeholderCopied: 'Placeholder copied', + validationRequired: 'Subject and HTML template are required', + empty: 'No email template events or locales are available yet.', + noPreview: 'Refresh the preview to see the rendered email subject.', + customized: 'Customized' + }, + opsMonitoring: { + title: 'Ops Monitoring', + description: 'Enable ops monitoring for troubleshooting and health visibility', + disabled: 'Ops monitoring is disabled', + enabled: 'Enable Ops Monitoring', + enabledHint: 'Enable the ops monitoring module (admin only)', + realtimeEnabled: 'Enable Realtime Monitoring', + realtimeEnabledHint: 'Enable realtime QPS/metrics push (WebSocket)', + queryMode: 'Default Query Mode', + queryModeHint: 'Default query mode for Ops Dashboard (auto/raw/preagg)', + queryModeAuto: 'Auto (recommended)', + queryModeRaw: 'Raw (most accurate, slower)', + queryModePreagg: 'Preagg (fastest, requires aggregation)', + metricsInterval: 'Metrics Collection Interval (seconds)', + metricsIntervalHint: 'How often to collect system/request metrics (60-3600 seconds)' + }, + adminApiKey: { + title: 'Admin API Key', + description: 'Global API key for external system integration with full admin access', + notConfigured: 'Admin API key not configured', + configured: 'Admin API key is active', + currentKey: 'Current Key', + regenerate: 'Regenerate', + regenerating: 'Regenerating...', + delete: 'Delete', + deleting: 'Deleting...', + create: 'Create Key', + creating: 'Creating...', + regenerateConfirm: 'Are you sure? The current key will be immediately invalidated.', + deleteConfirm: + 'Are you sure you want to delete the admin API key? External integrations will stop working.', + keyGenerated: 'New admin API key generated', + keyDeleted: 'Admin API key deleted', + copyKey: 'Copy Key', + keyCopied: 'Key copied to clipboard', + keyWarning: 'This key will only be shown once. Please copy it now.', + securityWarning: 'Warning: This key provides full admin access. Keep it secure.', + usage: 'Usage: Add to request header - x-api-key: ' + }, + soraS3: { + title: 'Sora Storage', + description: 'Manage Sora media storage profiles with S3 and Google Drive support', + newProfile: 'New Profile', + reloadProfiles: 'Reload Profiles', + empty: 'No storage profiles yet, create one first', + createTitle: 'Create Storage Profile', + editTitle: 'Edit Storage Profile', + selectProvider: 'Select Storage Type', + providerS3Desc: 'S3-compatible object storage', + providerGDriveDesc: 'Google Drive cloud storage', + profileID: 'Profile ID', + profileName: 'Profile Name', + setActive: 'Set as active after creation', + saveProfile: 'Save Profile', + activateProfile: 'Activate', + profileCreated: 'Storage profile created', + profileSaved: 'Storage profile saved', + profileDeleted: 'Storage profile deleted', + profileActivated: 'Active storage profile switched', + profileIDRequired: 'Profile ID is required', + profileNameRequired: 'Profile name is required', + profileSelectRequired: 'Please select a profile first', + endpointRequired: 'S3 endpoint is required when enabled', + bucketRequired: 'Bucket is required when enabled', + accessKeyRequired: 'Access Key ID is required when enabled', + deleteConfirm: 'Delete storage profile {profileID}?', + columns: { + profile: 'Profile', + profileId: 'Profile ID', + name: 'Name', + provider: 'Type', + active: 'Active', + endpoint: 'Endpoint', + bucket: 'Bucket', + storagePath: 'Storage Path', + capacityUsage: 'Capacity / Used', + capacityUnlimited: 'Unlimited', + videoCount: 'Videos', + videoCompleted: 'completed', + videoInProgress: 'in progress', + quota: 'Default Quota', + updatedAt: 'Updated At', + actions: 'Actions', + rootFolder: 'Root folder', + testInTable: 'Test', + testingInTable: 'Testing...', + testTimeout: 'Test timed out (15s)' + }, + enabled: 'Enable Storage', + enabledHint: 'When enabled, Sora generated media files will be automatically uploaded', + endpoint: 'S3 Endpoint', + region: 'Region', + bucket: 'Bucket', + prefix: 'Object Prefix', + accessKeyId: 'Access Key ID', + secretAccessKey: 'Secret Access Key', + secretConfigured: '(Configured, leave blank to keep)', + cdnUrl: 'CDN URL', + cdnUrlHint: 'Optional. When configured, files are accessed via CDN URL', + forcePathStyle: 'Force Path Style', + defaultQuota: 'Default Storage Quota', + defaultQuotaHint: 'Default quota when not specified at user or group level. 0 means unlimited', + testConnection: 'Test Connection', + testing: 'Testing...', + testSuccess: 'Connection test successful', + testFailed: 'Connection test failed', + saved: 'Storage settings saved successfully', + saveFailed: 'Failed to save storage settings', + gdrive: { + authType: 'Authentication Method', + serviceAccount: 'Service Account', + clientId: 'Client ID', + clientSecret: 'Client Secret', + clientSecretConfigured: '(Configured, leave blank to keep)', + refreshToken: 'Refresh Token', + refreshTokenConfigured: '(Configured, leave blank to keep)', + serviceAccountJson: 'Service Account JSON', + serviceAccountConfigured: '(Configured, leave blank to keep)', + folderId: 'Folder ID (optional)', + authorize: 'Authorize Google Drive', + authorizeHint: 'Get Refresh Token via OAuth2', + oauthFieldsRequired: 'Please fill in Client ID and Client Secret first', + oauthSuccess: 'Google Drive authorization successful', + oauthFailed: 'Google Drive authorization failed', + closeWindow: 'This window will close automatically', + processing: 'Processing authorization...', + testStorage: 'Test Storage', + testSuccess: 'Google Drive storage test passed (upload, access, delete all OK)', + testFailed: 'Google Drive storage test failed' + } + }, + overloadCooldown: { + title: '529 Overload Cooldown', + description: 'Configure account scheduling pause strategy when upstream returns 529 (overloaded)', + enabled: 'Enable Overload Cooldown', + enabledHint: 'Pause account scheduling on 529 errors, auto-recover after cooldown', + cooldownMinutes: 'Cooldown Duration (minutes)', + cooldownMinutesHint: 'Duration to pause account scheduling (1-120 minutes)', + saved: 'Overload cooldown settings saved', + saveFailed: 'Failed to save overload cooldown settings' + }, + rateLimit429Cooldown: { + title: '429 Default Cooldown', + description: 'Configure the default account cooldown when upstream returns 429 without an explicit reset time', + enabled: 'Enable 429 Default Cooldown', + enabledHint: 'Pause account scheduling when a 429 has no reset time, then auto-recover after cooldown', + cooldownSeconds: 'Cooldown Duration (seconds)', + cooldownSecondsHint: 'Default cooldown duration (1-7200 seconds); explicit upstream reset times still take precedence', + saved: '429 default cooldown settings saved', + saveFailed: 'Failed to save 429 default cooldown settings' + }, + streamTimeout: { + title: 'Stream Timeout Handling', + description: 'Configure account handling strategy when upstream response times out', + enabled: 'Enable Stream Timeout Handling', + enabledHint: 'Automatically handle problematic accounts when upstream times out', + timeoutSeconds: 'Timeout Threshold (seconds)', + timeoutSecondsHint: 'Stream data interval exceeding this time is considered timeout (30-300s)', + action: 'Action', + actionTempUnsched: 'Temporarily Unschedulable', + actionError: 'Mark as Error', + actionNone: 'No Action', + actionHint: 'Action to take on the account after timeout', + tempUnschedMinutes: 'Pause Duration (minutes)', + tempUnschedMinutesHint: 'Duration of temporary unschedulable state (1-60 minutes)', + thresholdCount: 'Trigger Threshold (count)', + thresholdCountHint: 'Number of timeouts before triggering action (1-10)', + thresholdWindowMinutes: 'Threshold Window (minutes)', + thresholdWindowMinutesHint: 'Time window for counting timeouts (1-60 minutes)', + saved: 'Stream timeout settings saved', + saveFailed: 'Failed to save stream timeout settings' + }, + rectifier: { + title: 'Request Rectifier', + description: 'Automatically fix request parameters and retry when upstream returns specific errors', + enabled: 'Enable Request Rectifier', + enabledHint: 'Master switch - disabling turns off all rectification features', + thinkingSignature: 'Thinking Signature Rectifier', + thinkingSignatureHint: 'Automatically strip signatures and retry when upstream returns thinking block signature validation errors', + thinkingBudget: 'Thinking Budget Rectifier', + thinkingBudgetHint: 'Automatically set budget to 32000 and retry when upstream returns budget_tokens constraint error (≥1024)', + apikeySignature: 'API Key Signature Rectifier', + apikeySignatureHint: + 'Automatically strip signatures and retry when API Key accounts receive signature-related errors (built-in patterns always apply)', + apikeyPatterns: 'Custom Match Patterns', + apikeyPatternsHint: + 'Additional keywords matched against the response body (case-insensitive). Built-in patterns always apply; use these for supplementary matching.', + apikeyPatternPlaceholder: 'e.g., thinking_error', + addPattern: 'Add Pattern', + saved: 'Rectifier settings saved', + saveFailed: 'Failed to save rectifier settings' + }, + betaPolicy: { + title: 'Beta Policy', + description: 'How to handle Beta features when configuring the forwarding of Anthropic API requests. Applicable only to the /v1/messages endpoint.', + action: 'Action', + actionPass: 'Pass (transparent)', + actionFilter: 'Filter (remove)', + actionBlock: 'Block (reject)', + scope: 'Scope', + scopeAll: 'All accounts', + scopeOAuth: 'OAuth only', + scopeAPIKey: 'API Key only', + scopeBedrock: 'Bedrock only', + errorMessage: 'Error message', + errorMessagePlaceholder: 'Custom error message when blocked', + errorMessageHint: 'Leave empty for default message', + saved: 'Beta policy settings saved', + saveFailed: 'Failed to save beta policy settings', + modelWhitelist: 'Model Whitelist', + modelWhitelistHint: 'Leave empty to apply to all models. Supports exact match and wildcard prefix (e.g., claude-opus-*)', + modelPatternPlaceholder: 'e.g., claude-opus-* or claude-opus-4-6', + addModelPattern: 'Add model pattern', + removePattern: 'Remove', + fallbackAction: 'Fallback Action', + fallbackActionHint: 'Action for models not matching the whitelist', + fallbackErrorMessagePlaceholder: 'Custom error message when non-whitelisted models are blocked', + quickPresets: 'Quick Presets', + presetOpusOnly: 'Opus only for 1M', + presetOpusOnlyDesc: 'Pass for Opus, filter others', + commonPatterns: 'Common patterns' + }, + openaiFastPolicy: { + title: 'OpenAI Fast/Flex Policy', + description: 'Intercept, filter, or pass OpenAI fast(priority) / flex requests based on the request body service_tier field. Applies to the OpenAI gateway only.', + empty: 'No rules configured. Click the button below to add one.', + ruleHeader: 'Rule #{index}', + removeRule: 'Remove rule', + addRule: 'Add rule', + saveHint: 'Saved together with system settings (click the global Save button at the bottom of the page).', + serviceTier: 'service_tier match', + tierAll: 'All tiers', + tierPriority: 'priority (fast)', + tierFlex: 'flex', + action: 'Action', + actionPass: 'Pass (keep service_tier)', + actionFilter: 'Filter (remove service_tier)', + actionForcePriority: 'Force priority (fast)', + actionBlock: 'Block (reject request)', + scope: 'Scope', + scopeAll: 'All accounts', + scopeOAuth: 'OAuth only', + scopeAPIKey: 'API Key only', + scopeBedrock: 'Bedrock only', + errorMessage: 'Error message', + errorMessagePlaceholder: 'Custom error message when blocked', + errorMessageHint: 'Leave empty for the default message.', + modelWhitelist: 'Model whitelist', + modelWhitelistHint: 'Leave empty to apply to all models. Supports exact match and wildcard prefix (e.g., gpt-5.5*).', + modelPatternPlaceholder: 'e.g., gpt-5.5 or gpt-5.5*', + addModelPattern: 'Add model pattern', + fallbackAction: 'Fallback action', + fallbackActionHint: 'Action for models not matching the whitelist.', + fallbackErrorMessagePlaceholder: 'Custom error message when non-whitelisted models are blocked' + }, + wechatConnect: { + title: 'WeChat Connect', + description: 'Third-party login configuration for WeChat Open Platform or Official Account / Mini Program.', + enabledLabel: 'Enable WeChat Connect', + enabledHint: 'Enable this to configure WeChat OAuth callbacks and authorization.', + appIdLabel: 'App ID', + appIdPlaceholder: 'WeChat App ID', + appSecretLabel: 'App Secret', + appSecretConfiguredPlaceholder: 'Secret configured. Leave empty to keep the current value.', + appSecretPlaceholder: 'WeChat App Secret', + appSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', + appSecretHint: 'Enter a new secret to replace the current WeChat credential.', + modeLabel: 'Mode', + openModeLabel: 'Use Open outside WeChat', + openModeHint: 'Use Open Platform QR authorization outside the WeChat browser.', + mpModeLabel: 'Use MP inside WeChat', + mpModeHint: 'Use Official Account authorization inside the WeChat browser.', + redirectUrlLabel: 'Redirect URL', + redirectUrlPlaceholder: 'https://your-site.com/api/v1/auth/oauth/wechat/callback', + generateAndCopy: 'Generate & Copy (current site)', + redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard', + frontendRedirectUrlLabel: 'Frontend redirect URL', + frontendRedirectUrlPlaceholder: '/auth/wechat/callback', + frontendRedirectUrlHint: 'Usually the frontend route callback path; keep it aligned with the backend.' + }, + authSourceDefaults: { + title: 'Auth Source Defaults', + description: 'Configure per-source default balance, concurrency, subscriptions, and grant rules.', + requireEmailLabel: 'Require email on third-party signup', + requireEmailHint: 'When enabled, Linux DO, OIDC, and WeChat signups must provide an email before account creation.', + enabledHint: 'These defaults apply when a new user registers through this source. Grant on first bind only applies when an existing user binds this source.', + sources: { + email: { + title: 'Email signup', + description: 'Default quota grants for email-password signups.' + }, + linuxdo: { + title: 'Linux DO signup', + description: 'Default quota grants for Linux DO signups.' + }, + oidc: { + title: 'OIDC signup', + description: 'Default quota grants for OIDC signups.' + }, + wechat: { + title: 'WeChat signup', + description: 'Default quota grants for WeChat signups.' + } + }, + grantOnFirstBindLabel: 'Grant on first bind', + grantOnFirstBindHint: 'Grant default entitlements when an existing user first binds this source.', + defaultSubscriptionsLabel: 'Default subscriptions', + defaultSubscriptionsHint: 'Applies only to this auth source. Leave empty to skip source-specific subscriptions.', + noSourceSubscriptions: 'No source-specific default subscriptions configured.', + platformQuotasOverride: 'Platform Quota Overrides', + platformQuotasOverrideHint: 'Blank fields inherit the system default. Set to 0 to fully block that window for this auth source.', + }, + paymentVisibleMethods: { + methodLabel: '{title} visible method', + methodHint: 'Controls whether checkout shows this method and which source key it exposes.', + sourceLabel: 'Payment source', + sourceHint: 'Choose an explicit source before enabling the method. Not configured methods are not exposed.', + sourceRequiredError: 'Select a payment source before enabling {title}.' + }, + openaiExperimentalScheduler: { + title: 'OpenAI experimental scheduler policy', + description: "Disabled by default. When enabled, this only changes the gateway's experimental account-selection policy for OpenAI traffic; it does not indicate an upstream OpenAI capability.", + stickyWeightedTitle: 'Sticky weighting', + stickyWeightedDescription: 'When enabled, previous_response_id and session_hash affinity are scored by the advanced scheduler. When disabled, sticky accounts keep the legacy hard-hit behavior.', + subscriptionPriorityTitle: 'Subscription priority', + subscriptionPriorityDescription: 'When enabled, the scheduler scores ChatGPT subscription accounts first and falls back to non-subscription accounts only if no subscription slot can be acquired.', + weightsTitle: 'Scheduler weight overrides', + weightsDescription: 'Blank values use config/environment values; when config is not set, built-in defaults apply. Non-blank page settings take priority.', + defaultPlaceholder: 'config/default: {value}', + topKLabel: 'TopK', + priorityWeight: 'Priority', + loadWeight: 'Load', + queueWeight: 'Queue', + errorRateWeight: 'Error rate', + ttftWeight: 'TTFT', + resetWeight: 'Reset window', + quotaHeadroomWeight: 'Quota headroom', + previousResponseWeight: 'previous_response sticky', + sessionStickyWeight: 'session_hash sticky' + }, + usageRecords: { + title: 'Usage Records', + description: 'Settings for usage and failed-request records visible to end users.', + }, + user_error_view: { + label: 'Allow users to view their own error requests', + description: 'When enabled, users can see a redacted view of their failed requests on the usage page (no internal/upstream details). Requires ops monitoring enabled to have data.', + }, + saveSettings: 'Save Settings', + saving: 'Saving...', + settingsSaved: 'Settings saved successfully', + smtpConnectionSuccess: 'SMTP connection successful', + testEmailSent: 'Test email sent successfully', + failedToLoad: 'Failed to load settings', + failedToSave: 'Failed to save settings', + failedToTestSmtp: 'SMTP connection test failed', + failedToSendTestEmail: 'Failed to send test email' + }, + + // Error Passthrough Rules + errorPassthrough: { + title: 'Error Passthrough Rules', + description: 'Configure how upstream errors are returned to clients', + createRule: 'Create Rule', + editRule: 'Edit Rule', + deleteRule: 'Delete Rule', + noRules: 'No rules configured', + createFirstRule: 'Create your first error passthrough rule', + allPlatforms: 'All Platforms', + passthrough: 'Passthrough', + custom: 'Custom', + code: 'Code', + body: 'Body', + skipMonitoring: 'Skip Monitoring', + + // Columns + columns: { + priority: 'Priority', + name: 'Name', + conditions: 'Conditions', + platforms: 'Platforms', + behavior: 'Behavior', + status: 'Status', + actions: 'Actions' + }, + + // Match Mode + matchMode: { + any: 'Code OR Keyword', + all: 'Code AND Keyword', + anyHint: 'Status code matches any error code, OR message contains any keyword', + allHint: 'Status code matches any error code, AND message contains any keyword' + }, + + // Form + form: { + name: 'Rule Name', + namePlaceholder: 'e.g., Context Limit Passthrough', + priority: 'Priority', + priorityHint: 'Lower values have higher priority', + description: 'Description', + descriptionPlaceholder: 'Describe the purpose of this rule...', + matchConditions: 'Match Conditions', + errorCodes: 'Error Codes', + errorCodesPlaceholder: '422, 400, 429', + errorCodesHint: 'Separate multiple codes with commas', + keywords: 'Keywords', + keywordsPlaceholder: 'One keyword per line\ncontext limit\nmodel not supported', + keywordsHint: 'One keyword per line, case-insensitive', + matchMode: 'Match Mode', + platforms: 'Platforms', + platformsHint: 'Leave empty to apply to all platforms', + responseBehavior: 'Response Behavior', + passthroughCode: 'Passthrough upstream status code', + responseCode: 'Custom status code', + passthroughBody: 'Passthrough upstream error message', + customMessage: 'Custom error message', + customMessagePlaceholder: 'Error message to return to client...', + skipMonitoring: 'Skip monitoring', + skipMonitoringHint: 'When enabled, errors matching this rule will not be recorded in ops monitoring', + enabled: 'Enable this rule' + }, + + // Messages + nameRequired: 'Please enter rule name', + conditionsRequired: 'Please configure at least one error code or keyword', + ruleCreated: 'Rule created successfully', + ruleUpdated: 'Rule updated successfully', + ruleDeleted: 'Rule deleted successfully', + deleteConfirm: 'Are you sure you want to delete rule "{name}"?', + failedToLoad: 'Failed to load rules', + failedToSave: 'Failed to save rule', + failedToDelete: 'Failed to delete rule', + failedToToggle: 'Failed to toggle status' + }, + + // TLS Fingerprint Profiles + tlsFingerprintProfiles: { + title: 'TLS Fingerprint Profiles', + description: 'Manage TLS fingerprint profiles for simulating specific client TLS handshake characteristics', + createProfile: 'Create Profile', + editProfile: 'Edit Profile', + deleteProfile: 'Delete Profile', + noProfiles: 'No profiles configured', + createFirstProfile: 'Create your first TLS fingerprint profile', + + columns: { + name: 'Name', + description: 'Description', + grease: 'GREASE', + alpn: 'ALPN', + actions: 'Actions' + }, + + form: { + pasteYaml: 'Paste YAML Configuration', + pasteYamlPlaceholder: 'Paste YAML output from TLS Fingerprint Collector here...', + pasteYamlHint: 'Paste the YAML copied from TLS Fingerprint Collector to auto-fill all fields.', + openCollector: 'Open Collector', + parseYaml: 'Parse YAML', + yamlParsed: 'YAML parsed successfully, fields auto-filled', + yamlParseFailed: 'Failed to parse YAML: name field not found', + name: 'Profile Name', + namePlaceholder: 'e.g. macOS Node.js v24', + description: 'Description', + descriptionPlaceholder: 'Optional description for this profile', + enableGrease: 'Enable GREASE', + enableGreaseHint: 'Insert GREASE values in TLS ClientHello extensions', + cipherSuites: 'Cipher Suites', + cipherSuitesHint: 'Comma-separated hex values, e.g. 0x1301, 0x1302, 0xc02c', + curves: 'Elliptic Curves', + curvesHint: 'Comma-separated curve IDs', + pointFormats: 'Point Formats', + signatureAlgorithms: 'Signature Algorithms', + alpnProtocols: 'ALPN Protocols', + alpnProtocolsHint: 'Comma-separated, e.g. h2, http/1.1', + supportedVersions: 'Supported TLS Versions', + keyShareGroups: 'Key Share Groups', + pskModes: 'PSK Modes', + extensions: 'Extensions' + }, + + deleteConfirm: 'Delete Profile', + deleteConfirmMessage: 'Are you sure you want to delete profile "{name}"? Accounts using this profile will fall back to the built-in default.', + createSuccess: 'Profile created successfully', + updateSuccess: 'Profile updated successfully', + deleteSuccess: 'Profile deleted successfully', + loadFailed: 'Failed to load profiles', + saveFailed: 'Failed to save profile', + deleteFailed: 'Failed to delete profile' + } +} diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts new file mode 100644 index 0000000000..ac0b8dfcae --- /dev/null +++ b/frontend/src/i18n/locales/en/common.ts @@ -0,0 +1,417 @@ +export default { + common: { + loading: 'Loading...', + submitting: 'Submitting...', + justNow: 'just now', + peakRateTooltip: 'Peak rate: {window}', + peakRateImageNote: '; image tokens billed as tokens are also affected, per-image billing is unaffected', + save: 'Save', + saved: 'Saved successfully', + deleted: 'Deleted successfully', + cancel: 'Cancel', + delete: 'Delete', + edit: 'Edit', + create: 'Create', + update: 'Update', + confirm: 'Confirm', + reset: 'Reset', + search: 'Search', + filter: 'Filter', + export: 'Export', + import: 'Import', + actions: 'Actions', + status: 'Status', + name: 'Name', + email: 'Email', + password: 'Password', + submit: 'Submit', + back: 'Back', + next: 'Next', + yes: 'Yes', + no: 'No', + all: 'All', + none: 'None', + selectAll: 'Select all', + noData: 'No data', + expand: 'Expand', + collapse: 'Collapse', + success: 'Success', + error: 'Error', + critical: 'Critical', + warning: 'Warning', + info: 'Info', + active: 'Active', + inactive: 'Inactive', + more: 'More', + close: 'Close', + enabled: 'Enabled', + disabled: 'Disabled', + total: 'Total', + balance: 'Balance', + availableBalance: 'Available balance', + frozenBalance: 'Frozen balance', + totalBalance: 'Total balance', + available: 'Available', + copiedToClipboard: 'Copied to clipboard', + copied: 'Copied', + copyFailed: 'Failed to copy', + verifying: 'Verifying...', + processing: 'Processing...', + contactSupport: 'Contact Support', + add: 'Add', + invalidEmail: 'Please enter a valid email address', + optional: 'optional', + selectOption: 'Select an option', + searchPlaceholder: 'Search...', + noOptionsFound: 'No options found', + noGroupsAvailable: 'No groups available', + unknownError: 'Unknown error occurred', + saving: 'Saving...', + selectedCount: '({count} selected)', + refresh: 'Refresh', + autoRefresh: { + title: 'Auto Refresh', + enable: 'Enable auto refresh', + countdown: 'Auto refresh: {seconds}s', + seconds: '{n} seconds', + }, + view: 'View', + settings: 'Settings', + chooseFile: 'Choose File', + copy: 'Copy', + notAvailable: 'N/A', + now: 'Now', + today: 'Today', + tomorrow: 'Tomorrow', + unknown: 'Unknown', + minutes: 'min', + time: { + never: 'Never', + justNow: 'Just now', + minutesAgo: '{n}m ago', + hoursAgo: '{n}h ago', + daysAgo: '{n}d ago', + countdown: { + daysHours: '{d}d {h}h', + hoursMinutes: '{h}h {m}m', + minutes: '{m}m', + withSuffix: '{time} to lift' + } + } + }, + + adminCompliance: { + title: 'Deployment and Operation Compliance Acknowledgment', + blockingNotice: 'Deployment and operation compliance acknowledgment is required before continuing to use the console.', + riskNotice: 'This acknowledgment provides clear, conspicuous, and reproducible notice of compliance obligations and operation risks for self-hosted instances.', + version: 'Document Version', + openDocument: 'Open the GitHub document', + documentSource: 'The agreement text comes from Markdown files in this project repository. When the agreement content changes, the document version must be incremented; acknowledgments of older versions become invalid and console users must acknowledge again.', + inputLabel: 'Type the following confirmation phrase exactly', + inputPlaceholder: 'Type the confirmation phrase to continue', + inputMismatch: 'The confirmation phrase does not match. Type the displayed text exactly.', + legalNote: 'This acknowledgment defines the no-affiliation relationship and responsibility boundary between self-hosted instances and the open-source project, copyright holders, contributors, and maintainers. The party that deploys, operates, or controls the relevant instance remains independently responsible for its applicable obligations.', + logout: 'Log out', + accept: 'Acknowledge and Continue', + accepted: 'Compliance acknowledgment recorded', + acceptFailed: 'Failed to submit acknowledgment' + }, + + legal: { + loadFailed: 'Failed to load document', + retryLater: 'Refresh the page and try again later.', + notFound: 'Document not found', + notFoundDescription: 'This legal document does not exist or has been removed by an administrator.', + updatedAt: 'Updated: {date}', + empty: 'No content', + loginAgreement: 'Login Agreement', + adminCompliance: 'Deployment and Operation Compliance Commitment', + loginAgreementPrompt: { + checkboxPrefix: 'I have read and agree to ', + documentSeparator: ', ', + noticeTitle: 'Accept the latest terms before continuing.', + noticeDescription: 'Account/password login and quick sign-in stay disabled until you accept.', + viewTerms: 'View terms', + dialogTitle: 'Terms Update Notice', + dialogDescription: 'Our service terms were updated on {date}. Please read and accept the following terms before continuing.', + recently: 'recently', + relatedDocuments: 'Related documents', + reject: 'Reject', + accept: 'Accept and continue', + loginRejectedWarning: 'Account/password login and quick sign-in are disabled until you accept the latest terms.', + loginRequiredWarning: 'Please read and accept the latest terms before logging in.', + registerRejectedWarning: 'Registration and quick sign-in are disabled until you accept the latest terms.', + registerRequiredWarning: 'Please read and accept the latest terms before registering.' + } + }, + + // Navigation + nav: { + dashboard: 'Dashboard', + announcements: 'Announcements', + apiKeys: 'API Keys', + batchImage: 'Batch Images', + usage: 'Usage', + redeem: 'Redeem', + affiliate: 'Affiliate Rebates', + affiliateManagement: 'Affiliate Rebates', + affiliateInviteRecords: 'Invite Records', + affiliateRebateRecords: 'Rebate Records', + affiliateTransferRecords: 'Transfer Records', + profile: 'Profile', + users: 'Users', + groups: 'Groups', + channels: 'Channels', + availableChannels: 'Available Channels', + subscriptions: 'Subscriptions', + accounts: 'Accounts', + proxies: 'Proxies', + redeemCodes: 'Redeem Codes', + ops: 'Ops', + promoCodes: 'Promo Codes', + settings: 'Settings', + myAccount: 'My Account', + lightMode: 'Light Mode', + darkMode: 'Dark Mode', + collapse: 'Collapse', + expand: 'Expand', + logout: 'Logout', + github: 'GitHub', + mySubscriptions: 'My Subscriptions', + buySubscription: 'Recharge / Subscription', + docs: 'Docs', + myOrders: 'My Orders', + orderManagement: 'Orders', + paymentDashboard: 'Payment Dashboard', + paymentConfig: 'Payment Config', + paymentPlans: 'Plans', + channelManagement: 'Channels', + channelPricing: 'Channel Pricing', + channelMonitor: 'Channel Monitor', + channelStatus: 'Channel Status', + riskControl: 'Risk Control', + }, + + // Auth + auth: { + welcomeBack: 'Welcome Back', + signInToAccount: 'Sign in to your account to continue', + signIn: 'Sign In', + signingIn: 'Signing in...', + createAccount: 'Create Account', + signUpToStart: 'Sign up to start using {siteName}', + signUp: 'Sign up', + processing: 'Processing...', + continue: 'Continue', + rememberMe: 'Remember me', + dontHaveAccount: "Don't have an account?", + alreadyHaveAccount: 'Already have an account?', + registrationDisabled: 'Registration is currently disabled. Please contact the administrator.', + emailLabel: 'Email', + emailPlaceholder: 'Enter your email', + passwordLabel: 'Password', + passwordPlaceholder: 'Enter your password', + createPasswordPlaceholder: 'Create a strong password', + passwordHint: 'At least 6 characters', + emailRequired: 'Email is required', + invalidEmail: 'Please enter a valid email address', + passwordRequired: 'Password is required', + passwordMinLength: 'Password must be at least 6 characters', + loginFailed: 'Login failed. Please check your credentials and try again.', + errors: { + USER_NOT_ACTIVE: 'Account has been disabled.', + }, + registrationFailed: 'Registration failed. Please try again.', + emailSuffixNotAllowed: 'This email domain is not allowed for registration.', + emailSuffixNotAllowedWithAllowed: + 'This email domain is not allowed. Allowed domains: {suffixes}', + emailSuffixAllowedMore: 'and {count} more', + loginSuccess: 'Login successful! Welcome back.', + accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.', + reloginRequired: 'Session expired. Please log in again.', + turnstileExpired: 'Verification expired, please try again', + turnstileFailed: 'Verification failed, please try again', + completeVerification: 'Please complete the verification', + verifyYourEmail: 'Verify Your Email', + sessionExpired: 'Session expired', + sessionExpiredDesc: 'Please go back to the registration page and start again.', + verificationCode: 'Verification Code', + verificationCodeHint: 'Enter the 6-digit code sent to your email', + sendingCode: 'Sending...', + sendCode: 'Send code', + clickToResend: 'Click to resend code', + resendCode: 'Resend verification code', + sendCodeDesc: "We'll send a verification code to", + codeSentSuccess: 'Verification code sent! Please check your inbox.', + verifying: 'Verifying...', + verifyAndCreate: 'Verify & Create Account', + resendCountdown: 'Resend code in {countdown}s', + backToRegistration: 'Back to registration', + sendCodeFailed: 'Failed to send verification code. Please try again.', + verifyFailed: 'Verification failed. Please try again.', + codeRequired: 'Verification code is required', + invalidCode: 'Please enter a valid 6-digit code', + promoCodeLabel: 'Promo Code', + promoCodePlaceholder: 'Enter promo code (optional)', + promoCodeValid: 'Valid! You will receive ${amount} bonus balance', + promoCodeInvalid: 'Invalid promo code', + promoCodeNotFound: 'Promo code not found', + promoCodeExpired: 'This promo code has expired', + promoCodeDisabled: 'This promo code is disabled', + promoCodeMaxUsed: 'This promo code has reached its usage limit', + promoCodeAlreadyUsed: 'You have already used this promo code', + promoCodeValidating: 'Promo code is being validated, please wait', + promoCodeInvalidCannotRegister: 'Invalid promo code. Please check and try again or clear the promo code field', + invitationCodeLabel: 'Invitation Code', + invitationCodePlaceholder: 'Enter invitation code', + invitationCodeRequired: 'Invitation code is required', + invitationCodeValid: 'Invitation code is valid', + invitationCodeInvalid: 'Invalid or used invitation code', + invitationCodeValidating: 'Validating invitation code...', + invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again', + oauthOrContinue: 'or continue with others', + linuxdo: { + signIn: 'Continue with Linux.do', + orContinue: 'or continue with email', + callbackTitle: 'Signing you in', + callbackProcessing: 'Completing login, please wait...', + callbackHint: 'If you are not redirected automatically, go back to the login page and try again.', + callbackMissingToken: 'Missing login token, please try again.', + backToLogin: 'Back to Login', + invitationRequired: 'This Linux.do account is not yet registered. The site requires an invitation code — please enter one to complete registration.', + invalidPendingToken: 'The registration token has expired. Please sign in with Linux.do again.', + completeRegistration: 'Complete Registration', + completing: 'Completing registration…', + completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.' + }, + dingtalk: { + signIn: 'Continue with DingTalk', + callbackTitle: 'Signing you in with DingTalk', + callbackProcessing: 'Completing DingTalk login, please wait...', + callbackHint: 'If you are not redirected automatically, go back to the login page and try again.', + callbackMissingToken: 'Missing login token, please try again.', + backToLogin: 'Back to Login', + invitationRequired: 'This DingTalk account is not yet registered. The site requires an invitation code — please enter one to complete registration.', + invalidPendingToken: 'The registration token has expired. Please sign in with DingTalk again.', + completeRegistration: 'Complete Registration', + completing: 'Completing registration…', + completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.', + createAccountTitle: 'Create DingTalk Account', + registrationDisabledRedirectToBind: 'New account registration is currently disabled. Please bind to your existing account with its email and password.', + error: { + title: 'DingTalk Sign-in Failed', + csrf: 'Login session expired, please scan again', + corp_rejected: 'Your DingTalk account is not part of this organization. Please contact administrator', + dingtalk_not_enabled: 'DingTalk login is not enabled', + upstream_error: 'DingTalk service is temporarily unavailable. Please try again later', + missing_browser_session: 'Browser session lost. Please login again', + missing_params: 'Request parameters are incomplete', + invalid_state: 'Invalid login state', + provider_error: 'DingTalk authorization failed', + session_error: 'Failed to create session. Please retry', + retry: 'Retry Login' + } + }, + emailOAuth: { + signIn: 'Continue with {providerName}' + }, + oidc: { + signIn: 'Continue with {providerName}', + callbackTitle: 'Signing you in with {providerName}', + callbackProcessing: 'Completing login with {providerName}, please wait...', + callbackHint: 'If you are not redirected automatically, go back to the login page and try again.', + callbackMissingToken: 'Missing login token, please try again.', + backToLogin: 'Back to Login', + invitationRequired: + 'This {providerName} account is not yet registered. The site requires an invitation code — please enter one to complete registration.', + invalidPendingToken: 'The registration token has expired. Please sign in again.', + completeRegistration: 'Complete Registration', + completing: 'Completing registration…', + completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.' + }, + oauthFlow: { + profileDetailsTitle: 'Use {providerName} profile details', + profileDetailsDescription: 'Choose whether to apply the nickname or avatar from {providerName} to this account.', + useDisplayName: 'Use display name', + useAvatar: 'Use avatar', + avatarAlt: '{providerName} avatar', + reviewProfileBeforeContinue: 'Review the {providerName} profile details before continuing.', + chooseHowToContinue: 'Choose how to continue', + chooseAccountActionHint: 'Choose whether to bind an existing account or create a new one.', + suggestedEmail: 'Suggested email: {email}', + bindExistingAccount: 'Bind existing account', + createNewAccount: 'Create new account', + createAccountHint: 'Enter an email address to create your account and continue.', + bindLoginHint: 'Log in to an existing account to bind this {providerName} sign-in.', + signInThenBindDescription: 'Sign in to an existing account, then bind this {providerName} sign-in to it.', + bindSignInToExistingAccount: 'Bind this {providerName} sign-in to an existing account.', + bindCurrentAccountTitle: 'Bind the current account', + bindCurrentAccountDescription: 'Bind this {providerName} sign-in to the account currently signed in on this browser.', + bindCurrentAccount: 'Bind current account', + logInAndBind: 'Log in and bind', + useDifferentEmail: 'Use a different email', + backToOptions: 'Back to options', + yourAccount: 'your account', + totpHint: 'Enter the 6-digit verification code for {account} to finish binding this {providerName} sign-in.', + verifyAndContinue: 'Verify and continue', + wechatAvailabilityUnknown: 'WeChat sign-in availability could not be confirmed. Refresh and retry.', + wechatSystemBrowserOnly: 'This WeChat sign-in flow is only available in your system browser.', + wechatBrowserOnly: 'This WeChat sign-in flow is only available inside the WeChat browser.', + wechatNotConfigured: 'WeChat sign-in is not configured yet.' + }, + linuxdoCallbackPageTitle: 'LinuxDo Sign-In Callback', + dingtalkCallbackPageTitle: 'DingTalk Sign-In Callback', + dingtalkProviderName: 'DingTalk', + oidcCallbackPageTitle: 'OIDC Sign-In Callback', + oauthCallbackPageTitle: 'OAuth Callback', + wechatProviderName: 'WeChat', + wechatCallbackPageTitle: 'WeChat Sign-In Callback', + wechatPaymentCallbackPageTitle: 'WeChat Payment Callback', + wechatPayment: { + callbackTitle: 'Resuming WeChat payment', + callbackProcessing: 'Resuming WeChat payment...', + backToPayment: 'Back to payment', + callbackMissingResumeToken: 'The WeChat payment callback is missing the resume token.' + }, + oauth: { + callbackTitle: 'OAuth Callback', + callbackHint: 'Copy the code and state back to the admin authorization flow when needed.', + invalidCallbackTitle: 'Invalid sign-in callback', + invalidCallbackHint: 'This page does not contain a valid authorization result. Return to the login page and start quick sign-in again.', + code: 'Code', + state: 'State', + fullUrl: 'Full URL' + }, + // Forgot password + forgotPassword: 'Forgot password?', + forgotPasswordTitle: 'Reset Your Password', + forgotPasswordHint: 'Enter your email address and we will send you a link to reset your password.', + sendResetLink: 'Send Reset Link', + sendingResetLink: 'Sending...', + sendResetLinkFailed: 'Failed to send reset link. Please try again.', + resetEmailSent: 'Reset Link Sent', + resetEmailSentHint: 'If an account exists with this email, you will receive a password reset link shortly. Please check your inbox and spam folder.', + backToLogin: 'Back to Login', + rememberedPassword: 'Remembered your password?', + // Reset password + resetPasswordTitle: 'Set New Password', + resetPasswordHint: 'Enter your new password below.', + newPassword: 'New Password', + newPasswordPlaceholder: 'Enter your new password', + confirmPassword: 'Confirm Password', + confirmPasswordPlaceholder: 'Confirm your new password', + confirmPasswordRequired: 'Please confirm your password', + passwordsDoNotMatch: 'Passwords do not match', + resetPassword: 'Reset Password', + resettingPassword: 'Resetting...', + resetPasswordFailed: 'Failed to reset password. Please try again.', + passwordResetSuccess: 'Password Reset Successful', + passwordResetSuccessHint: 'Your password has been reset. You can now sign in with your new password.', + invalidResetLink: 'Invalid Reset Link', + invalidResetLinkHint: 'This password reset link is invalid or has expired. Please request a new one.', + requestNewResetLink: 'Request New Reset Link', + invalidOrExpiredToken: 'The password reset link is invalid or has expired. Please request a new one.' + }, + + // Dashboard +} diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts new file mode 100644 index 0000000000..a9c7c750c8 --- /dev/null +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -0,0 +1,811 @@ +export default { + dashboard: { + title: 'Dashboard', + welcomeMessage: "Welcome back! Here's an overview of your account.", + balance: 'Balance', + apiKeys: 'API Keys', + todayRequests: 'Today Requests', + todayCost: 'Today Cost', + todayTokens: 'Today Tokens', + totalTokens: 'Total Tokens', + cacheToday: 'Cache (Today)', + performance: 'Performance', + avgResponse: 'Avg Response', + averageTime: 'Average time', + timeRange: 'Time Range', + granularity: 'Granularity', + day: 'Day', + hour: 'Hour', + modelDistribution: 'Model Distribution', + groupDistribution: 'Group Usage Distribution', + platformBreakdown: 'Per-platform Breakdown', + platformBreakdownEmpty: 'No platform usage yet', + platformCount: '{count} platforms', + platformOther: 'Other', + platformQuota: { + title: 'Quota Usage', + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly (30-day rolling)', + resetsAt: 'Resets {time}', + noLimit: 'unlimited', + disabled: 'Disabled', + }, + tokenUsageTrend: 'Token Usage Trend', + noDataAvailable: 'No data available', + model: 'Model', + group: 'Group', + noGroup: 'No Group', + requests: 'Requests', + tokens: 'Tokens', + actual: 'Actual', + standard: 'Standard', + input: 'Input', + output: 'Output', + cache: 'Cache', + recentUsage: 'Recent Usage', + last7Days: 'Last 7 days', + noUsageRecords: 'No usage records', + startUsingApi: 'Start using the API to see your usage history here.', + viewAllUsage: 'View all usage', + quickActions: 'Quick Actions', + createApiKey: 'Create API Key', + generateNewKey: 'Generate a new API key', + batchImageAgent: 'Batch Image Assistant', + batchImageAgentDesc: 'Copy instructions for an agent', + viewUsage: 'View Usage', + checkDetailedLogs: 'Check detailed usage logs', + redeemCode: 'Redeem Code', + addBalanceWithCode: 'Add balance with a code' + }, + + // Groups (shared) + groups: { + subscription: 'Sub' + }, + + // API Keys + keys: { + title: 'API Keys', + description: 'Manage your API keys and access tokens', + searchPlaceholder: 'Search name or key...', + endpoints: { + title: 'API Endpoints', + default: 'Default', + copied: 'Copied', + copiedHint: 'Copied to clipboard', + clickToCopy: 'Click to copy this endpoint', + speedTest: 'Speed Test', + }, + allGroups: 'All Groups', + allStatus: 'All Status', + columnSettings: 'Column Settings', + columnAlwaysVisible: 'This column is always visible', + createKey: 'Create API Key', + editKey: 'Edit API Key', + deleteKey: 'Delete API Key', + deleteConfirmMessage: "Are you sure you want to delete '{name}'? This action cannot be undone.", + apiKey: 'API Key', + group: 'Group', + currentConcurrency: 'Current Concurrency', + noGroup: 'No group', + searchGroup: 'Search groups...', + noGroupFound: 'No groups found', + created: 'Created', + copyToClipboard: 'Copy to clipboard', + copied: 'Copied!', + importToCcSwitch: 'Import to CCS', + enable: 'Enable', + disable: 'Disable', + nameLabel: 'Name', + namePlaceholder: 'My API Key', + groupLabel: 'Group', + selectGroup: 'Select a group', + statusLabel: 'Status', + selectStatus: 'Select status', + saving: 'Saving...', + noKeysYet: 'No API keys yet', + createFirstKey: 'Create your first API key to get started with the API.', + keyCreatedSuccess: 'API key created successfully', + keyUpdatedSuccess: 'API key updated successfully', + keyDeletedSuccess: 'API key deleted successfully', + keyEnabledSuccess: 'API key enabled successfully', + keyDisabledSuccess: 'API key disabled successfully', + failedToLoad: 'Failed to load API keys', + failedToSave: 'Failed to save API key', + failedToDelete: 'Failed to delete API key', + failedToUpdateStatus: 'Failed to update API key status', + clickToChangeGroup: 'Click to change group', + groupChangedSuccess: 'Group changed successfully', + failedToChangeGroup: 'Failed to change group', + groupRequired: 'Please select a group', + usage: 'Usage', + today: 'Today', + total: 'Last 30d', + quota: 'Quota', + lastUsedAt: 'Last Used', + useKey: 'Use Key', + useKeyModal: { + title: 'Use API Key', + description: + 'Add the following environment variables to your terminal profile or run directly in terminal to configure API access.', + copy: 'Copy', + copied: 'Copied', + note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.', + noGroupTitle: 'Please assign a group first', + noGroupDescription: 'This API key has not been assigned to a group. Please click the group column in the key list to assign one before viewing the configuration.', + openai: { + description: 'Add the following configuration files to your Codex CLI config directory.', + configTomlHint: 'Make sure the following content is at the beginning of the config.toml file', + note: 'Make sure the config directory exists. macOS/Linux users can run mkdir -p ~/.codex to create it.', + noteWindows: 'Press Win+R and enter %userprofile%\\.codex to open the config directory. Create it manually if it does not exist.', + }, + cliTabs: { + claudeCode: 'Claude Code', + geminiCli: 'Gemini CLI', + codexCli: 'Codex CLI', + codexCliWs: 'Codex CLI (WebSocket)', + opencode: 'OpenCode', + }, + antigravity: { + description: 'Configure API access for Antigravity group. Select the configuration method based on your client.', + claudeCode: 'Claude Code', + geminiCli: 'Gemini CLI', + claudeNote: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.', + geminiNote: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.', + }, + gemini: { + description: 'Add the following environment variables to your terminal profile or run directly in terminal to configure Gemini CLI access.', + modelComment: 'If you have Gemini 3 access, you can use: gemini-3-pro-preview', + note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.', + }, + opencode: { + title: 'OpenCode Example', + subtitle: 'opencode.json', + hint: 'Config path: ~/.config/opencode/opencode.json (or opencode.jsonc), create if not exists. Use default providers (openai/anthropic/google) or custom provider_id. API Key can be configured directly or via /connect command. This is an example, adjust models and options as needed.', + }, + }, + customKeyLabel: 'Custom Key', + customKeyPlaceholder: 'Enter your custom key (min 16 chars)', + customKeyHint: 'Only letters, numbers, underscores and hyphens allowed. Minimum 16 characters.', + customKeyTooShort: 'Custom key must be at least 16 characters', + customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens', + customKeyRequired: 'Please enter a custom key', + ipRestriction: 'IP Restriction', + ipWhitelist: 'IP Whitelist', + ipWhitelistPlaceholder: '192.168.1.100\n10.0.0.0/8', + ipWhitelistHint: 'One IP or CIDR per line. Only these IPs can use this key when set.', + ipBlacklist: 'IP Blacklist', + ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16', + ipBlacklistHint: 'One IP or CIDR per line. These IPs will be blocked from using this key.', + ipRestrictionEnabled: 'IP restriction enabled', + ccSwitchNotInstalled: 'CC-Switch is not installed or the protocol handler is not registered. Please install CC-Switch first or manually copy the API key.', + ccsClientSelect: { + title: 'Select Client', + description: 'Please select the client type to import to CC-Switch:', + claudeCode: 'Claude Code', + claudeCodeDesc: 'Import as Claude Code configuration', + geminiCli: 'Gemini CLI', + geminiCliDesc: 'Import as Gemini CLI configuration', + }, + // Quota and expiration + quotaLimit: 'Quota Limit', + quotaAmount: 'Quota Amount (USD)', + quotaAmountPlaceholder: 'Enter quota limit in USD', + quotaAmountHint: 'Set the maximum amount this key can spend. 0 = unlimited.', + quotaUsed: 'Quota Used', + reset: 'Reset', + resetQuotaUsed: 'Reset used quota to 0', + resetQuotaTitle: 'Confirm Reset Quota', + resetQuotaConfirmMessage: 'Are you sure you want to reset the used quota (${used}) for key "{name}" to 0? This action cannot be undone.', + quotaResetSuccess: 'Quota reset successfully', + failedToResetQuota: 'Failed to reset quota', + rateLimitColumn: 'Rate Limit', + rateLimitSection: 'Rate Limit', + resetUsage: 'Reset', + rateLimit5h: '5-Hour Limit (USD)', + rateLimit1d: 'Daily Limit (USD)', + rateLimit7d: '7-Day Limit (USD)', + rateLimitHint: 'Set the maximum spending for this key within each time window. 0 = unlimited.', + rateLimitUsage: 'Rate Limit Usage', + resetRateLimitUsage: 'Reset Rate Limit Usage', + resetRateLimitTitle: 'Confirm Reset Rate Limit', + resetRateLimitConfirmMessage: 'Are you sure you want to reset the rate limit usage for key "{name}"? All time window usage will be reset to zero. This action cannot be undone.', + rateLimitResetSuccess: 'Rate limit usage reset successfully', + failedToResetRateLimit: 'Failed to reset rate limit usage', + resetNow: 'Resetting soon', + expiration: 'Expiration', + expiresInDays: '{days} days', + extendDays: '+{days} days', + customDate: 'Custom', + expirationDate: 'Expiration Date', + expirationDateHint: 'Select when this API key should expire.', + currentExpiration: 'Current expiration', + expiresAt: 'Expires', + noExpiration: 'Never', + status: { + active: 'Active', + inactive: 'Inactive', + quota_exhausted: 'Quota Exhausted', + expired: 'Expired', + }, + }, + + // Usage + usage: { + title: 'Usage Records', + description: 'View and analyze your API usage history', + costDetails: 'Cost Breakdown', + tokenDetails: 'Token Breakdown', + cacheTtlOverriddenHint: 'Cache TTL Override enabled', + cacheTtlOverriddenLabel: 'TTL Override', + cacheTtlOverridden5m: 'Billed as 5m', + cacheTtlOverridden1h: 'Billed as 1h', + totalRequests: 'Total Requests', + totalTokens: 'Total Tokens', + cacheTotal: 'Cache', + cacheBreakdown: 'Cache Token Breakdown', + cacheCreationTokensLabel: 'Cache Creation', + cacheReadTokensLabel: 'Cache Read', + totalCost: 'Total Cost', + standardCost: 'Standard', + actualCost: 'Actual', + accountCost: 'Cost', + userBilled: 'User billed', + accountBilled: 'Account billed', + resetNow: 'Now', + resetPending: 'Pending refresh', + accountMultiplier: 'Account rate', + avgDuration: 'Avg Duration', + inSelectedRange: 'in selected range', + perRequest: 'per request', + apiKeyFilter: 'API Key', + allApiKeys: 'All API Keys', + timeRange: 'Time Range', + exportCsv: 'Export CSV', + exportExcel: 'Export Excel', + exportingProgress: 'Exporting data...', + exportedCount: 'Exported {current}/{total} records', + estimatedTime: 'Estimated time remaining: {time}', + cancelExport: 'Cancel Export', + exportCancelled: 'Export cancelled', + exporting: 'Exporting...', + preparingExport: 'Preparing export...', + model: 'Model', + requestedModel: 'Requested', + upstreamModel: 'Upstream', + reasoningEffort: 'Reasoning Effort', + endpoint: 'Endpoint', + endpointDistribution: 'Endpoint Distribution', + inbound: 'Inbound', + upstream: 'Upstream', + mapping: 'Mapping', + path: 'Path', + inboundEndpoint: 'Inbound Endpoint', + upstreamEndpoint: 'Upstream Endpoint', + type: 'Type', + tokens: 'Tokens', + cost: 'Cost', + firstToken: 'First Token', + duration: 'Duration', + time: 'Time', + ws: 'WS', + stream: 'Stream', + sync: 'Sync', + cyber: 'Cyber', + unknown: 'Unknown', + in: 'In', + out: 'Out', + cacheHit: 'Cache hit', + cacheCreate: 'Cache create', + cacheHitRate: 'Cache hit rate', + inputTokenPrice: 'Input price', + outputTokenPrice: 'Output price', + perMillionTokens: '/ 1M tokens', + unitPrice: 'Per-request price', + imageUnitPrice: 'Per-image price', + imageTotalPrice: 'Image total price', + imageCount: 'Image count', + imageBillingSize: 'Billing size', + imageInputSize: 'Input size', + imageOutputSize: 'Output size', + imageOutputTokens: 'Image Output Tokens', + imageOutputTokenPrice: 'Image Output Price', + imageOutputCost: 'Image Output Cost', + imageSizeSource: 'Size source', + imageSizeBreakdown: 'Size breakdown', + imageSizeSourceOutput: 'Upstream output', + imageSizeSourceInput: 'Request input', + imageSizeSourceDefault: 'Default billing tier', + imageSizeSourceLegacy: 'Legacy record', + imageSizeSourceMissing: 'Not recorded', + imageSizeNotRecorded: 'not recorded', + imageSizeLegacyUnstandardized: 'legacy unstandardized', + imageSizeUnknown: 'unknown', + cacheRead: 'Read', + cacheWrite: 'Write', + serviceTier: 'Service tier', + serviceTierPriority: 'Fast', + serviceTierFlex: 'Flex', + serviceTierStandard: 'Standard', + rate: 'Rate', + original: 'Original', + billed: 'Billed', + noRecords: 'No usage records found. Try adjusting your filters.', + failedToLoad: 'Failed to load usage logs', + noDataToExport: 'No data to export', + exportSuccess: 'Usage data exported successfully', + exportFailed: 'Failed to export usage data', + exportExcelSuccess: 'Usage data exported successfully (Excel format)', + exportExcelFailed: 'Failed to export usage data', + imageUnit: ' images', + userAgent: 'User-Agent', + ipGeo: { + fetch: 'Fetch region', + fetching: 'Fetching...', + failed: 'Failed', + private: 'Private address', + refreshTitle: 'Refresh region info', + batchFetch: 'Batch fetch regions', + batchFetching: 'Fetching...', + pending: '{count} IPs pending', + batchFailed: 'Failed to batch fetch IP regions', + detailOrg: 'ISP', + detailTimezone: 'Timezone', + detailAccuracy: 'Accuracy', + detailCoordinates: 'Coordinates', + }, + tabs: { usage: 'Usage', errors: 'Error Requests' }, + errors: { + time: 'Time', model: 'Model', endpoint: 'Endpoint', status: 'Status', + category: 'Category', platform: 'Platform', message: 'Message', + keyName: 'Key Name', keyDeleted: 'Deleted', allKeys: 'All keys', + modelPlaceholder: 'Search model', allCategories: 'All categories', allStatuses: 'All status codes', + empty: 'No error requests', failedToLoad: 'Failed to load error requests', + categories: { + auth: 'Auth failed', rate_limit: 'Rate limited', quota: 'Balance/Subscription', + invalid_request: 'Invalid request', service_unavailable: 'Service unavailable', + upstream: 'Upstream error', internal: 'Platform error', other: 'Other', cyber: 'Cyber policy', + }, + detail: { + title: 'Error Request Detail', + responseBody: 'Response Body', + upstreamStatus: 'Upstream Status', + loadFailed: 'Failed to load detail, please try again', + }, + }, + }, + + // Shared keys for channel monitor (admin + user views) + monitorCommon: { + status: { + operational: 'Operational', + degraded: 'Degraded', + failed: 'Failed', + error: 'Error', + unknown: '-' + }, + providers: { + openai: 'OpenAI', + anthropic: 'Anthropic', + gemini: 'Gemini' + }, + extraModelsHeader: 'Extra Models', + extraModelsEmpty: 'No extra models', + latencyEmpty: '-', + availabilityPrefix: 'Availability', + dialogLatency: 'Dialog Latency', + endpointPing: 'Endpoint PING', + history60pts: 'HISTORY ({n} PTS)', + nextUpdateIn: 'NEXT UPDATE IN {n}s', + past: 'PAST', + now: 'NOW', + maintenancePaused: 'Maintenance · timeline paused', + extraModelsCount: '+ {n} models', + pollEvery: '{n}s polling', + updatedAt: 'Updated {time}', + relativeSecondsAgo: '{n}s ago', + relativeMinutesAgo: '{n}m ago', + relativeHoursAgo: '{n}h ago', + relativeDaysAgo: '{n}d ago' + }, + + // Channel Status (user-facing read-only view) + channelStatus: { + title: 'Channel Status', + description: 'Inspect channel availability, latency and recent status', + searchPlaceholder: 'Search channels...', + allProviders: 'All Providers', + loadError: 'Failed to load channel status', + detailLoadError: 'Failed to load channel detail', + detailTitle: 'Channel Detail', + closeDetail: 'Close', + windowTab: { + '7d': '7 days', + '15d': '15 days', + '30d': '30 days' + }, + overall: { + operational: 'OPERATIONAL', + degraded: 'DEGRADED', + unavailable: 'UNAVAILABLE' + }, + columns: { + name: 'Name', + provider: 'Provider', + groupName: 'Group', + primaryModel: 'Primary Model', + availability7d: '7d Availability', + latency: 'Latency (ms)' + }, + detailColumns: { + model: 'Model', + latestStatus: 'Latest Status', + latestLatency: 'Latest Latency (ms)', + availability7d: '7d Availability', + availability15d: '15d Availability', + availability30d: '30d Availability', + avgLatency7d: '7d Avg Latency (ms)' + }, + empty: { + title: 'No channels available', + description: 'No monitored channels have been configured yet.' + } + }, + + // Available Channels (user-facing) + availableChannels: { + title: 'Available Channels', + description: 'Channels you can access, along with their supported models and pricing', + searchPlaceholder: 'Search channels or models...', + empty: 'No available channels', + noModels: 'No models configured', + noPricing: 'Pricing not configured', + exclusive: 'Exclusive', + public: 'Public', + exclusiveTooltip: 'Exclusive groups granted to you by an admin', + publicTooltip: 'Groups open to all users', + columns: { + name: 'Channel', + description: 'Description', + platform: 'Platform', + groups: 'Your Accessible Groups', + supportedModels: 'Supported Models' + }, + pricing: { + billingMode: 'Billing Mode', + billingModeToken: 'Per Token', + billingModePerRequest: 'Per Request', + billingModeImage: 'Per Image', + inputPrice: 'Input', + outputPrice: 'Output', + cacheWritePrice: 'Cache Write', + cacheReadPrice: 'Cache Read', + imageOutputPrice: 'Image Output', + perRequestPrice: 'Per Request', + intervals: 'Tiered Pricing', + unitPerMillion: '/ 1M tokens', + unitPerRequest: '/ request' + } + }, + + affiliate: { + title: 'Affiliate Rebates', + description: 'Invite new users and convert your rebate quota into account balance', + yourCode: 'Your Affiliate Code', + inviteLink: 'Invite Link', + copyCode: 'Copy Code', + copyLink: 'Copy Link', + codeCopied: 'Affiliate code copied', + linkCopied: 'Invite link copied', + loadFailed: 'Failed to load affiliate data', + transferFailed: 'Failed to transfer affiliate quota', + stats: { + rebateRate: 'My Rebate Rate', + rebateRateHint: 'What you earn each time an invitee recharges', + invitedUsers: 'Invited Users', + availableQuota: 'Available Rebate Quota', + frozenQuota: 'Frozen', + frozenQuotaHint: 'Recently earned rebates pending release', + totalQuota: 'Historical Rebate Quota' + }, + transfer: { + title: 'Transfer Rebate Quota', + description: 'Move available rebate quota into your account balance', + button: 'Transfer to Balance', + transferring: 'Transferring...', + empty: 'No available rebate quota', + success: '{amount} has been transferred to your balance' + }, + invitees: { + title: 'Invited Users', + empty: 'No invited users yet', + columns: { + email: 'Email', + username: 'Username', + rebate: 'Rebate', + joinedAt: 'Joined At' + } + }, + tips: { + title: 'How It Works', + line1: 'Share your affiliate code or invite link with new users.', + line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.', + line3: 'Transfer rebate quota to balance at any time.', + line4: 'Newly earned rebates may have a waiting period before they can be transferred.' + } + }, + + // Redeem + redeem: { + title: 'Redeem Code', + description: 'Enter your redeem code to add balance or increase concurrency', + currentBalance: 'Current Balance', + concurrency: 'Concurrency', + requests: 'requests', + redeemCodeLabel: 'Redeem Code', + redeemCodePlaceholder: 'Enter your redeem code', + redeemCodeHint: 'Redeem codes are case-sensitive', + redeeming: 'Redeeming...', + redeemButton: 'Redeem Code', + redeemSuccess: 'Code Redeemed Successfully!', + redeemFailed: 'Redemption Failed', + added: 'Added', + concurrentRequests: 'concurrent requests', + newBalance: 'New Balance', + newConcurrency: 'New Concurrency', + aboutCodes: 'About Redeem Codes', + codeRule1: 'Each code can only be used once', + codeRule2: 'Codes may add balance, increase concurrency, or grant trial access', + codeRule3: 'Contact support if you have issues redeeming a code', + codeRule4: 'Balance and concurrency updates are immediate', + recentActivity: 'Recent Activity', + historyWillAppear: 'Your redemption history will appear here', + balanceAddedRedeem: 'Balance Added (Redeem)', + balanceAddedAffiliate: 'Balance Added (Affiliate Transfer)', + balanceAddedAdmin: 'Balance Added (Admin)', + balanceDeductedAdmin: 'Balance Deducted (Admin)', + concurrencyAddedRedeem: 'Concurrency Added (Redeem)', + concurrencyAddedAdmin: 'Concurrency Added (Admin)', + concurrencyReducedAdmin: 'Concurrency Reduced (Admin)', + adminAdjustment: 'Admin Adjustment', + subscriptionAssigned: 'Subscription Assigned', + subscriptionAssignedDesc: 'You have been granted access to {groupName}', + subscriptionDays: '{days} days', + days: ' days', + codeRedeemSuccess: 'Code redeemed successfully!', + failedToRedeem: 'Failed to redeem code. Please check the code and try again.', + subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.', + pleaseEnterCode: 'Please enter a redeem code' + }, + + // Profile + profile: { + title: 'Profile Settings', + description: 'Manage your account information and settings', + accountBalance: 'Account Balance', + concurrencyLimit: 'Concurrency Limit', + rpmLimit: 'RPM Limit', + rpmUnlimited: 'Unlimited', + memberSince: 'Member Since', + overviewTitle: 'Account Overview', + overviewDescription: 'Check account status, profile sources, and common actions at a glance.', + basicsTitle: 'Profile & Avatar', + basicsDescription: 'Keep your public profile details and avatar aligned.', + linkedProfileSources: 'Profile Sources', + linkedProfileSourcesDescription: 'Some profile details may stay synced from third-party sign-in methods.', + securityTitle: 'Security Settings', + securityDescription: 'Password, two-factor authentication, and alerts live in the right rail.', + administrator: 'Administrator', + user: 'User', + username: 'Username', + email: 'Email', + status: 'Status', + role: 'Role', + enterUsername: 'Enter username', + editProfile: 'Edit Profile', + updateProfile: 'Update Profile', + updating: 'Updating...', + updateSuccess: 'Profile updated successfully', + updateFailed: 'Failed to update profile', + usernameRequired: 'Username is required', + changePassword: 'Change Password', + currentPassword: 'Current Password', + newPassword: 'New Password', + confirmNewPassword: 'Confirm New Password', + passwordHint: 'Password must be at least 8 characters long', + changingPassword: 'Changing...', + changePasswordButton: 'Change Password', + passwordsNotMatch: 'New passwords do not match', + passwordTooShort: 'Password must be at least 8 characters long', + passwordChangeSuccess: 'Password changed successfully', + passwordChangeFailed: 'Failed to change password', + // TOTP 2FA + totp: { + title: 'Two-Factor Authentication (2FA)', + description: 'Enhance account security with Google Authenticator or similar apps', + enabled: 'Enabled', + enabledAt: 'Enabled at', + notEnabled: 'Not Enabled', + notEnabledHint: 'Enable two-factor authentication to enhance account security', + enable: 'Enable', + disable: 'Disable', + featureDisabled: 'Feature Unavailable', + featureDisabledHint: 'Two-factor authentication has not been enabled by the administrator', + setupTitle: 'Set Up Two-Factor Authentication', + setupStep1: 'Scan the QR code below with your authenticator app', + setupStep2: 'Enter the 6-digit code from your app', + manualEntry: "Can't scan? Enter the key manually:", + enterCode: 'Enter 6-digit code', + verify: 'Verify', + setupFailed: 'Failed to get setup information', + verifyFailed: 'Invalid code, please try again', + enableSuccess: 'Two-factor authentication enabled', + disableTitle: 'Disable Two-Factor Authentication', + disableWarning: 'After disabling, you will no longer need a verification code to log in. This may reduce your account security.', + enterPassword: 'Enter your current password to confirm', + confirmDisable: 'Confirm Disable', + disableSuccess: 'Two-factor authentication disabled', + disableFailed: 'Failed to disable, please check your password', + loginTitle: 'Two-Factor Authentication', + loginHint: 'Enter the 6-digit code from your authenticator app', + loginFailed: 'Verification failed, please try again', + // New translations for email verification + verifyEmailFirst: 'Please verify your email first', + verifyPasswordFirst: 'Please verify your identity first', + emailCode: 'Email Verification Code', + enterEmailCode: 'Enter 6-digit code', + sendCode: 'Send Code', + codeSent: 'Verification code sent to your email', + sendCodeFailed: 'Failed to send verification code' + }, + balanceNotify: { + title: 'Balance Low Notification', + description: 'Send email alert when account balance falls below threshold', + enabled: 'Enable Balance Low Notification', + threshold: 'Custom Threshold', + thresholdHint: 'Leave empty to use system default', + thresholdPlaceholder: 'Enter amount', + systemDefault: 'System Default', + extraEmails: 'Notification Emails', + extraEmailsHint: 'You must add and verify an email address to receive low balance alerts', + primaryEmail: 'Primary', + noExtraEmails: 'No extra notification emails', + enterEmail: 'Enter email address', + addEmail: 'Add Email', + emailPlaceholder: 'Enter email address', + sendCode: 'Send Code', + resend: 'Resend', + codeSent: 'Verification code sent', + codeSentTo: 'Code sent to {email}', + enterCode: 'Enter verification code', + codePlaceholder: '6-digit code', + verify: 'Verify', + emailAdded: 'Email added', + emailRemoved: 'Email removed', + verifySuccess: 'Email added successfully', + removeEmail: 'Remove', + removeSuccess: 'Email removed', + emailDuplicate: 'This email already exists', + maxEmailsReached: 'Maximum number of notification emails reached', + unverified: 'Unverified', + verified: 'Verified', + }, + avatar: { + title: 'Profile Avatar', + description: 'Upload an avatar image. Static uploads are compressed to 20KB before saving.', + uploadAction: 'Upload image', + uploadHint: 'Static uploads are compressed to 20KB when possible. GIF uploads must already be within 20KB.', + uploadRequired: 'Upload an avatar image first', + saveSuccess: 'Avatar updated', + deleteSuccess: 'Avatar removed', + invalidType: 'Please choose an image file', + gifTooLarge: 'GIF avatars must already be 20KB or smaller', + compressTooLarge: 'Unable to compress this image below 20KB. Try a smaller image.', + compressFailed: 'Failed to compress the selected image.', + readFailed: 'Failed to read the selected image.', + emptyDeleteHint: 'Avatar is already empty', + }, + authBindings: { + title: 'Connected Sign-In Methods', + description: 'View current bindings and connect another provider to this account.', + bindAction: 'Bind {providerName}', + bindSuccess: 'Account linked successfully', + emailPlaceholder: 'Enter email address', + codePlaceholder: 'Enter verification code', + passwordPlaceholder: 'Set a login password', + replaceEmailPasswordPlaceholder: 'Enter current password', + sendCodeAction: 'Send code', + manageEmailAction: 'Manage email', + hideEmailFormAction: 'Hide email form', + confirmEmailBindAction: 'Bind email', + confirmEmailReplaceAction: 'Replace primary email', + codeSentTo: 'Code sent to {email}', + replaceSuccess: 'Primary email updated', + unbindAction: 'Unbind', + unbindSuccess: '{providerName} unbound', + boundCount: '{count} linked records', + status: { + bound: 'Bound', + notBound: 'Not bound', + }, + providers: { + email: 'Email', + linuxdo: 'LinuxDo', + dingtalk: 'DingTalk', + oidc: '{providerName}', + wechat: 'WeChat', + }, + notes: { + emailManagedFromProfile: 'Primary email is managed in the profile form', + canUnbind: 'You can unbind this sign-in method', + bindAnotherBeforeUnbind: 'Bind another sign-in method before unbinding', + }, + source: { + avatar: 'Avatar is currently synced from {providerName}', + username: 'Nickname is currently synced from {providerName}', + }, + } + }, + + // Empty States + empty: { + noData: 'No data found' + }, + + // Table + table: { + expandActions: 'Expand More Actions', + collapseActions: 'Collapse Actions' + }, + + // Pagination + pagination: { + showing: 'Showing', + to: 'to', + of: 'of', + results: 'results', + page: 'Page', + pageOf: 'Page {page} of {total}', + previous: 'Previous', + next: 'Next', + perPage: 'Per page', + goToPage: 'Go to page {page}', + jumpTo: 'Jump to', + jumpPlaceholder: 'Page', + jumpAction: 'Go' + }, + + // Errors + errors: { + somethingWentWrong: 'Something went wrong', + pageNotFound: 'Page not found', + unauthorized: 'Unauthorized', + forbidden: 'Forbidden', + serverError: 'Server error', + networkError: 'Network error', + timeout: 'Request timeout', + tryAgain: 'Please try again' + }, + + // Dates + dates: { + today: 'Today', + yesterday: 'Yesterday', + thisWeek: 'This Week', + lastWeek: 'Last Week', + thisMonth: 'This Month', + lastMonth: 'Last Month', + last24Hours: 'Last 24 Hours', + last7Days: 'Last 7 Days', + last14Days: 'Last 14 Days', + last30Days: 'Last 30 Days', + custom: 'Custom', + startDate: 'Start Date', + endDate: 'End Date', + apply: 'Apply', + selectDateRange: 'Select date range' + }, + + // Admin +} diff --git a/frontend/src/i18n/locales/en/index.ts b/frontend/src/i18n/locales/en/index.ts new file mode 100644 index 0000000000..377c67aec7 --- /dev/null +++ b/frontend/src/i18n/locales/en/index.ts @@ -0,0 +1,13 @@ +import landing from './landing' +import common from './common' +import dashboard from './dashboard' +import admin from './admin' +import misc from './misc' + +export default { + ...landing, + ...common, + ...dashboard, + admin, + ...misc, +} diff --git a/frontend/src/i18n/locales/en/landing.ts b/frontend/src/i18n/locales/en/landing.ts new file mode 100644 index 0000000000..afbecf4861 --- /dev/null +++ b/frontend/src/i18n/locales/en/landing.ts @@ -0,0 +1,255 @@ +export default { + batchImageGuide: { + title: 'Batch Image Generation', + description: 'Submit multiple prompts in one job and download the generated images when complete' + }, + // Home Page + home: { + viewOnGithub: 'View on GitHub', + viewDocs: 'View Documentation', + docs: 'Docs', + switchToLight: 'Switch to Light Mode', + switchToDark: 'Switch to Dark Mode', + dashboard: 'Dashboard', + login: 'Login', + getStarted: 'Get Started', + goToDashboard: 'Go to Dashboard', + // User-focused value proposition + heroSubtitle: 'One Key, All AI Models', + heroDescription: 'No need to manage multiple subscriptions. Access Claude, GPT, Gemini and more with a single API key', + tags: { + subscriptionToApi: 'Subscription to API', + stickySession: 'Session Persistence', + realtimeBilling: 'Pay As You Go' + }, + // Pain points section + painPoints: { + title: 'Sound Familiar?', + items: { + expensive: { + title: 'High Subscription Costs', + desc: 'Paying for multiple AI subscriptions that add up every month' + }, + complex: { + title: 'Account Chaos', + desc: 'Managing scattered accounts and API keys across different platforms' + }, + unstable: { + title: 'Service Interruptions', + desc: 'Single accounts hitting rate limits and disrupting your workflow' + }, + noControl: { + title: 'No Usage Control', + desc: "Can't track where your money goes or limit team member usage" + } + } + }, + // Solutions section + solutions: { + title: 'We Solve These Problems', + subtitle: 'Three simple steps to stress-free AI access' + }, + features: { + unifiedGateway: 'One-Click Access', + unifiedGatewayDesc: 'Get a single API key to call all connected AI models. No separate applications needed.', + multiAccount: 'Always Reliable', + multiAccountDesc: 'Smart routing across multiple upstream accounts with automatic failover. Say goodbye to errors.', + balanceQuota: 'Pay What You Use', + balanceQuotaDesc: 'Usage-based billing with quota limits. Full visibility into team consumption.' + }, + // Comparison section + comparison: { + title: 'Why Choose Us?', + headers: { + feature: 'Comparison', + official: 'Official Subscriptions', + us: 'Our Platform' + }, + items: { + pricing: { + feature: 'Pricing', + official: 'Fixed monthly fee, pay even if unused', + us: 'Pay only for what you use' + }, + models: { + feature: 'Model Selection', + official: 'Single provider only', + us: 'Switch between models freely' + }, + management: { + feature: 'Account Management', + official: 'Manage each service separately', + us: 'Unified key, one dashboard' + }, + stability: { + feature: 'Stability', + official: 'Single account rate limits', + us: 'Multi-account pool, auto-failover' + }, + control: { + feature: 'Usage Control', + official: 'Not available', + us: 'Quotas & detailed analytics' + } + } + }, + providers: { + title: 'Supported AI Models', + description: 'One API, Multiple Choices', + supported: 'Supported', + soon: 'Soon', + claude: 'Claude', + gemini: 'Gemini', + antigravity: 'Antigravity', + more: 'More' + }, + // CTA section + cta: { + title: 'Ready to Get Started?', + description: 'Sign up now and get free trial credits to experience seamless AI access', + button: 'Sign Up Free' + }, + footer: { + allRightsReserved: 'All rights reserved.' + } + }, + + // Key Usage Query Page + keyUsage: { + title: 'API Key Usage', + subtitle: 'Enter your API Key to view real-time spending and usage status', + placeholder: 'sk-ant-mirror-xxxxxxxxxxxx', + query: 'Query', + querying: 'Querying...', + privacyNote: 'Your Key is processed locally in the browser and will not be stored', + dateRange: 'Date Range:', + dateRangeToday: 'Today', + dateRange7d: '7 Days', + dateRange30d: '30 Days', + dateRange90d: '90 Days', + dateRangeCustom: 'Custom', + apply: 'Apply', + used: 'Used', + detailInfo: 'Detail Information', + tokenStats: 'Token Statistics', + dailyDetail: 'Daily Detail', + modelStats: 'Model Usage Statistics', + // Table headers + date: 'Date', + model: 'Model', + requests: 'Requests', + inputTokens: 'Input Tokens', + outputTokens: 'Output Tokens', + cacheCreationTokens: 'Cache Creation', + cacheReadTokens: 'Cache Read', + cacheWriteTokens: 'Cache Write', + totalTokens: 'Total Tokens', + cost: 'Cost', + // Status + quotaMode: 'Key Quota Mode', + walletBalance: 'Wallet Balance', + // Ring card titles + totalQuota: 'Total Quota', + limit5h: '5-Hour Limit', + limitDaily: 'Daily Limit', + limit7d: '7-Day Limit', + limitWeekly: 'Weekly Limit', + limitMonthly: 'Monthly Limit', + // Detail rows + remainingQuota: 'Remaining Quota', + expiresAt: 'Expires At', + todayExpires: '(expires today)', + daysLeft: '({days} days)', + usedQuota: 'Used Quota', + resetNow: 'Resetting soon', + subscriptionType: 'Subscription Type', + subscriptionExpires: 'Subscription Expires', + // Usage stat cells + todayRequests: 'Today Requests', + todayInputTokens: 'Today Input', + todayOutputTokens: 'Today Output', + todayTokens: 'Today Tokens', + todayCacheCreation: 'Today Cache Creation', + todayCacheRead: 'Today Cache Read', + todayCost: 'Today Cost', + rpmTpm: 'RPM / TPM', + totalRequests: 'Total Requests', + totalInputTokens: 'Total Input', + totalOutputTokens: 'Total Output', + totalTokensLabel: 'Total Tokens', + totalCacheCreation: 'Total Cache Creation', + totalCacheRead: 'Total Cache Read', + totalCost: 'Total Cost', + avgDuration: 'Avg Duration', + // Messages + enterApiKey: 'Please enter an API Key', + querySuccess: 'Query successful', + queryFailed: 'Query failed', + queryFailedRetry: 'Query failed, please try again later', + noDailyUsage: 'No daily usage data', + }, + + // Setup Wizard + setup: { + title: 'Sub2API Setup', + description: 'Configure your Sub2API instance', + database: { + title: 'Database Configuration', + description: 'Connect to your PostgreSQL database', + host: 'Host', + port: 'Port', + username: 'Username', + password: 'Password', + databaseName: 'Database Name', + sslMode: 'SSL Mode', + passwordPlaceholder: 'Password', + ssl: { + disable: 'Disable', + require: 'Require', + verifyCa: 'Verify CA', + verifyFull: 'Verify Full' + } + }, + redis: { + title: 'Redis Configuration', + description: 'Connect to your Redis server', + host: 'Host', + port: 'Port', + password: 'Password (optional)', + database: 'Database', + passwordPlaceholder: 'Password', + enableTls: 'Enable TLS', + enableTlsHint: 'Use TLS when connecting to Redis (public CA certs)' + }, + admin: { + title: 'Admin Account', + description: 'Create your administrator account', + email: 'Email', + password: 'Password', + confirmPassword: 'Confirm Password', + passwordPlaceholder: 'Min 8 characters', + confirmPasswordPlaceholder: 'Confirm password', + passwordMismatch: 'Passwords do not match' + }, + ready: { + title: 'Ready to Install', + description: 'Review your configuration and complete setup', + database: 'Database', + redis: 'Redis', + adminEmail: 'Admin Email' + }, + status: { + testing: 'Testing...', + success: 'Connection Successful', + testConnection: 'Test Connection', + installing: 'Installing...', + completeInstallation: 'Complete Installation', + completed: 'Installation completed!', + redirecting: 'Redirecting to login page...', + restarting: 'Service is restarting, please wait...', + timeout: 'Service restart is taking longer than expected. Please refresh the page manually.' + } + }, + + // Common +} diff --git a/frontend/src/i18n/locales/en/misc.ts b/frontend/src/i18n/locales/en/misc.ts new file mode 100644 index 0000000000..e378a46cf3 --- /dev/null +++ b/frontend/src/i18n/locales/en/misc.ts @@ -0,0 +1,591 @@ +export default { + + // Subscription Progress (Header component) + subscriptionProgress: { + title: 'My Subscriptions', + viewDetails: 'View subscription details', + activeCount: '{count} active subscription(s)', + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + daysRemaining: '{days} days left', + expired: 'Expired', + expiresToday: 'Expires today', + expiresTomorrow: 'Expires tomorrow', + viewAll: 'View all subscriptions', + noSubscriptions: 'No active subscriptions', + unlimited: 'Unlimited' + }, + + // Version Badge + version: { + currentVersion: 'Current Version', + latestVersion: 'Latest Version', + upToDate: "You're running the latest version.", + updateAvailable: 'A new version is available!', + releaseNotes: 'Release Notes', + noReleaseNotes: 'No release notes', + viewUpdate: 'View Update', + viewRelease: 'View Release', + viewChangelog: 'View Changelog', + refresh: 'Refresh', + sourceMode: 'Source Build', + sourceModeHint: 'Source build, use git pull to update', + updateNow: 'Update Now', + updating: 'Updating...', + updateComplete: 'Update Complete', + updateFailed: 'Update Failed', + restartRequired: 'Please restart the service to apply the update', + restartNow: 'Restart Now', + restarting: 'Restarting...', + retry: 'Retry' + }, + + // Recharge / Subscription Page + purchase: { + title: 'Recharge / Subscription', + description: 'Recharge balance or purchase subscription via the embedded page', + openInNewTab: 'Open in new tab', + notEnabledTitle: 'Feature not enabled', + notEnabledDesc: 'The administrator has not enabled the recharge/subscription entry. Please contact admin.', + notConfiguredTitle: 'Recharge / Subscription URL not configured', + notConfiguredDesc: + 'The administrator enabled the entry but has not configured a recharge/subscription URL. Please contact admin.' + }, + + // Custom Page (iframe embed) + customPage: { + title: 'Custom Page', + openInNewTab: 'Open in new tab', + notFoundTitle: 'Page not found', + notFoundDesc: 'This custom page does not exist or has been removed.', + notConfiguredTitle: 'Page URL not configured', + notConfiguredDesc: 'The URL for this custom page has not been properly configured.', + tableOfContents: 'Contents', + copyCode: 'Copy', + copiedCode: 'Copied', + copyCodeFailed: 'Failed' + }, + + // Announcements Page + announcements: { + title: 'Announcements', + description: 'View system announcements', + unreadOnly: 'Show unread only', + markRead: 'Mark as read', + markAllRead: 'Mark all as read', + viewAll: 'View all announcements', + markedAsRead: 'Marked as read', + allMarkedAsRead: 'All announcements marked as read', + newCount: '{count} new announcement | {count} new announcements', + readAt: 'Read at', + read: 'Read', + unread: 'Unread', + startsAt: 'Starts at', + endsAt: 'Ends at', + empty: 'No announcements', + emptyUnread: 'No unread announcements', + total: 'announcements', + emptyDescription: 'There are no system announcements at this time', + readStatus: 'You have read this announcement', + markReadHint: 'Click "Mark as read" to mark this announcement' + }, + + // User Subscriptions Page + userSubscriptions: { + title: 'My Subscriptions', + description: 'View your subscription plans and usage', + noActiveSubscriptions: 'No Active Subscriptions', + noActiveSubscriptionsDesc: + "You don't have any active subscriptions. Contact administrator to get one.", + failedToLoad: 'Failed to load subscriptions', + status: { + active: 'Active', + expired: 'Expired', + revoked: 'Revoked' + }, + usage: 'Usage', + expires: 'Expires', + noExpiration: 'No expiration', + unlimited: 'Unlimited', + unlimitedDesc: 'No usage limits on this subscription', + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + daysRemaining: '{days} days remaining', + expiresOn: 'Expires on {date}', + resetIn: 'Resets in {time}', + quotaEndsIn: 'Quota ends in {time}', + windowNotActive: 'Awaiting first use', + usageOf: '{used} of {limit}' + }, + + // Onboarding Tour + onboarding: { + restartTour: 'Restart Onboarding Tour', + dontShowAgain: "Don't show again", + dontShowAgainTitle: 'Permanently close onboarding guide', + confirmDontShow: "Are you sure you don't want to see the onboarding guide again?\n\nYou can restart it anytime from the user menu in the top right corner.", + confirmExit: 'Are you sure you want to exit the onboarding guide? You can restart it anytime from the top right menu.', + interactiveHint: 'Press Enter or Click to continue', + navigation: { + flipPage: 'Flip Page', + exit: 'Exit' + }, + // Admin tour steps + admin: { + welcome: { + title: '👋 Welcome to Sub2API', + description: '

Sub2API is a powerful AI service gateway platform that helps you easily manage and distribute AI services.

🎯 Core Features:

  • 📦 Group Management - Create service tiers (VIP, Free Trial, etc.)
  • 🔗 Account Pool - Connect multiple upstream AI service accounts
  • 🔑 Key Distribution - Generate independent API Keys for users
  • 💰 Billing Control - Flexible rate and quota management

Let\'s complete the initial setup in 3 minutes →

', + nextBtn: 'Start Setup 🚀', + prevBtn: 'Skip' + }, + groupManage: { + title: '📦 Step 1: Group Management', + description: '

What is a Group?

Groups are the core concept of Sub2API, like a "service package":

  • 🎯 Each group can contain multiple upstream accounts
  • 💰 Each group has independent billing multiplier
  • 👥 Can be set as public or exclusive

💡 Example: You can create "VIP Premium" (high rate) and "Free Trial" (low rate) groups

👉 Click "Group Management" on the left sidebar

' + }, + createGroup: { + title: '➕ Create New Group', + description: '

Let\'s create your first group.

📝 Tip: Recommend creating a test group first to familiarize yourself with the process

👉 Click the "Create Group" button

' + }, + groupName: { + title: '✏️ 1. Group Name', + description: '

Give your group an easy-to-identify name.

💡 Naming Suggestions:
  • "Test Group" - For testing
  • "VIP Premium" - High-quality service
  • "Free Trial" - Trial version

Click "Next" when done

', + nextBtn: 'Next' + }, + groupPlatform: { + title: '🤖 2. Select Platform', + description: '

Choose the AI platform this group supports.

📌 Platform Guide:
  • Anthropic - Claude models
  • OpenAI - GPT models
  • Google - Gemini models

One group can only have one platform

', + nextBtn: 'Next' + }, + groupMultiplier: { + title: '💰 3. Rate Multiplier', + description: '

Set the billing multiplier to control user charges.

⚙️ Billing Rules:
  • 1.0 - Original price (cost price)
  • 1.5 - User consumes $1, charged $1.5
  • 2.0 - User consumes $1, charged $2
  • 0.8 - Subsidy mode (loss-making)

Recommend setting test group to 1.0

', + nextBtn: 'Next' + }, + groupExclusive: { + title: '🔒 4. Exclusive Group (Optional)', + description: '

Control group visibility and access permissions.

🔐 Permission Guide:
  • Off - Public group, visible to all users
  • On - Exclusive group, only for specified users

💡 Use Cases: VIP exclusive, internal testing, special customers

', + nextBtn: 'Next' + }, + groupSubmit: { + title: '✅ Save Group', + description: '

Confirm the information and click create to save the group.

⚠️ Note: Platform type cannot be changed after creation, but other settings can be edited anytime

📌 Next Step: After creation, we\'ll add upstream accounts to this group

👉 Click "Create" button

' + }, + accountManage: { + title: '🔗 Step 2: Add Account', + description: '

Great! Group created successfully 🎉

Now add upstream AI service accounts to enable actual service delivery.

🔑 Account Purpose:
  • Connect to upstream AI services (Claude, GPT, etc.)
  • One group can contain multiple accounts (load balancing)
  • Supports OAuth and Session Key methods

👉 Click "Account Management" on the left sidebar

' + }, + createAccount: { + title: '➕ Add New Account', + description: '

Click the button to start adding your first upstream account.

💡 Tip: Recommend using OAuth method - more secure and no manual key extraction needed

👉 Click "Add Account" button

' + }, + accountName: { + title: '✏️ 1. Account Name', + description: '

Set an easy-to-identify name for the account.

💡 Naming Suggestions: "Claude Main", "GPT Backup 1", "Test Account", etc.

', + nextBtn: 'Next' + }, + accountPlatform: { + title: '🤖 2. Select Platform', + description: '

Choose the service provider platform for this account.

⚠️ Important: Platform must match the group you just created

', + nextBtn: 'Next' + }, + accountType: { + title: '🔐 3. Authorization Method', + description: '

Choose the account authorization method.

✅ Recommended: OAuth Method
  • No manual key extraction needed
  • More secure with auto-refresh support
  • Works with Claude Code, ChatGPT OAuth
📌 Session Key Method
  • Requires manual extraction from browser
  • May need periodic updates
  • For platforms without OAuth support
', + nextBtn: 'Next' + }, + accountPriority: { + title: '⚖️ 4. Priority (Optional)', + description: '

Set the account call priority.

📊 Priority Rules:
  • Lower number = higher priority
  • System uses low-value accounts first
  • Same priority = random selection

💡 Use Case: Set main account to lower value, backup accounts to higher value

', + nextBtn: 'Next' + }, + accountGroups: { + title: '🎯 5. Assign Groups', + description: '

Key Step! Assign the account to the group you just created.

⚠️ Important Reminder:
  • Must select at least one group
  • Unassigned accounts cannot be used
  • One account can be assigned to multiple groups

💡 Tip: Select the test group you just created

', + nextBtn: 'Next' + }, + accountSubmit: { + title: '✅ Save Account', + description: '

Confirm the information and click save.

📌 OAuth Flow:
  • Will redirect to service provider page after clicking save
  • Complete login and authorization on provider page
  • Auto-return after successful authorization

📌 Next Step: After adding account, we\'ll create an API key

👉 Click "Save" button

' + }, + keyManage: { + title: '🔑 Step 3: Generate Key', + description: '

Congratulations! Account setup complete 🎉

Final step: generate an API Key to test if the service works properly.

🔑 API Key Purpose:
  • Credential for calling AI services
  • Each key is bound to one group
  • Can set quota and expiration
  • Supports independent usage statistics

👉 Click "API Keys" on the left sidebar

' + }, + createKey: { + title: '➕ Create Key', + description: '

Click the button to create your first API Key.

💡 Tip: Copy and save immediately after creation - key is only shown once

👉 Click "Create Key" button

' + }, + keyName: { + title: '✏️ 1. Key Name', + description: '

Set an easy-to-manage name for the key.

💡 Naming Suggestions: "Test Key", "Production", "Mobile", etc.

', + nextBtn: 'Next' + }, + keyGroup: { + title: '🎯 2. Select Group', + description: '

Select the group you just configured.

📌 Group Determines:
  • Which accounts this key can use
  • What billing multiplier applies
  • Whether it\'s an exclusive key

💡 Tip: Select the test group you just created

', + nextBtn: 'Next' + }, + keySubmit: { + title: '🎉 Generate and Copy', + description: '

System will generate a complete API Key after clicking create.

⚠️ Important Reminder:
  • Key is only shown once, copy immediately
  • Need to regenerate if lost
  • Keep it safe, don\'t share with others
🚀 Next Steps:
  • Copy the generated sk-xxx key
  • Use in any OpenAI-compatible client
  • Start experiencing AI services!

👉 Click "Create" button

' + } + }, + // User tour steps + user: { + welcome: { + title: '👋 Welcome to Sub2API', + description: '

Hello! Welcome to the Sub2API AI service platform.

🎯 Quick Start:

  • 🔑 Create API Key
  • 📋 Copy key to your application
  • 🚀 Start using AI services

Just 1 minute, let\'s get started →

', + nextBtn: 'Start 🚀', + prevBtn: 'Skip' + }, + keyManage: { + title: '🔑 API Key Management', + description: '

Manage all your API access keys here.

📌 What is an API Key?
An API key is your credential for accessing AI services, like a key that allows your application to call AI capabilities.

👉 Click to enter key page

' + }, + createKey: { + title: '➕ Create New Key', + description: '

Click the button to create your first API key.

💡 Tip: Key is only shown once after creation, make sure to copy and save

👉 Click "Create Key"

' + }, + keyName: { + title: '✏️ Key Name', + description: '

Give your key an easy-to-identify name.

💡 Examples: "My First Key", "For Testing", etc.

', + nextBtn: 'Next' + }, + keyGroup: { + title: '🎯 Select Group', + description: '

Select the service group assigned by the administrator.

📌 Group Info:
Different groups may have different service quality and billing rates, choose according to your needs.

', + nextBtn: 'Next' + }, + keySubmit: { + title: '🎉 Complete Creation', + description: '

Click to confirm and create your API key.

⚠️ Important:
  • Copy the key (sk-xxx) immediately after creation
  • Key is only shown once, need to regenerate if lost

🚀 How to Use:
Configure the key in any OpenAI-compatible client (like ChatBox, OpenCat, etc.) and start using!

👉 Click "Create" button

' + } + } + }, + + // Payment System + payment: { + title: 'Recharge / Subscription', + amountLabel: 'Amount', + paymentAmount: 'Payment Amount', + creditedBalance: 'Credited Balance', + quickAmounts: 'Quick Amounts', + customAmount: 'Custom Amount', + enterAmount: 'Enter amount', + paymentMethod: 'Payment Method', + fee: 'Fee', + actualPay: 'Actual Payment', + createOrder: 'Confirm Payment', + methods: { + easypay: 'EasyPay', + alipay: 'Alipay', + wxpay: 'WeChat Pay', + stripe: 'Stripe', + airwallex: 'Airwallex', + card: 'Card', + link: 'Link', + alipay_direct: 'Alipay (Direct)', + wxpay_direct: 'WeChat Pay (Direct)', + }, + status: { + pending: 'Pending', + paid: 'Paid', + recharging: 'Recharging', + completed: 'Completed', + expired: 'Expired', + cancelled: 'Cancelled', + failed: 'Failed', + refund_requested: 'Refund Requested', + refunding: 'Refunding', + refund_pending: 'Refund Pending', + refunded: 'Refunded', + partially_refunded: 'Partially Refunded', + refund_failed: 'Refund Failed', + }, + qr: { + scanToPay: 'Scan to Pay', + scanAlipay: 'Alipay QR Payment', + scanWxpay: 'WeChat QR Payment', + scanAlipayHint: 'Open Alipay on your phone and scan the QR code to pay', + scanWxpayHint: 'Open WeChat on your phone and scan the QR code to pay', + payInNewWindow: 'Complete Payment in New Window', + payInNewWindowHint: 'The payment page has opened in a new window. Please complete the payment there and return to this page.', + openPayWindow: 'Reopen Payment Page', + expiresIn: 'Expires in', + expired: 'Order Expired', + expiredDesc: 'This order has expired. Please create a new one.', + cancelled: 'Order Cancelled', + cancelledDesc: 'You have cancelled this payment.', + waitingPayment: 'Waiting for payment...', + cancelOrder: 'Cancel Order', + }, + orders: { + title: 'My Orders', + empty: 'No orders yet', + orderId: 'Order ID', + orderNo: 'Order No.', + amount: 'Amount', + payAmount: 'Paid', + creditedAmount: 'Credited Amount', + fee: 'Fee', + baseAmount: 'Base Amount', + includedInPayAmount: 'included in paid amount', + status: 'Status', + paymentMethod: 'Payment Method', + createdAt: 'Created', + cancel: 'Cancel Order', + userId: 'User ID', + orderType: 'Order Type', + actions: 'Actions', + requestRefund: 'Request Refund', + }, + result: { + success: 'Payment Successful', + subscriptionSuccess: 'Subscription Successful', + processing: 'Payment Processing', + processingHint: 'Payment confirmation is still pending. This page will refresh automatically.', + failed: 'Payment Failed', + backToRecharge: 'Back to Recharge', + viewOrders: 'View Orders', + }, + currentBalance: 'Current Balance', + groupFallback: 'Group #{id}', + rechargeAccount: 'Recharge Account', + activeSubscription: 'Active Subscription', + noActiveSubscription: 'No active subscription', + tabTopUp: 'Top Up', + tabSubscribe: 'Subscribe', + noPlans: 'No subscription plans available', + notAvailable: 'Top-up is currently unavailable', + confirmSubscription: 'Confirm Subscription', + confirmCancel: 'Are you sure you want to cancel this order?', + amountTooLow: 'Minimum amount is {min}', + amountTooHigh: 'Maximum amount is {max}', + amountNoMethod: 'No payment method available for this amount', + rechargeRatePreview: 'Current rate: 1 CNY = {usd} USD', + refundReason: 'Refund Reason', + refundReasonPlaceholder: 'Please describe your refund reason', + stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.', + stripeMissingParams: 'Missing order ID or client secret', + stripeNotConfigured: 'Stripe is not configured', + airwallexLoadFailed: 'Failed to load Airwallex payment component. Please refresh and try again.', + airwallexMissingParams: 'Missing Airwallex payment parameters', + errors: { + tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.', + cancelRateLimited: 'Too many cancellations. Please try again later.', + wechatH5NotAuthorized: 'This merchant has not enabled WeChat H5 payment. Open this page in WeChat to continue.', + wechatPaymentMpNotConfigured: 'This site has not completed WeChat MP/JSAPI payment setup, so in-app WeChat payment is unavailable right now.', + wechatJsapiUnavailable: 'WeChat payment could not be invoked in the current environment. Reopen this page inside WeChat and try again.', + wechatJsapiFailed: 'WeChat payment did not complete. Try invoking it again or switch to QR payment.', + wechatUnavailable: 'WeChat payment is temporarily unavailable. Please try again later.', + wechatOpenInWeChatHint: 'Open the current page inside WeChat, or switch to desktop WeChat QR payment.', + wechatScanOnDesktopHint: 'On desktop, use WeChat Scan to pay; on mobile, reopen the current page inside WeChat.', + wechatSwitchBrowserHint: 'Switch to desktop WeChat QR payment, or reopen this page in an external browser and retry.', + mobilePaymentFallbackToQr: 'This merchant has not enabled mobile payment. The flow has been switched to QR payment automatically.', + alipayDesktopUnavailable: 'The desktop Alipay flow could not generate a QR code.', + alipayDesktopQrHint: 'Desktop Alipay should render a QR code. Refresh and retry, or make sure the payment page was not blocked.', + alipayMobileUnavailable: 'This page could not hand off to Alipay.', + alipayMobileOpenHint: 'Allow the current page to open the Alipay app, or retry from the system browser.', + // Structured error codes (reason strings from backend ApplicationError) + PAYMENT_DISABLED: 'Payment system is disabled.', + USER_INACTIVE: 'Your account is disabled.', + BALANCE_PAYMENT_DISABLED: 'Balance recharge has been disabled.', + INVALID_AMOUNT: 'Invalid amount.', + INVALID_INPUT: 'Invalid request.', + PLAN_NOT_AVAILABLE: 'Plan not found or no longer available.', + GROUP_NOT_FOUND: 'Subscription group is no longer available.', + GROUP_TYPE_MISMATCH: 'Group is not a subscription type.', + TOO_MANY_PENDING: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.', + DAILY_LIMIT_EXCEEDED: 'Daily recharge limit reached. Remaining: {remaining}.', + PAYMENT_GATEWAY_ERROR: 'Payment method is unavailable.', + NO_AVAILABLE_INSTANCE: 'No payment channel available right now.', + PAYMENT_PROVIDER_MISCONFIGURED: 'Payment provider misconfigured. Please contact an administrator.', + WXPAY_CONFIG_MISSING_KEY: 'WeChat Pay config missing required key: {key}.', + WXPAY_CONFIG_INVALID_KEY_LENGTH: 'WeChat Pay {key} length is invalid (expected {expected} bytes, got {actual}).', + WXPAY_CONFIG_INVALID_KEY: 'WeChat Pay {key} is malformed. Make sure you copied the full PEM content.', + PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.', + PAYMENT_PROVIDER_CONFLICT: 'Another enabled provider instance is already serving this payment method. Disable it before continuing.', + CANCEL_RATE_LIMITED: 'Too many cancellations. Please try again later.', + NOT_FOUND: 'Order not found.', + FORBIDDEN: 'No permission for this order.', + CONFLICT: 'Order status has changed. Please refresh.', + INVALID_ORDER_TYPE: 'Only balance orders can request a refund.', + INVALID_STATUS: 'The current order status does not allow this operation.', + BALANCE_NOT_ENOUGH: 'Refund amount exceeds balance.', + REFUND_AMOUNT_EXCEEDED: 'Refund amount exceeds the recharge amount.', + REFUND_FAILED: 'Refund failed.', + }, + airwallexPay: 'Airwallex Payment', + stripePay: 'Pay Now', + stripeSuccessProcessing: 'Payment successful, processing your order...', + stripePopup: { + redirecting: 'Redirecting to payment page...', + loadingQr: 'Loading WeChat Pay QR code...', + timeout: 'Timed out waiting for payment credentials, please retry', + qrFailed: 'Failed to get WeChat Pay QR code', + }, + subscribeNow: 'Subscribe Now', + renewNow: 'Renew', + selectPlan: 'Select Plan', + planFeatures: 'Features', + planCard: { + rate: 'Rate', + peakRate: 'Peak Rate', + dailyLimit: 'Daily', + weeklyLimit: 'Weekly', + monthlyLimit: 'Monthly', + quota: 'Quota', + unlimited: 'Unlimited', + models: 'Models', + }, + days: 'days', + months: 'months', + years: 'years', + oneMonth: '1 Month', + oneYear: '1 Year', + perMonth: 'month', + perYear: 'year', + admin: { + tabs: { + overview: 'Overview', + orders: 'Orders', + channels: 'Channels', + plans: 'Plans', + }, + todayRevenue: 'Today Revenue', + totalRevenue: 'Total Revenue', + todayOrders: 'Today Orders', + orderCount: 'Order Count', + avgAmount: 'Average Amount', + revenue: 'Revenue', + dailyRevenue: 'Daily Revenue', + paymentDistribution: 'Payment Distribution', + colUser: 'User', + topUsers: 'Top Users', + noData: 'No data', + days: 'days', + weeks: 'weeks', + months: 'months', + searchOrders: 'Search orders...', + allStatuses: 'All Statuses', + allPaymentTypes: 'All Payment Types', + allOrderTypes: 'All Order Types', + orderDetail: 'Order Detail', + orderType: 'Order Type', + orders: 'Orders', + balanceOrder: 'Balance Top-Up', + subscriptionOrder: 'Subscription', + paidAt: 'Paid At', + completedAt: 'Completed At', + expiresAt: 'Expires At', + feeRate: 'Fee Rate', + refund: 'Refund', + refundOrder: 'Refund Order', + refundAmount: 'Refund Amount', + maxRefundable: 'Max Refundable', + refundReason: 'Refund Reason', + refundReasonPlaceholder: 'Please enter refund reason', + confirmRefund: 'Confirm Refund', + refundSuccess: 'Refund successful', + refundPending: 'Refund pending gateway confirmation', + queryRefundStatus: 'Query refund status', + refundInfo: 'Refund Info', + refundEnabled: 'Refund Enabled', + allowUserRefund: 'Allow User Refund', + alreadyRefunded: 'Already Refunded', + deductBalance: 'Deduct Balance', + deductBalanceHint: 'Subtract recharged amount from user balance', + userBalance: 'User Balance', + orderAmount: 'Order Amount', + insufficientBalance: 'Insufficient balance — will deduct to $0', + noDeduction: 'Will NOT deduct user balance', + forceRefund: 'Force refund (ignore balance check)', + orderCancelled: 'Order Cancelled', + retry: 'Retry', + retrySuccess: 'Retry successful', + approveRefund: 'Approve Refund', + retryRefund: 'Retry Refund', + refundRequestInfo: 'Refund Request Info', + refundRequestedAt: 'Requested At', + refundRequestedBy: 'Requested By', + refundRequestReason: 'Request Reason', + auditLogs: 'Audit Logs', + operator: 'Operator', + channelName: 'Channel Name', + channelDescription: 'Channel Description', + createChannel: 'Create Channel', + editChannel: 'Edit Channel', + deleteChannel: 'Delete Channel', + deleteChannelConfirm: 'Are you sure you want to delete this channel?', + planName: 'Plan Name', + planDescription: 'Plan Description', + createPlan: 'Create Plan', + editPlan: 'Edit Plan', + deletePlan: 'Delete Plan', + deletePlanConfirm: 'Are you sure you want to delete this plan?', + originalPrice: 'Original Price', + price: 'Price', + subscriptionCnyPayPreview: 'CNY channel charge preview: {amount}', + subscriptionCnyPayPreviewWithFee: '({feeRate}% fee included: {total})', + validityDays: 'Validity (days)', + validityUnit: 'Validity Unit', + sortOrder: 'Sort Order', + forSale: 'For Sale', + onSale: 'On Sale', + offSale: 'Off Sale', + group: 'Group', + groupId: 'Group ID', + features: 'Features', + featuresHint: 'One feature per line', + featuresPlaceholder: 'Enter plan features...', + providerManagement: 'Provider Management', + providerManagementDesc: 'Manage payment provider instances', + createProvider: 'Create Provider', + editProvider: 'Edit Provider', + deleteProvider: 'Delete Provider', + deleteProviderConfirm: 'Are you sure you want to delete this provider?', + providerName: 'Provider Name', + providerKey: 'Provider Key', + selectProviderKey: 'Select Provider Key', + providerConfig: 'Provider Config', + noProviders: 'No providers configured', + noProvidersHint: 'Create a provider instance to start accepting payments', + supportedTypes: 'Supported Payment Types', + supportedTypesHint: 'Select the payment types this provider supports', + rateMultiplier: 'Rate Multiplier', + dashboardTitle: 'Payment Dashboard', + dashboardDesc: 'Recharge order analytics and insights', + daySuffix: 'd', + paymentConfigTitle: 'Payment Config', + paymentConfigDesc: 'Configure payment providers and settings', + plansPageTitle: 'Subscription Plans', + plansPageDesc: 'Manage subscription plan configuration', + tabPlanConfig: 'Plan Configuration', + tabUserSubs: 'User Subscriptions', + selectGroup: 'Select a group', + groupRequired: 'Please select a subscription group', + priceRequired: 'Price must be greater than 0', + validityDaysRequired: 'Validity days must be greater than 0', + groupMissing: 'Missing', + groupInfo: 'Group Info', + platform: 'Platform', + rateMultiplierLabel: 'Rate', + dailyLimit: 'Daily Limit', + weeklyLimit: 'Weekly Limit', + monthlyLimit: 'Monthly Limit', + unlimited: 'Unlimited', + searchUserSubs: 'Search user subscriptions...', + daily: 'D', + weekly: 'W', + monthly: 'M', + subsStatus: { + active: 'Active', + expired: 'Expired', + revoked: 'Revoked', + }, + }, + }, + +} diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts deleted file mode 100644 index d57df8c01f..0000000000 --- a/frontend/src/i18n/locales/zh.ts +++ /dev/null @@ -1,7731 +0,0 @@ -export default { - batchImageGuide: { - title: '图片批量生成', - description: '一次提交多条提示词,任务完成后可统一下载图片结果' - }, - // Home Page - home: { - viewOnGithub: '在 GitHub 上查看', - viewDocs: '查看文档', - docs: '文档', - switchToLight: '切换到浅色模式', - switchToDark: '切换到深色模式', - dashboard: '控制台', - login: '登录', - getStarted: '立即开始', - goToDashboard: '进入控制台', - // 新增:面向用户的价值主张 - heroSubtitle: '一个密钥,畅用多个 AI 模型', - heroDescription: '无需管理多个订阅账号,一站式接入 Claude、GPT、Gemini 等主流 AI 服务', - tags: { - subscriptionToApi: '订阅转 API', - stickySession: '会话保持', - realtimeBilling: '按量计费' - }, - // 用户痛点区块 - painPoints: { - title: '你是否也遇到这些问题?', - items: { - expensive: { - title: '订阅费用高', - desc: '每个 AI 服务都要单独订阅,每月支出越来越多' - }, - complex: { - title: '多账号难管理', - desc: '不同平台的账号、密钥分散各处,管理起来很麻烦' - }, - unstable: { - title: '服务不稳定', - desc: '单一账号容易触发限制,影响正常使用' - }, - noControl: { - title: '用量无法控制', - desc: '不知道钱花在哪了,也无法限制团队成员的使用' - } - } - }, - // 解决方案区块 - solutions: { - title: '我们帮你解决', - subtitle: '简单三步,开始省心使用 AI' - }, - features: { - unifiedGateway: '一键接入', - unifiedGatewayDesc: '获取一个 API 密钥,即可调用所有已接入的 AI 模型,无需分别申请。', - multiAccount: '稳定可靠', - multiAccountDesc: '智能调度多个上游账号,自动切换和负载均衡,告别频繁报错。', - balanceQuota: '用多少付多少', - balanceQuotaDesc: '按实际使用量计费,支持设置配额上限,团队用量一目了然。' - }, - // 优势对比 - comparison: { - title: '为什么选择我们?', - headers: { - feature: '对比项', - official: '官方订阅', - us: '本平台' - }, - items: { - pricing: { - feature: '付费方式', - official: '固定月费,用不完也付', - us: '按量付费,用多少付多少' - }, - models: { - feature: '模型选择', - official: '单一服务商', - us: '多模型随意切换' - }, - management: { - feature: '账号管理', - official: '每个服务单独管理', - us: '统一密钥,一站管理' - }, - stability: { - feature: '服务稳定性', - official: '单账号易触发限制', - us: '多账号池,自动切换' - }, - control: { - feature: '用量控制', - official: '无法限制', - us: '可设配额、查明细' - } - } - }, - providers: { - title: '已支持的 AI 模型', - description: '一个 API,多种选择', - supported: '已支持', - soon: '即将推出', - claude: 'Claude', - gemini: 'Gemini', - antigravity: 'Antigravity', - more: '更多' - }, - // CTA 区块 - cta: { - title: '准备好开始了吗?', - description: '注册即可获得免费试用额度,体验一站式 AI 服务', - button: '免费注册' - }, - footer: { - allRightsReserved: '保留所有权利。' - } - }, - - // Key Usage Query Page - keyUsage: { - title: 'API Key 用量查询', - subtitle: '输入您的 API Key 以查看实时消费金额与使用状态', - placeholder: 'sk-ant-mirror-xxxxxxxxxxxx', - query: '查询', - querying: '查询中...', - privacyNote: '您的 Key 仅在浏览器本地处理,不会被存储', - dateRange: '统计范围:', - dateRangeToday: '今日', - dateRange7d: '7 天', - dateRange30d: '30 天', - dateRange90d: '90 天', - dateRangeCustom: '自定义', - apply: '应用', - used: '已使用', - detailInfo: '详细信息', - tokenStats: 'Token 统计', - dailyDetail: '按日明细', - modelStats: '模型用量统计', - // Table headers - date: '日期', - model: '模型', - requests: '请求数', - inputTokens: '输入 Tokens', - outputTokens: '输出 Tokens', - cacheCreationTokens: '缓存创建', - cacheReadTokens: '缓存读取', - cacheWriteTokens: '缓存写入', - totalTokens: '总 Tokens', - cost: '费用', - // Status - quotaMode: 'Key 限额模式', - walletBalance: '钱包余额', - // Ring card titles - totalQuota: '总额度', - limit5h: '5 小时限额', - limitDaily: '日限额', - limit7d: '7 天限额', - limitWeekly: '周限额', - limitMonthly: '月限额', - // Detail rows - remainingQuota: '剩余额度', - expiresAt: '过期时间', - todayExpires: '(今日到期)', - daysLeft: '({days} 天)', - usedQuota: '已用额度', - resetNow: '即将重置', - subscriptionType: '订阅类型', - subscriptionExpires: '订阅到期', - // Usage stat cells - todayRequests: '今日请求', - todayInputTokens: '今日输入', - todayOutputTokens: '今日输出', - todayTokens: '今日 Tokens', - todayCacheCreation: '今日缓存创建', - todayCacheRead: '今日缓存读取', - todayCost: '今日费用', - rpmTpm: 'RPM / TPM', - totalRequests: '累计请求', - totalInputTokens: '累计输入', - totalOutputTokens: '累计输出', - totalTokensLabel: '累计 Tokens', - totalCacheCreation: '累计缓存创建', - totalCacheRead: '累计缓存读取', - totalCost: '累计费用', - avgDuration: '平均耗时', - // Messages - enterApiKey: '请输入 API Key', - querySuccess: '查询成功', - queryFailed: '查询失败', - queryFailedRetry: '查询失败,请稍后重试', - noDailyUsage: '暂无按日用量数据', - }, - - // Setup Wizard - setup: { - title: 'Sub2API 安装向导', - description: '配置您的 Sub2API 实例', - database: { - title: '数据库配置', - description: '连接到您的 PostgreSQL 数据库', - host: '主机', - port: '端口', - username: '用户名', - password: '密码', - databaseName: '数据库名称', - sslMode: 'SSL 模式', - passwordPlaceholder: '密码', - ssl: { - disable: '禁用', - require: '要求', - verifyCa: '验证 CA', - verifyFull: '完全验证' - } - }, - redis: { - title: 'Redis 配置', - description: '连接到您的 Redis 服务器', - host: '主机', - port: '端口', - password: '密码(可选)', - database: '数据库', - passwordPlaceholder: '密码', - enableTls: '启用 TLS', - enableTlsHint: '连接 Redis 时使用 TLS(公共 CA 证书)' - }, - admin: { - title: '管理员账户', - description: '创建您的管理员账户', - email: '邮箱', - password: '密码', - confirmPassword: '确认密码', - passwordPlaceholder: '至少 8 个字符', - confirmPasswordPlaceholder: '确认密码', - passwordMismatch: '密码不匹配' - }, - ready: { - title: '准备安装', - description: '检查您的配置并完成安装', - database: '数据库', - redis: 'Redis', - adminEmail: '管理员邮箱' - }, - status: { - testing: '测试中...', - success: '连接成功', - testConnection: '测试连接', - installing: '安装中...', - completeInstallation: '完成安装', - completed: '安装完成!', - redirecting: '正在跳转到登录页面...', - restarting: '服务正在重启,请稍候...', - timeout: '服务重启时间超出预期,请手动刷新页面。' - } - }, - - // Common - common: { - loading: '加载中...', - submitting: '提交中...', - justNow: '刚刚', - peakRateTooltip: '高峰倍率:{window}', - peakRateImageNote: ';token 计费的图片 token 同样适用,图片按次计费不受高峰影响', - save: '保存', - saved: '保存成功', - deleted: '删除成功', - cancel: '取消', - delete: '删除', - edit: '编辑', - create: '创建', - update: '更新', - confirm: '确认', - reset: '重置', - search: '搜索', - filter: '筛选', - export: '导出', - import: '导入', - actions: '操作', - status: '状态', - name: '名称', - email: '邮箱', - password: '密码', - submit: '提交', - back: '返回', - next: '下一步', - yes: '是', - no: '否', - all: '全部', - none: '无', - selectAll: '全选', - noData: '暂无数据', - expand: '展开', - collapse: '收起', - success: '成功', - error: '错误', - critical: '严重', - warning: '警告', - info: '提示', - active: '启用', - inactive: '禁用', - more: '更多', - close: '关闭', - enabled: '已启用', - disabled: '已禁用', - total: '总计', - balance: '余额', - availableBalance: '可用余额', - frozenBalance: '冻结金额', - totalBalance: '总余额', - available: '可用', - copiedToClipboard: '已复制到剪贴板', - copied: '已复制', - copyFailed: '复制失败', - verifying: '验证中...', - processing: '处理中...', - contactSupport: '联系客服', - add: '添加', - invalidEmail: '请输入有效的邮箱地址', - optional: '可选', - selectOption: '请选择', - searchPlaceholder: '搜索...', - noOptionsFound: '无匹配选项', - noGroupsAvailable: '无可用分组', - unknownError: '发生未知错误', - saving: '保存中...', - selectedCount: '(已选 {count} 个)', - refresh: '刷新', - autoRefresh: { - title: '自动刷新', - enable: '启用自动刷新', - countdown: '自动刷新: {seconds}s', - seconds: '{n} 秒', - }, - view: '查看', - settings: '设置', - chooseFile: '选择文件', - copy: '复制', - notAvailable: '不可用', - now: '现在', - today: '今天', - tomorrow: '明天', - unknown: '未知', - minutes: '分钟', - time: { - never: '从未', - justNow: '刚刚', - minutesAgo: '{n}分钟前', - hoursAgo: '{n}小时前', - daysAgo: '{n}天前', - countdown: { - daysHours: '{d}d {h}h', - hoursMinutes: '{h}h {m}m', - minutes: '{m}m', - withSuffix: '{time} 后解除' - } - } - }, - - adminCompliance: { - title: '部署与运营合规确认', - blockingNotice: '继续使用控制台前,须完成部署与运营合规确认。', - riskNotice: '本确认用于以清晰、显著、可留痕的方式提示自部署实例的合规义务与运营风险。', - version: '协议版本', - openDocument: '在 GitHub 查看协议文件', - documentSource: '协议正文来自本项目仓库中的 Markdown 文件。修改协议内容时必须同步递增协议版本;已确认的旧版本将失效,控制台使用者须重新确认。', - inputLabel: '请逐字输入以下确认短语', - inputPlaceholder: '输入确认短语以继续', - inputMismatch: '确认短语不匹配,请逐字输入提示内容。', - legalNote: '本确认用于明确自部署实例与开源项目、著作权人、贡献者及维护者之间的非关联关系和责任边界;部署、运营或控制相关实例的主体应独立承担其适用义务。', - logout: '退出登录', - accept: '确认并继续', - accepted: '合规确认已记录', - acceptFailed: '提交确认失败' - }, - - legal: { - loadFailed: '文档加载失败', - retryLater: '请稍后刷新页面重试。', - notFound: '文档不存在', - notFoundDescription: '当前条款文档不存在或已被管理员移除。', - updatedAt: '更新日期:{date}', - empty: '暂无正文内容', - loginAgreement: '登录条款', - adminCompliance: '部署与运营合规承诺', - loginAgreementPrompt: { - checkboxPrefix: '我已阅读并同意', - documentSeparator: '、', - noticeTitle: '继续登录前需要先同意最新条款。', - noticeDescription: '未同意前,账号密码输入和快捷登录会保持禁用。', - viewTerms: '查看条款', - dialogTitle: '条款更新通知', - dialogDescription: '我们的服务条款已于 {date} 更新。在继续使用服务之前,请仔细阅读并同意以下条款。', - recently: '近期', - relatedDocuments: '相关文档', - reject: '拒绝', - accept: '同意并继续', - loginRejectedWarning: '未同意最新条款前,无法输入账号密码或使用快捷登录。', - loginRequiredWarning: '请先阅读并同意最新条款后再登录。', - registerRejectedWarning: '未同意最新条款前,无法注册或使用快捷登录。', - registerRequiredWarning: '请先阅读并同意最新条款后再注册。' - } - }, - - // Navigation - nav: { - dashboard: '仪表盘', - announcements: '公告', - apiKeys: 'API 密钥', - batchImage: '批量生图', - usage: '使用记录', - redeem: '兑换', - affiliate: '邀请返利', - affiliateManagement: '邀请返利', - affiliateInviteRecords: '邀请记录', - affiliateRebateRecords: '返利记录', - affiliateTransferRecords: '提取记录', - profile: '个人资料', - users: '用户管理', - groups: '分组管理', - channels: '渠道管理', - availableChannels: '可用渠道', - subscriptions: '订阅管理', - accounts: '账号管理', - proxies: 'IP管理', - redeemCodes: '兑换码', - ops: '运维监控', - promoCodes: '优惠码', - settings: '系统设置', - myAccount: '我的账户', - lightMode: '浅色模式', - darkMode: '深色模式', - collapse: '收起', - expand: '展开', - logout: '退出登录', - github: 'GitHub', - mySubscriptions: '我的订阅', - buySubscription: '充值/订阅', - docs: '文档', - myOrders: '我的订单', - orderManagement: '订单管理', - paymentDashboard: '支付概览', - paymentConfig: '支付配置', - paymentPlans: '订阅套餐', - channelManagement: '渠道管理', - channelPricing: '渠道定价', - channelMonitor: '渠道监控', - channelStatus: '渠道状态', - riskControl: '风控中心', - }, - - // Auth - auth: { - welcomeBack: '欢迎回来', - signInToAccount: '登录您的账户以继续', - signIn: '登录', - signingIn: '登录中...', - createAccount: '创建账户', - signUpToStart: '注册以开始使用 {siteName}', - signUp: '注册', - processing: '处理中...', - continue: '继续', - rememberMe: '记住我', - dontHaveAccount: '还没有账户?', - alreadyHaveAccount: '已有账户?', - registrationDisabled: '注册功能暂时关闭,请联系管理员。', - emailLabel: '邮箱', - emailPlaceholder: '请输入邮箱', - passwordLabel: '密码', - passwordPlaceholder: '请输入密码', - createPasswordPlaceholder: '创建一个安全的密码', - passwordHint: '至少 6 个字符', - emailRequired: '请输入邮箱', - invalidEmail: '请输入有效的邮箱地址', - passwordRequired: '请输入密码', - passwordMinLength: '密码至少需要 6 个字符', - loginFailed: '登录失败,请检查您的凭据后重试。', - errors: { - USER_NOT_ACTIVE: '账号已被禁用', - }, - registrationFailed: '注册失败,请重试。', - emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。', - emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}', - emailSuffixAllowedMore: '等 {count} 项', - loginSuccess: '登录成功!欢迎回来。', - accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。', - reloginRequired: '会话已过期,请重新登录。', - turnstileExpired: '验证已过期,请重试', - turnstileFailed: '验证失败,请重试', - completeVerification: '请完成验证', - verifyYourEmail: '验证您的邮箱', - sessionExpired: '会话已过期', - sessionExpiredDesc: '请返回注册页面重新开始。', - verificationCode: '验证码', - verificationCodeHint: '请输入发送到您邮箱的6位验证码', - sendingCode: '发送中...', - sendCode: '发送验证码', - clickToResend: '点击重新发送验证码', - resendCode: '重新发送验证码', - sendCodeDesc: '我们将发送验证码到', - codeSentSuccess: '验证码已发送!请查收您的邮箱。', - verifying: '验证中...', - verifyAndCreate: '验证并创建账户', - resendCountdown: '{countdown}秒后可重新发送', - backToRegistration: '返回注册', - sendCodeFailed: '发送验证码失败,请重试。', - verifyFailed: '验证失败,请重试。', - codeRequired: '请输入验证码', - invalidCode: '请输入有效的6位验证码', - promoCodeLabel: '优惠码', - promoCodePlaceholder: '输入优惠码(可选)', - promoCodeValid: '有效!注册后将获得 ${amount} 赠送余额', - promoCodeInvalid: '无效的优惠码', - promoCodeNotFound: '优惠码不存在', - promoCodeExpired: '此优惠码已过期', - promoCodeDisabled: '此优惠码已被禁用', - promoCodeMaxUsed: '此优惠码已达到使用上限', - promoCodeAlreadyUsed: '您已使用过此优惠码', - promoCodeValidating: '优惠码正在验证中,请稍候', - promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码', - invitationCodeLabel: '邀请码', - invitationCodePlaceholder: '请输入邀请码', - invitationCodeRequired: '请输入邀请码', - invitationCodeValid: '邀请码有效', - invitationCodeInvalid: '邀请码无效或已被使用', - invitationCodeValidating: '正在验证邀请码...', - invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试', - oauthOrContinue: '或使用其他继续', - linuxdo: { - signIn: '使用 Linux.do 登录', - orContinue: '或使用邮箱密码继续', - callbackTitle: '正在完成登录', - callbackProcessing: '正在验证登录信息,请稍候...', - callbackHint: '如果页面未自动跳转,请返回登录页重试。', - callbackMissingToken: '登录信息缺失,请返回重试。', - backToLogin: '返回登录', - invitationRequired: '该 Linux.do 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。', - invalidPendingToken: '注册凭证已失效,请重新使用 Linux.do 登录。', - completeRegistration: '完成注册', - completing: '正在完成注册...', - completeRegistrationFailed: '注册失败,请检查邀请码后重试。' - }, - dingtalk: { - signIn: '钉钉登录', - callbackTitle: '正在完成钉钉登录', - callbackProcessing: '正在验证钉钉登录信息,请稍候...', - callbackHint: '如果页面未自动跳转,请返回登录页重试。', - callbackMissingToken: '登录信息缺失,请返回重试。', - backToLogin: '返回登录', - invitationRequired: '该钉钉账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。', - invalidPendingToken: '注册凭证已失效,请重新使用钉钉登录。', - completeRegistration: '完成注册', - completing: '正在完成注册...', - completeRegistrationFailed: '注册失败,请检查邀请码后重试。', - createAccountTitle: '创建钉钉账户', - registrationDisabledRedirectToBind: '当前已禁止注册新账户,请使用已有账户邮箱和密码绑定钉钉登录', - error: { - title: '钉钉登录失败', - csrf: '登录会话已过期,请重新扫码登录', - corp_rejected: '您的钉钉账号不属于本企业,请联系管理员', - dingtalk_not_enabled: '钉钉登录暂未启用', - upstream_error: '钉钉服务暂时不可用,请稍后重试', - missing_browser_session: '浏览器会话丢失,请重新登录', - missing_params: '请求参数不完整', - invalid_state: '登录状态异常', - provider_error: '钉钉授权失败', - session_error: '会话创建失败,请重试', - retry: '重新登录' - } - }, - emailOAuth: { - signIn: '使用 {providerName} 登录' - }, - oidc: { - signIn: '使用 {providerName} 登录', - callbackTitle: '正在完成 {providerName} 登录', - callbackProcessing: '正在验证 {providerName} 登录信息,请稍候...', - callbackHint: '如果页面未自动跳转,请返回登录页重试。', - callbackMissingToken: '登录信息缺失,请返回重试。', - backToLogin: '返回登录', - invitationRequired: '该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。', - invalidPendingToken: '注册凭证已失效,请重新登录。', - completeRegistration: '完成注册', - completing: '正在完成注册...', - completeRegistrationFailed: '注册失败,请检查邀请码后重试。' - }, - oauthFlow: { - profileDetailsTitle: '使用 {providerName} 资料', - profileDetailsDescription: '选择是否将 {providerName} 的昵称或头像应用到当前账户。', - useDisplayName: '使用昵称', - useAvatar: '使用头像', - avatarAlt: '{providerName} 头像', - reviewProfileBeforeContinue: '请先确认 {providerName} 资料后再继续。', - chooseHowToContinue: '选择后续操作', - chooseAccountActionHint: '请选择绑定已有账户,或创建一个新账户。', - suggestedEmail: '建议邮箱:{email}', - bindExistingAccount: '绑定已有账户', - createNewAccount: '创建新账户', - createAccountHint: '请输入邮箱地址以创建账户并继续。', - bindLoginHint: '登录一个已有账户以绑定此次 {providerName} 登录。', - signInThenBindDescription: '请先登录已有账户,再将此次 {providerName} 登录绑定到该账户。', - bindSignInToExistingAccount: '将此次 {providerName} 登录绑定到已有账户。', - bindCurrentAccountTitle: '绑定当前账户', - bindCurrentAccountDescription: '将此次 {providerName} 登录绑定到当前浏览器已登录的账户。', - bindCurrentAccount: '绑定当前账户', - logInAndBind: '登录并绑定', - useDifferentEmail: '使用其他邮箱', - backToOptions: '返回选项', - yourAccount: '当前账户', - totpHint: '请输入 {account} 的 6 位验证码,以完成此次 {providerName} 登录绑定。', - verifyAndContinue: '验证并继续', - wechatAvailabilityUnknown: '暂时无法确认微信登录可用性,请刷新后重试。', - wechatSystemBrowserOnly: '当前微信登录流程仅支持在系统浏览器中继续。', - wechatBrowserOnly: '当前微信登录流程仅支持在微信内置浏览器中继续。', - wechatNotConfigured: '微信登录尚未配置。' - }, - linuxdoCallbackPageTitle: 'LinuxDo 登录回调', - dingtalkCallbackPageTitle: '钉钉登录回调', - dingtalkProviderName: '钉钉', - oidcCallbackPageTitle: 'OIDC 登录回调', - oauthCallbackPageTitle: 'OAuth 回调', - wechatProviderName: '微信', - wechatCallbackPageTitle: '微信登录回调', - wechatPaymentCallbackPageTitle: '微信支付回调', - wechatPayment: { - callbackTitle: '正在恢复微信支付', - callbackProcessing: '正在恢复微信支付...', - backToPayment: '返回支付页', - callbackMissingResumeToken: '微信支付回调缺少恢复令牌。' - }, - oauth: { - callbackTitle: 'OAuth 回调', - callbackHint: '按需将授权码和状态值复制回后台授权流程。', - invalidCallbackTitle: '无效的登录回调', - invalidCallbackHint: '当前页面缺少有效的授权结果,请返回登录页重新发起快捷登录。', - code: '授权码', - state: '状态', - fullUrl: '完整URL' - }, - // 忘记密码 - forgotPassword: '忘记密码?', - forgotPasswordTitle: '重置密码', - forgotPasswordHint: '输入您的邮箱地址,我们将向您发送密码重置链接。', - sendResetLink: '发送重置链接', - sendingResetLink: '发送中...', - sendResetLinkFailed: '发送重置链接失败,请重试。', - resetEmailSent: '重置链接已发送', - resetEmailSentHint: - '如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。', - backToLogin: '返回登录', - rememberedPassword: '想起密码了?', - // 重置密码 - resetPasswordTitle: '设置新密码', - resetPasswordHint: '请在下方输入您的新密码。', - newPassword: '新密码', - newPasswordPlaceholder: '输入新密码', - confirmPassword: '确认密码', - confirmPasswordPlaceholder: '再次输入新密码', - confirmPasswordRequired: '请确认您的密码', - passwordsDoNotMatch: '两次输入的密码不一致', - resetPassword: '重置密码', - resettingPassword: '重置中...', - resetPasswordFailed: '重置密码失败,请重试。', - passwordResetSuccess: '密码重置成功', - passwordResetSuccessHint: '您的密码已重置。现在可以使用新密码登录。', - invalidResetLink: '无效的重置链接', - invalidResetLinkHint: '此密码重置链接无效或已过期。请重新请求一个新链接。', - requestNewResetLink: '请求新的重置链接', - invalidOrExpiredToken: '密码重置链接无效或已过期。请重新请求一个新链接。' - }, - - // Dashboard - dashboard: { - title: '仪表盘', - welcomeMessage: '欢迎回来!这是您账户的概览。', - balance: '余额', - apiKeys: 'API 密钥', - todayRequests: '今日请求', - todayCost: '今日消费', - todayTokens: '今日 Token', - totalTokens: '累计 Token', - cacheToday: '今日缓存', - performance: '性能指标', - avgResponse: '平均响应', - averageTime: '平均时间', - timeRange: '时间范围', - granularity: '粒度', - day: '按天', - hour: '按小时', - modelDistribution: '模型分布', - groupDistribution: '分组使用分布', - platformBreakdown: '按平台拆分', - platformBreakdownEmpty: '暂无平台用量', - platformCount: '{count} 个平台', - platformOther: '其他', - platformQuota: { - title: '配额用量', - daily: '日', - weekly: '周', - monthly: '月(近30天)', - resetsAt: '{time} 重置', - noLimit: '不限制', - disabled: '已禁用', - }, - tokenUsageTrend: 'Token 使用趋势', - noDataAvailable: '暂无数据', - model: '模型', - group: '分组', - noGroup: '无分组', - requests: '请求', - tokens: 'Token', - actual: '实际', - standard: '标准', - input: '输入', - output: '输出', - cache: '缓存', - recentUsage: '最近使用', - last7Days: '近 7 天', - noUsageRecords: '暂无使用记录', - startUsingApi: '开始使用 API 后,您的使用历史将显示在这里。', - viewAllUsage: '查看全部', - quickActions: '快捷操作', - createApiKey: '创建 API 密钥', - generateNewKey: '生成新的 API 密钥', - batchImageAgent: '批量生图助手', - batchImageAgentDesc: '复制给 Agent 的任务说明', - viewUsage: '查看使用记录', - checkDetailedLogs: '查看详细的使用日志', - redeemCode: '兑换码', - addBalanceWithCode: '使用兑换码充值' - }, - - // Groups (shared) - groups: { - subscription: '订阅' - }, - - // API Keys - keys: { - title: 'API 密钥', - description: '管理您的 API 密钥和访问令牌', - searchPlaceholder: '搜索名称或Key...', - endpoints: { - title: 'API 端点', - default: '默认', - copied: '已复制', - copiedHint: '已复制到剪贴板', - clickToCopy: '点击可复制此端点', - speedTest: '测速', - }, - allGroups: '全部分组', - allStatus: '全部状态', - columnSettings: '列设置', - columnAlwaysVisible: '该列固定显示,不可隐藏', - createKey: '创建密钥', - editKey: '编辑密钥', - deleteKey: '删除密钥', - deleteConfirmMessage: "确定要删除 '{name}' 吗?此操作无法撤销。", - apiKey: 'API 密钥', - group: '分组', - currentConcurrency: '当前并发', - noGroup: '无分组', - searchGroup: '搜索分组...', - noGroupFound: '未找到匹配的分组', - created: '创建时间', - copyToClipboard: '复制到剪贴板', - copied: '已复制!', - importToCcSwitch: '导入到 CCS', - enable: '启用', - disable: '禁用', - nameLabel: '名称', - namePlaceholder: '我的 API 密钥', - groupLabel: '分组', - selectGroup: '选择分组', - statusLabel: '状态', - selectStatus: '选择状态', - saving: '保存中...', - noKeysYet: '暂无 API 密钥', - createFirstKey: '创建您的第一个 API 密钥以开始使用 API。', - keyCreatedSuccess: 'API 密钥创建成功', - keyUpdatedSuccess: 'API 密钥更新成功', - keyDeletedSuccess: 'API 密钥删除成功', - keyEnabledSuccess: 'API 密钥已启用', - keyDisabledSuccess: 'API 密钥已禁用', - failedToLoad: '加载 API 密钥失败', - failedToSave: '保存 API 密钥失败', - failedToDelete: '删除 API 密钥失败', - failedToUpdateStatus: '更新 API 密钥状态失败', - clickToChangeGroup: '点击更换分组', - groupChangedSuccess: '分组更换成功', - failedToChangeGroup: '更换分组失败', - groupRequired: '请选择分组', - usage: '用量', - today: '今日', - total: '近30天', - quota: '额度', - lastUsedAt: '上次使用时间', - useKey: '使用密钥', - useKeyModal: { - title: '使用 API 密钥', - description: '将以下环境变量添加到您的终端配置文件或直接在终端中运行。', - copy: '复制', - copied: '已复制', - note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。', - noGroupTitle: '请先分配分组', - noGroupDescription: - '此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。', - openai: { - description: '将以下配置文件添加到 Codex CLI 配置目录中。', - configTomlHint: '请确保以下内容位于 config.toml 文件的开头部分', - note: '请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。', - noteWindows: - '按 Win+R,输入 %userprofile%\\.codex 打开配置目录。如目录不存在,请先手动创建。' - }, - cliTabs: { - claudeCode: 'Claude Code', - geminiCli: 'Gemini CLI', - codexCli: 'Codex CLI', - codexCliWs: 'Codex CLI (WebSocket)', - opencode: 'OpenCode' - }, - antigravity: { - description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。', - claudeCode: 'Claude Code', - geminiCli: 'Gemini CLI', - claudeNote: - '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。', - geminiNote: - '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。' - }, - gemini: { - description: - '将以下环境变量添加到您的终端配置文件或直接在终端中运行,以配置 Gemini CLI 访问。', - modelComment: '如果你有 Gemini 3 权限可以填:gemini-3-pro-preview', - note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。' - }, - opencode: { - title: 'OpenCode 配置示例', - subtitle: 'opencode.json', - hint: '配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。' - } - }, - customKeyLabel: '自定义密钥', - customKeyPlaceholder: '输入自定义密钥(至少16个字符)', - customKeyHint: '仅允许字母、数字、下划线和连字符,最少16个字符。', - customKeyTooShort: '自定义密钥至少需要16个字符', - customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符', - customKeyRequired: '请输入自定义密钥', - ipRestriction: 'IP 限制', - ipWhitelist: 'IP 白名单', - ipWhitelistPlaceholder: '192.168.1.100\n10.0.0.0/8', - ipWhitelistHint: '每行一个 IP 或 CIDR,设置后仅允许这些 IP 使用此密钥', - ipBlacklist: 'IP 黑名单', - ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16', - ipBlacklistHint: '每行一个 IP 或 CIDR,这些 IP 将被禁止使用此密钥', - ipRestrictionEnabled: '已配置 IP 限制', - ccSwitchNotInstalled: - 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。', - ccsClientSelect: { - title: '选择客户端', - description: '请选择您要导入到 CC-Switch 的客户端类型:', - claudeCode: 'Claude Code', - claudeCodeDesc: '导入为 Claude Code 配置', - geminiCli: 'Gemini CLI', - geminiCliDesc: '导入为 Gemini CLI 配置' - }, - // 配额和有效期 - quotaLimit: '额度限制', - quotaAmount: '额度金额 (USD)', - quotaAmountPlaceholder: '输入 USD 额度限制', - quotaAmountHint: '设置此密钥可消费的最大金额。0 = 无限制。', - quotaUsed: '已用额度', - reset: '重置', - resetQuotaUsed: '将已用额度重置为 0', - resetQuotaTitle: '确认重置额度', - resetQuotaConfirmMessage: '确定要将密钥 "{name}" 的已用额度(${used})重置为 0 吗?此操作不可撤销。', - quotaResetSuccess: '额度重置成功', - failedToResetQuota: '重置额度失败', - rateLimitColumn: '速率限制', - rateLimitSection: '速率限制', - resetUsage: '重置', - rateLimit5h: '5小时限额 (USD)', - rateLimit1d: '日限额 (USD)', - rateLimit7d: '7天限额 (USD)', - rateLimitHint: '设置此密钥在指定时间窗口内的最大消费额。0 = 无限制。', - rateLimitUsage: '速率限制用量', - resetRateLimitUsage: '重置速率限制用量', - resetRateLimitTitle: '确认重置速率限制', - resetRateLimitConfirmMessage: '确定要重置密钥 "{name}" 的速率限制用量吗?所有时间窗口的已用额度将归零。此操作不可撤销。', - rateLimitResetSuccess: '速率限制已重置', - failedToResetRateLimit: '重置速率限制失败', - resetNow: '即将重置', - expiration: '密钥有效期', - expiresInDays: '{days} 天', - extendDays: '+{days} 天', - customDate: '自定义', - expirationDate: '过期时间', - expirationDateHint: '选择此 API 密钥的过期时间。', - currentExpiration: '当前过期时间', - expiresAt: '过期时间', - noExpiration: '永久有效', - status: { - active: '活跃', - inactive: '已停用', - quota_exhausted: '额度耗尽', - expired: '已过期' - } - }, - - // Usage - usage: { - title: '使用记录', - description: '查看和分析您的 API 使用历史', - costDetails: '费用明细', - tokenDetails: 'Token 明细', - cacheTtlOverriddenHint: '缓存 TTL Override 已启用', - cacheTtlOverriddenLabel: 'TTL 替换', - cacheTtlOverridden5m: '按 5m 计费', - cacheTtlOverridden1h: '按 1h 计费', - totalRequests: '总请求数', - totalTokens: '总 Token', - cacheTotal: '缓存', - cacheBreakdown: '缓存 Token 明细', - cacheCreationTokensLabel: '缓存创建', - cacheReadTokensLabel: '缓存读取', - totalCost: '总消费', - standardCost: '标准', - actualCost: '实际', - accountCost: '成本', - userBilled: '用户扣费', - accountBilled: '账号计费', - resetNow: '现在', - resetPending: '待刷新', - accountMultiplier: '账号倍率', - avgDuration: '平均耗时', - inSelectedRange: '所选范围内', - perRequest: '每次请求', - apiKeyFilter: 'API 密钥', - allApiKeys: '全部密钥', - timeRange: '时间范围', - exportCsv: '导出 CSV', - exportExcel: '导出 Excel', - exportingProgress: '正在导出数据...', - exportedCount: '已导出 {current}/{total} 条', - estimatedTime: '预计剩余时间:{time}', - cancelExport: '取消导出', - exportCancelled: '导出已取消', - exporting: '导出中...', - preparingExport: '正在准备导出...', - model: '模型', - requestedModel: '请求', - upstreamModel: '上游', - reasoningEffort: '推理强度', - endpoint: '端点', - endpointDistribution: '端点分布', - inbound: '入站', - upstream: '上游', - mapping: '映射', - path: '路径', - inboundEndpoint: '入站端点', - upstreamEndpoint: '上游端点', - type: '类型', - tokens: 'Token', - cost: '费用', - firstToken: '首 Token', - duration: '耗时', - time: '时间', - ws: 'WS', - stream: '流式', - sync: '同步', - cyber: '安全策略', - unknown: '未知', - in: '输入', - out: '输出', - cacheHit: '缓存命中', - cacheCreate: '缓存创建', - cacheHitRate: '缓存命中率', - inputTokenPrice: '输入单价', - outputTokenPrice: '输出单价', - perMillionTokens: '/ 1M Token', - unitPrice: '单次价格', - imageUnitPrice: '单张价格', - imageTotalPrice: '图片总价', - imageCount: '图片张数', - imageBillingSize: '计费尺寸', - imageInputSize: '输入尺寸', - imageOutputSize: '输出尺寸', - imageOutputTokens: '图片输出 Token', - imageOutputTokenPrice: '图片输出单价', - imageOutputCost: '图片输出费用', - imageSizeSource: '尺寸来源', - imageSizeBreakdown: '尺寸明细', - imageSizeSourceOutput: '上游输出', - imageSizeSourceInput: '请求输入', - imageSizeSourceDefault: '默认计费档位', - imageSizeSourceLegacy: '历史记录', - imageSizeSourceMissing: '未记录', - imageSizeNotRecorded: '未记录', - imageSizeLegacyUnstandardized: '历史非标准', - imageSizeUnknown: '未知', - cacheRead: '读取', - cacheWrite: '写入', - serviceTier: '服务档位', - serviceTierPriority: 'Fast', - serviceTierFlex: 'Flex', - serviceTierStandard: 'Standard', - rate: '倍率', - original: '原始', - billed: '计费', - noRecords: '未找到使用记录,请尝试调整筛选条件。', - failedToLoad: '加载使用记录失败', - noDataToExport: '没有可导出的数据', - exportSuccess: '使用数据导出成功', - exportFailed: '使用数据导出失败', - exportExcelSuccess: '使用数据导出成功(Excel格式)', - exportExcelFailed: '使用数据导出失败', - imageUnit: '张', - userAgent: 'User-Agent', - ipGeo: { - fetch: '获取地区', - fetching: '获取中...', - failed: '获取失败', - private: '内网地址', - refreshTitle: '刷新地区信息', - batchFetch: '批量获取地区', - batchFetching: '获取中...', - pending: '{count} 个 IP 待获取地区', - batchFailed: '批量获取地区信息失败', - detailOrg: '运营商', - detailTimezone: '时区', - detailAccuracy: '定位精度', - detailCoordinates: '坐标', - }, - tabs: { usage: '用量明细', errors: '错误请求' }, - errors: { - time: '时间', model: '模型', endpoint: '端点', status: '状态码', - category: '分类', platform: '平台', message: '错误信息', - keyName: 'Key 名称', keyDeleted: '已删除', allKeys: '全部 Key', - modelPlaceholder: '搜索模型', allCategories: '全部分类', allStatuses: '全部状态码', - empty: '暂无错误请求', failedToLoad: '加载错误请求失败', - categories: { - auth: '认证失败', rate_limit: '限流', quota: '余额/订阅', - invalid_request: '参数错误', service_unavailable: '服务暂时不可用', - upstream: '上游错误', internal: '平台错误', other: '其他', cyber: '安全策略', - }, - detail: { - title: '错误请求详情', - responseBody: '上游响应内容', - upstreamStatus: '上游状态码', - loadFailed: '加载详情失败,请稍后重试', - }, - }, - }, - - // Shared keys for channel monitor (admin + user views) - monitorCommon: { - status: { - operational: '正常', - degraded: '降级', - failed: '失败', - error: '错误', - unknown: '-' - }, - providers: { - openai: 'OpenAI', - anthropic: 'Anthropic', - gemini: 'Gemini' - }, - extraModelsHeader: '附加模型', - extraModelsEmpty: '无附加模型', - latencyEmpty: '-', - availabilityPrefix: '可用性', - dialogLatency: '对话延迟', - endpointPing: '端点 PING', - history60pts: '近 {n} 次记录', - nextUpdateIn: '{n}s 后刷新', - past: 'PAST', - now: 'NOW', - maintenancePaused: '维护中 · 已暂停时间线采集', - extraModelsCount: '+ {n} 模型', - pollEvery: '{n}s 轮询', - updatedAt: '更新于 {time}', - relativeSecondsAgo: '{n} 秒前', - relativeMinutesAgo: '{n} 分钟前', - relativeHoursAgo: '{n} 小时前', - relativeDaysAgo: '{n} 天前' - }, - - // Channel Status (user-facing read-only view) - channelStatus: { - title: '渠道状态', - description: '查看渠道可用性、延迟和近期状态', - searchPlaceholder: '搜索渠道...', - allProviders: '全部供应商', - loadError: '加载渠道状态失败', - detailLoadError: '加载渠道详情失败', - detailTitle: '渠道详情', - closeDetail: '关闭', - windowTab: { - '7d': '7 天', - '15d': '15 天', - '30d': '30 天' - }, - overall: { - operational: 'OPERATIONAL', - degraded: 'DEGRADED', - unavailable: 'UNAVAILABLE' - }, - columns: { - name: '名称', - provider: '供应商', - groupName: '分组', - primaryModel: '主模型', - availability7d: '7 天可用率', - latency: '延迟 (ms)' - }, - detailColumns: { - model: '模型', - latestStatus: '最新状态', - latestLatency: '最新延迟 (ms)', - availability7d: '7 天可用率', - availability15d: '15 天可用率', - availability30d: '30 天可用率', - avgLatency7d: '7 天平均延迟 (ms)' - }, - empty: { - title: '暂无可显示的渠道', - description: '管理员尚未配置可监控的渠道。' - } - }, - - // Available Channels (user-facing) - availableChannels: { - title: '可用渠道', - description: '查看您可访问的渠道与其支持的模型、定价', - searchPlaceholder: '搜索渠道或模型...', - empty: '暂无可用渠道', - noModels: '未配置模型', - noPricing: '未配置定价', - exclusive: '专属', - public: '公开', - exclusiveTooltip: '管理员授权给你的专属分组', - publicTooltip: '对所有用户公开的分组', - columns: { - name: '渠道名', - description: '描述', - platform: '平台', - groups: '我可访问的分组', - supportedModels: '支持模型' - }, - pricing: { - billingMode: '计费模式', - billingModeToken: '按 Token', - billingModePerRequest: '按次', - billingModeImage: '按图片', - inputPrice: '输入', - outputPrice: '输出', - cacheWritePrice: '缓存写入', - cacheReadPrice: '缓存读取', - imageOutputPrice: '图片输出', - perRequestPrice: '每次请求', - intervals: '阶梯定价', - unitPerMillion: '/ 1M token', - unitPerRequest: '/ 次' - } - }, - - affiliate: { - title: '邀请返利', - description: '邀请新用户注册,并将返利额度转入账户余额', - yourCode: '我的邀请码', - inviteLink: '邀请链接', - copyCode: '复制邀请码', - copyLink: '复制链接', - codeCopied: '邀请码已复制', - linkCopied: '邀请链接已复制', - loadFailed: '加载邀请返利数据失败', - transferFailed: '转入余额失败', - stats: { - rebateRate: '我的返利比例', - rebateRateHint: '被邀请用户每次充值后你可获得的返利比例', - invitedUsers: '邀请人数', - availableQuota: '可转返利额度', - frozenQuota: '冻结中', - frozenQuotaHint: '新产生的返利正在冻结期中', - totalQuota: '历史返利额度' - }, - transfer: { - title: '返利额度转余额', - description: '将当前可用返利额度一键转入账户余额', - button: '转入余额', - transferring: '转入中...', - empty: '当前没有可转入额度', - success: '已转入余额:{amount}' - }, - invitees: { - title: '已邀请用户', - empty: '暂无邀请记录', - columns: { - email: '邮箱', - username: '用户名', - rebate: '返利明细', - joinedAt: '注册时间' - } - }, - tips: { - title: '使用说明', - line1: '将邀请码或邀请链接分享给新用户。', - line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。', - line3: '返利额度可随时转入账户余额。', - line4: '新产生的返利需要经过冻结期后才能提现。' - } - }, - - // Redeem - redeem: { - title: '兑换码', - description: '输入兑换码以充值余额或增加并发数', - currentBalance: '当前余额', - concurrency: '并发数', - requests: '请求', - redeemCodeLabel: '兑换码', - redeemCodePlaceholder: '请输入兑换码', - redeemCodeHint: '兑换码区分大小写', - redeeming: '兑换中...', - redeemButton: '兑换', - redeemSuccess: '兑换成功!', - redeemFailed: '兑换失败', - added: '已添加', - concurrentRequests: '并发请求', - newBalance: '新余额', - newConcurrency: '新并发数', - aboutCodes: '关于兑换码', - codeRule1: '每个兑换码只能使用一次', - codeRule2: '兑换码可以增加余额、并发数或试用权限', - codeRule3: '如有兑换问题,请联系客服', - codeRule4: '余额和并发数即时更新', - recentActivity: '最近活动', - historyWillAppear: '您的兑换历史将显示在这里', - balanceAddedRedeem: '余额充值(兑换)', - balanceAddedAffiliate: '余额充值(返利转入)', - balanceAddedAdmin: '余额充值(管理员)', - balanceDeductedAdmin: '余额扣除(管理员)', - concurrencyAddedRedeem: '并发增加(兑换)', - concurrencyAddedAdmin: '并发增加(管理员)', - concurrencyReducedAdmin: '并发减少(管理员)', - adminAdjustment: '管理员调整', - subscriptionAssigned: '订阅已分配', - subscriptionAssignedDesc: '您已获得 {groupName} 的访问权限', - subscriptionDays: '{days} 天', - days: '天', - codeRedeemSuccess: '兑换成功!', - failedToRedeem: '兑换失败,请检查兑换码后重试。', - subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。', - pleaseEnterCode: '请输入兑换码' - }, - - // Profile - profile: { - title: '个人设置', - description: '管理您的账户信息和设置', - accountBalance: '账户余额', - concurrencyLimit: '并发限制', - rpmLimit: 'RPM 限制', - rpmUnlimited: '不限制', - memberSince: '注册时间', - overviewTitle: '账户总览', - overviewDescription: '快速查看账号状态、资料来源与常用设置。', - basicsTitle: '资料与头像', - basicsDescription: '维护公开展示信息,并保持头像与昵称风格一致。', - linkedProfileSources: '资料来源', - linkedProfileSourcesDescription: '部分头像和昵称可能同步自第三方登录方式。', - securityTitle: '安全设置', - securityDescription: '密码、双因素认证和通知提醒集中放在右侧。', - administrator: '管理员', - user: '用户', - username: '用户名', - email: '邮箱', - status: '状态', - role: '角色', - enterUsername: '输入用户名', - editProfile: '编辑个人资料', - updateProfile: '更新资料', - updating: '更新中...', - updateSuccess: '资料更新成功', - updateFailed: '资料更新失败', - usernameRequired: '用户名不能为空', - changePassword: '修改密码', - currentPassword: '当前密码', - newPassword: '新密码', - confirmNewPassword: '确认新密码', - passwordHint: '密码至少需要 8 个字符', - changingPassword: '修改中...', - changePasswordButton: '修改密码', - passwordsNotMatch: '两次输入的密码不一致', - passwordTooShort: '密码至少需要 8 个字符', - passwordChangeSuccess: '密码修改成功', - passwordChangeFailed: '密码修改失败', - // TOTP 2FA - totp: { - title: '双因素认证 (2FA)', - description: '使用 Google Authenticator 等应用增强账户安全', - enabled: '已启用', - enabledAt: '启用时间', - notEnabled: '未启用', - notEnabledHint: '启用双因素认证可以增强账户安全性', - enable: '启用', - disable: '禁用', - featureDisabled: '功能未开放', - featureDisabledHint: '管理员尚未开放双因素认证功能', - setupTitle: '设置双因素认证', - setupStep1: '使用认证器应用扫描下方二维码', - setupStep2: '输入应用显示的 6 位验证码', - manualEntry: '无法扫码?手动输入密钥:', - enterCode: '输入 6 位验证码', - verify: '验证', - setupFailed: '获取设置信息失败', - verifyFailed: '验证码错误,请重试', - enableSuccess: '双因素认证已启用', - disableTitle: '禁用双因素认证', - disableWarning: '禁用后,登录时将不再需要验证码。这可能会降低您的账户安全性。', - enterPassword: '请输入当前密码确认', - confirmDisable: '确认禁用', - disableSuccess: '双因素认证已禁用', - disableFailed: '禁用失败,请检查密码是否正确', - loginTitle: '双因素认证', - loginHint: '请输入您认证器应用显示的 6 位验证码', - loginFailed: '验证失败,请重试', - // New translations for email verification - verifyEmailFirst: '请先验证您的邮箱', - verifyPasswordFirst: '请先验证您的身份', - emailCode: '邮箱验证码', - enterEmailCode: '请输入 6 位验证码', - sendCode: '发送验证码', - codeSent: '验证码已发送到您的邮箱', - sendCodeFailed: '发送验证码失败' - }, - balanceNotify: { - title: '余额不足提醒', - description: '当账户余额低于阈值时发送邮件提醒', - enabled: '启用余额不足提醒', - threshold: '自定义提醒阈值', - thresholdHint: '留空使用系统默认值', - thresholdPlaceholder: '输入金额', - systemDefault: '系统默认值', - extraEmails: '通知邮箱', - extraEmailsHint: '必须添加并验证邮箱后,余额不足时才能收到提醒邮件', - primaryEmail: '主邮箱', - noExtraEmails: '暂无额外通知邮箱', - enterEmail: '输入邮箱地址', - addEmail: '添加邮箱', - emailPlaceholder: '输入邮箱地址', - sendCode: '发送验证码', - resend: '重发', - codeSent: '验证码已发送', - codeSentTo: '验证码已发送到 {email}', - enterCode: '输入验证码', - codePlaceholder: '6位验证码', - verify: '验证', - emailAdded: '邮箱已添加', - emailRemoved: '邮箱已移除', - verifySuccess: '邮箱添加成功', - removeEmail: '移除', - removeSuccess: '邮箱已移除', - emailDuplicate: '该邮箱已存在', - maxEmailsReached: '已达到通知邮箱数量上限', - unverified: '未验证', - verified: '已验证', - }, - avatar: { - title: '资料头像', - description: '仅支持上传头像图片;静态图片会自动压缩到 20KB 以内后再保存。', - uploadAction: '上传图片', - uploadHint: '上传图片时会自动压缩静态图片到 20KB 以内,GIF 需自行控制在 20KB 以内', - uploadRequired: '请先上传头像图片', - saveSuccess: '头像已更新', - deleteSuccess: '头像已删除', - invalidType: '请选择图片文件', - gifTooLarge: 'GIF 头像必须在 20KB 以内', - compressTooLarge: '无法将图片压缩到 20KB 以内,请换一张更小的图片', - compressFailed: '压缩所选图片失败', - readFailed: '读取所选图片失败', - emptyDeleteHint: '当前没有可删除的头像', - }, - authBindings: { - title: '登录方式绑定', - description: '查看当前绑定状态,并将更多第三方登录方式关联到这个账号。', - bindAction: '绑定 {providerName}', - bindSuccess: '账号绑定成功', - emailPlaceholder: '输入邮箱地址', - codePlaceholder: '输入验证码', - passwordPlaceholder: '设置登录密码', - replaceEmailPasswordPlaceholder: '输入当前密码', - sendCodeAction: '发送验证码', - manageEmailAction: '管理邮箱', - hideEmailFormAction: '收起邮箱表单', - confirmEmailBindAction: '绑定邮箱', - confirmEmailReplaceAction: '更换主邮箱', - codeSentTo: '验证码已发送到 {email}', - replaceSuccess: '主邮箱已更新', - unbindAction: '解绑', - unbindSuccess: '{providerName} 已解绑', - boundCount: '已关联 {count} 条记录', - status: { - bound: '已绑定', - notBound: '未绑定', - }, - providers: { - email: '邮箱', - linuxdo: 'LinuxDo', - dingtalk: '钉钉', - oidc: '{providerName}', - wechat: '微信', - }, - notes: { - emailManagedFromProfile: '主邮箱在资料表单中管理', - canUnbind: '你可以解绑这个登录方式。', - bindAnotherBeforeUnbind: '请先绑定其他登录方式,再解除当前绑定。', - }, - source: { - avatar: '头像当前来自 {providerName}', - username: '昵称当前来自 {providerName}', - }, - } - }, - - // Empty States - empty: { - noData: '暂无数据' - }, - - // Table - table: { - expandActions: '展开更多操作', - collapseActions: '收起操作' - }, - - // Pagination - pagination: { - showing: '显示', - to: '至', - of: '共', - results: '条结果', - page: '页', - pageOf: '第 {page} / {total} 页', - previous: '上一页', - next: '下一页', - perPage: '每页', - goToPage: '跳转到第 {page} 页', - jumpTo: '跳转页', - jumpPlaceholder: '页码', - jumpAction: '跳转' - }, - - // Errors - errors: { - somethingWentWrong: '出错了', - pageNotFound: '页面未找到', - unauthorized: '未授权', - forbidden: '禁止访问', - serverError: '服务器错误', - networkError: '网络错误', - timeout: '请求超时', - tryAgain: '请重试' - }, - - // Dates - dates: { - today: '今天', - yesterday: '昨天', - thisWeek: '本周', - lastWeek: '上周', - thisMonth: '本月', - lastMonth: '上月', - last24Hours: '近24小时', - last7Days: '近 7 天', - last14Days: '近 14 天', - last30Days: '近 30 天', - custom: '自定义', - startDate: '开始日期', - endDate: '结束日期', - apply: '应用', - selectDateRange: '选择日期范围' - }, - - // Admin - admin: { - // Dashboard - dashboard: { - title: '管理控制台', - description: '系统概览与统计数据', - apiKeys: 'API 密钥', - totalApiKeys: 'API 密钥总数', - activeApiKeys: '活跃密钥', - users: '用户', - totalUsers: '用户总数', - activeUsers: '活跃用户', - accounts: '账号', - totalAccounts: '账号总数', - activeAccounts: '活跃账号', - todayRequests: '今日请求', - totalRequests: '总请求数', - todayCost: '今日消费', - totalCost: '总消费', - actual: '实际', - standard: '标准', - accountCost: '成本', - todayTokens: '今日 Token', - totalTokens: '总 Token', - input: '输入', - output: '输出', - cacheToday: '今日缓存', - performance: '性能指标', - avgResponse: '平均响应', - averageTime: '平均时间', - timeRange: '时间范围', - granularity: '粒度', - day: '按天', - hour: '按小时', - modelDistribution: '模型分布', - groupDistribution: '分组使用分布', - metricTokens: '按 Token', - metricActualCost: '按实际消费', - tokenUsageTrend: 'Token 使用趋势', - noDataAvailable: '暂无数据', - model: '模型', - group: '分组', - noGroup: '无分组', - requests: '请求', - tokens: 'Token', - cache: '缓存', - recentUsage: '最近使用', - viewModelDistribution: '模型分布', - viewSpendingRanking: '用户消费榜', - spendingRankingTitle: '用户消费榜', - spendingRankingUser: '用户', - spendingRankingRequests: '请求', - spendingRankingTokens: 'Token', - spendingRankingSpend: '消费', - spendingRankingOther: '其他', - spendingRankingUsage: '用量', - spendShort: '消费', - requestsShort: '请求', - tokensShort: 'Token', - last7Days: '近 7 天', - noUsageRecords: '暂无使用记录', - startUsingApi: '开始使用 API 后,使用历史将显示在这里。', - viewAllUsage: '查看全部', - quickActions: '快捷操作', - manageUsers: '管理用户', - viewUserAccounts: '查看和管理用户账户', - manageAccounts: '管理账号', - configureAiAccounts: '配置 AI 平台账号', - batchImage: '批量生图', - batchImageDesc: '提交任务、复制 Agent 调用说明', - groupPricing: '分组定价', - groupPricingDesc: '设置批量折扣和冻结比例', - systemSettings: '系统设置', - configureSystem: '配置系统设置', - failedToLoad: '加载仪表盘数据失败' - }, - - backup: { - title: '数据库备份', - description: '全量数据库备份到 S3 兼容存储,支持定时备份与恢复', - s3: { - title: 'S3 存储配置', - description: '配置 S3 兼容存储(支持 Cloudflare R2)', - descriptionPrefix: '配置 S3 兼容存储(支持', - descriptionSuffix: ')', - enabled: '启用 S3 存储', - endpoint: '端点地址', - region: '区域', - bucket: '存储桶', - prefix: 'Key 前缀', - accessKeyId: 'Access Key ID', - secretAccessKey: 'Secret Access Key', - secretConfigured: '已配置,留空保持不变', - forcePathStyle: '强制路径风格', - testConnection: '测试连接', - testSuccess: 'S3 连接测试成功', - testFailed: 'S3 连接测试失败', - saved: 'S3 配置已保存' - }, - schedule: { - title: '定时备份', - description: '配置自动定时备份', - enabled: '启用定时备份', - cronExpr: 'Cron 表达式', - cronHint: '例如 "0 2 * * *" 表示每天凌晨 2 点', - retainDays: '备份过期天数', - retainDaysHint: '备份文件超过此天数后自动删除,0 = 永不过期', - retainCount: '最大保留份数', - retainCountHint: '最多保留的备份数量,0 = 不限制', - saved: '定时备份配置已保存' - }, - operations: { - title: '备份记录', - description: '创建手动备份和管理已有备份记录', - createBackup: '创建备份', - backing: '备份中...', - backupCreated: '备份创建成功', - expireDays: '过期天数', - alreadyInProgress: '已有备份正在进行中', - backupRunning: '备份进行中...', - backupFailed: '备份失败', - restoreRunning: '恢复进行中...', - restoreFailed: '恢复失败', - }, - columns: { - status: '状态', - fileName: '文件名', - size: '大小', - expiresAt: '过期时间', - triggeredBy: '触发方式', - startedAt: '开始时间', - actions: '操作' - }, - status: { - pending: '等待中', - running: '执行中', - completed: '已完成', - failed: '失败' - }, - progress: { - pending: '准备中', - dumping: '导出数据库', - uploading: '上传中', - }, - trigger: { - manual: '手动', - scheduled: '定时' - }, - neverExpire: '永不过期', - empty: '暂无备份记录', - actions: { - download: '下载', - restore: '恢复', - restoreConfirm: '确定要从此备份恢复吗?这将覆盖当前数据库!', - restorePasswordPrompt: '请输入管理员密码以确认恢复操作', - restoreSuccess: '数据库恢复成功', - deleteConfirm: '确定要删除此备份吗?', - deleted: '备份已删除' - }, - r2Guide: { - title: 'Cloudflare R2 配置教程', - intro: 'Cloudflare R2 提供 S3 兼容的对象存储,免费额度为 10GB 存储 + 每月 100 万次 A 类请求,非常适合数据库备份。', - step1: { - title: '创建 R2 存储桶', - line1: '登录 Cloudflare Dashboard (dash.cloudflare.com),左侧菜单选择「R2 对象存储」', - line2: '点击「创建存储桶」,输入名称(如 sub2api-backups),选择区域', - line3: '点击创建完成' - }, - step2: { - title: '创建 API 令牌', - line1: '在 R2 页面,点击右上角「管理 R2 API 令牌」', - line2: '点击「创建 API 令牌」,权限选择「对象读和写」', - line3: '建议指定存储桶范围(仅允许访问备份桶,更安全)', - line4: '创建后会显示 Access Key ID 和 Secret Access Key', - warning: 'Secret Access Key 只会显示一次,请立即复制保存!' - }, - step3: { - title: '获取 S3 端点地址', - desc: '在 R2 概览页面找到你的账户 ID(在 URL 或右侧面板中),端点格式为:', - accountId: '你的账户 ID' - }, - step4: { - title: '填写以下配置', - checkEnabled: '勾选', - bucketValue: '你创建的存储桶名称', - fromStep2: '第 2 步获取的值', - unchecked: '不勾选' - }, - freeTier: 'R2 免费额度:10GB 存储 + 每月 100 万次 A 类请求 + 1000 万次 B 类请求,对数据库备份完全够用。' - } - }, - - dataManagement: { - title: '数据管理', - description: '统一管理数据管理代理状态、对象存储配置和备份任务', - agent: { - title: '数据管理代理状态', - description: '系统会自动探测固定 Unix Socket,仅在可连通时启用数据管理功能。', - enabled: '数据管理代理已就绪,可继续进行数据管理操作。', - disabled: '数据管理代理不可用,当前仅可查看诊断信息。', - socketPath: 'Socket 路径', - version: '版本', - status: '状态', - uptime: '运行时长', - reasonLabel: '不可用原因', - reason: { - DATA_MANAGEMENT_AGENT_SOCKET_MISSING: '未检测到数据管理 Socket 文件', - DATA_MANAGEMENT_AGENT_UNAVAILABLE: '数据管理代理不可连通', - BACKUP_AGENT_SOCKET_MISSING: '未检测到备份 Socket 文件', - BACKUP_AGENT_UNAVAILABLE: '备份代理不可连通', - UNKNOWN: '未知原因' - } - }, - sections: { - config: { - title: '备份配置', - description: '配置备份源、保留策略与 S3 存储参数。' - }, - s3: { - title: 'S3 对象存储', - description: '配置并测试备份产物上传到标准 S3 对象存储。' - }, - backup: { - title: '备份操作', - description: '触发 PostgreSQL、Redis 与全量备份任务。' - }, - history: { - title: '备份历史', - description: '查看备份任务执行状态、错误与产物信息。' - } - }, - form: { - sourceMode: '源模式', - backupRoot: '备份根目录', - activePostgresProfile: '当前激活 PostgreSQL 配置', - activeRedisProfile: '当前激活 Redis 配置', - activeS3Profile: '当前激活 S3 账号', - retentionDays: '保留天数', - keepLast: '至少保留最近任务数', - uploadToS3: '上传到 S3', - useActivePostgresProfile: '使用当前激活 PostgreSQL 配置', - useActiveRedisProfile: '使用当前激活 Redis 配置', - useActiveS3Profile: '使用当前激活账号', - idempotencyKey: '幂等键(可选)', - secretConfigured: '已配置,留空不变', - source: { - profileID: '配置 ID(唯一)', - profileName: '配置名称', - setActive: '创建后立即设为激活配置' - }, - postgres: { - title: 'PostgreSQL', - host: '主机', - port: '端口', - user: '用户名', - password: '密码', - database: '数据库', - sslMode: 'SSL 模式', - containerName: '容器名(docker_exec 模式)' - }, - redis: { - title: 'Redis', - addr: '地址(host:port)', - username: '用户名', - password: '密码', - db: '数据库编号', - containerName: '容器名(docker_exec 模式)' - }, - s3: { - enabled: '启用 S3 上传', - profileID: '账号 ID(唯一)', - profileName: '账号名称', - endpoint: 'Endpoint(可选)', - region: 'Region', - bucket: 'Bucket', - accessKeyID: 'Access Key ID', - secretAccessKey: 'Secret Access Key', - prefix: '对象前缀', - forcePathStyle: '强制 path-style', - useSSL: '使用 SSL', - setActive: '创建后立即设为激活账号' - } - }, - sourceProfiles: { - createTitle: '创建数据源配置', - editTitle: '编辑数据源配置', - empty: '暂无配置,请先创建', - deleteConfirm: '确定删除配置 {profileID} 吗?', - columns: { - profile: '配置', - active: '激活状态', - connection: '连接信息', - database: '数据库', - updatedAt: '更新时间', - actions: '操作' - } - }, - s3Profiles: { - createTitle: '创建 S3 账号', - editTitle: '编辑 S3 账号', - empty: '暂无 S3 账号,请先创建', - editHint: '点击“编辑”将在右侧抽屉中修改账号信息。', - deleteConfirm: '确定删除 S3 账号 {profileID} 吗?', - columns: { - profile: '账号', - active: '激活状态', - storage: '存储配置', - updatedAt: '更新时间', - actions: '操作' - } - }, - history: { - total: '共 {count} 条', - empty: '暂无备份任务', - columns: { - jobID: '任务 ID', - type: '类型', - status: '状态', - triggeredBy: '触发人', - pgProfile: 'PostgreSQL 配置', - redisProfile: 'Redis 配置', - s3Profile: 'S3 账号', - finishedAt: '完成时间', - artifact: '产物', - error: '错误' - }, - status: { - queued: '排队中', - running: '执行中', - succeeded: '成功', - failed: '失败', - partial_succeeded: '部分成功' - } - }, - actions: { - refresh: '刷新状态', - disabledHint: '请先启动 datamanagementd 并确认 Socket 可连通。', - reloadConfig: '加载配置', - reloadSourceProfiles: '刷新数据源配置', - reloadProfiles: '刷新账号列表', - newSourceProfile: '新建数据源配置', - saveConfig: '保存配置', - configSaved: '配置保存成功', - testS3: '测试 S3 连接', - s3TestOK: 'S3 连接测试成功', - s3TestFailed: 'S3 连接测试失败', - newProfile: '新建账号', - saveProfile: '保存账号', - activateProfile: '设为激活', - profileIDRequired: '请输入账号 ID', - profileNameRequired: '请输入账号名称', - profileSelectRequired: '请先选择要编辑的账号', - profileCreated: 'S3 账号创建成功', - profileSaved: 'S3 账号保存成功', - profileActivated: 'S3 账号已切换为激活', - profileDeleted: 'S3 账号删除成功', - sourceProfileCreated: '数据源配置创建成功', - sourceProfileSaved: '数据源配置保存成功', - sourceProfileActivated: '数据源配置已切换为激活', - sourceProfileDeleted: '数据源配置删除成功', - createBackup: '创建备份任务', - jobCreated: '备份任务已创建:{jobID}({status})', - refreshJobs: '刷新任务', - loadMore: '加载更多' - } - }, - - affiliates: { - invitesDescription: '查看全站邀请关系和被邀请用户累计返利', - rebatesDescription: '查看每一笔产生返利的充值订单', - transfersDescription: '查看返利额度转入账户余额的提取流水', - errors: { - loadFailed: '加载邀请返利记录失败' - }, - records: { - search: '搜索', - searchPlaceholder: '邮箱、用户名、用户 ID、订单号', - startAt: '开始日期', - endAt: '结束日期', - inviter: '邀请人', - invitee: '被邀请人', - user: '用户', - affCode: '邀请码', - order: '订单', - totalRebate: '累计返利', - orderAmount: '充值金额', - payAmount: '支付金额', - rebateAmount: '返利金额', - paymentType: '支付方式', - orderStatus: '订单状态', - transferAmount: '提取金额', - balanceAfter: '提取后余额', - availableQuotaAfter: '提取后可提', - frozenQuotaAfter: '提取后冻结', - historyQuotaAfter: '提取后历史返利', - invitedAt: '邀请时间', - rebatedAt: '返利时间', - transferredAt: '提取时间' - }, - overview: { - title: '用户返利概览', - affCode: '邀请码', - rebateRate: '返利比例', - invitedCount: '邀请人数', - rebatedInviteeCount: '已产生返利人数', - availableQuota: '可提余额', - historyQuota: '历史返利' - } - }, - - // Users Management - users: { - title: '用户管理', - description: '管理用户账户和权限', - createUser: '创建用户', - editUser: '编辑用户', - deleteUser: '删除用户', - deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。", - searchPlaceholder: '邮箱/用户名/备注/API Key 模糊搜索...', - searchUsers: '邮箱/用户名/备注/API Key 模糊搜索', - roleFilter: '角色筛选', - allRoles: '全部角色', - allStatus: '全部状态', - allGroups: '全部分组', - searchGroups: '搜索分组...', - fuzzySearch: '模糊搜索', - apiKeyGroupFilter: 'API Key 分组', - apiKeyGroupExclusive: '专用分组', - apiKeyGroupPublic: '公开分组', - apiKeyGroupSubscription: '订阅分组', - apiKeyGroupDisabled: '已禁用分组', - authorizedGroupFilter: '授权分组', - allAuthorizedGroups: '全部授权分组', - searchAuthorizedGroups: '搜索授权分组...', - allApiKeyGroups: '全部 API Key 分组', - searchApiKeyGroups: '搜索 API Key 分组...', - statusFilter: '状态筛选', - allStatuses: '全部状态', - admin: '管理员', - user: '用户', - disabled: '禁用', - email: '邮箱', - password: '密码', - username: '用户名', - notes: '备注', - enterEmail: '请输入邮箱', - enterPassword: '请输入密码', - enterUsername: '请输入用户名(选填)', - enterNotes: '请输入备注(仅管理员可见)', - notesHint: '此备注仅对管理员可见', - enterNewPassword: '请输入新密码(选填)', - leaveEmptyToKeep: '留空则保持原密码不变', - generatePassword: '生成随机密码', - copyPassword: '复制密码', - creating: '创建中...', - updating: '更新中...', - columns: { - user: '用户', - id: 'ID', - email: '邮箱', - username: '用户名', - notes: '备注', - role: '角色', - groups: '分组', - subscriptions: '订阅分组', - balance: '余额', - balancePlatformQuota: '余额(平台配额)', - usage: '用量', - usageAnthropic: '用量 (Claude)', - usageOpenAI: '用量 (OpenAI)', - usageGemini: '用量 (Gemini)', - usageAntigravity: '用量 (Antigravity)', - concurrency: '并发数', - status: '状态', - lastActive: '最后活跃时间', - lastUsed: '最后使用时间', - created: '创建时间', - actions: '操作' - }, - today: '今日', - total: '近30天', - sortBy: '排序方式', - sortCurrentPageOnly: '仅对本页数据排序', - noSubscription: '暂无订阅', - publicGroupCount: '+{count} 公开', - exclusiveLabel: '专属', - publicLabel: '公开', - daysRemaining: '{days}天', - expired: '已过期', - disable: '禁用', - enable: '启用', - disableUser: '禁用用户', - enableUser: '启用用户', - viewApiKeys: '查看 API 密钥', - groups: '分组', - apiKeys: 'API密钥', - userApiKeys: '用户 API 密钥', - noApiKeys: '此用户暂无 API 密钥', - group: '分组', - none: '无', - groupChangedSuccess: '分组修改成功', - groupChangedWithGrant: '分组修改成功,已自动为用户添加「{group}」分组权限', - groupChangeFailed: '分组修改失败', - noUsersYet: '暂无用户', - createFirstUser: '创建您的第一个用户以开始使用系统', - userCreated: '用户创建成功', - userUpdated: '用户更新成功', - userDeleted: '用户删除成功', - userEnabled: '用户已启用', - userDisabled: '用户已禁用', - failedToLoad: '加载用户列表失败', - failedToCreate: '创建用户失败', - failedToUpdate: '更新用户失败', - failedToDelete: '删除用户失败', - failedToToggle: '更新用户状态失败', - failedToLoadApiKeys: '加载用户 API 密钥失败', - deleteConfirm: "确定要删除用户 '{email}' 吗?此操作无法撤销。", - roles: { - admin: '管理员', - user: '用户' - }, - form: { - emailLabel: '邮箱', - emailPlaceholder: '请输入邮箱', - usernameLabel: '用户名', - usernamePlaceholder: '请输入用户名(选填)', - notesLabel: '备注', - notesPlaceholder: '请输入备注(仅管理员可见)', - notesHint: '此备注仅对管理员可见', - passwordLabel: '密码', - passwordPlaceholder: '请输入密码(留空则不修改)', - roleLabel: '角色', - selectRole: '选择角色', - balanceLabel: '余额', - concurrencyLabel: '并发数', - statusLabel: '状态', - selectStatus: '选择状态', - rpmLimit: '每分钟请求数 (RPM)', - rpmLimitPlaceholder: '0 表示不限制', - rpmLimitHint: '该用户每分钟最大请求数,0 = 不限制;仅在所用分组未设置 rpm_limit 时作为兜底生效' - }, - adjustBalance: '调整余额', - adjustConcurrency: '调整并发数', - adjustmentAmount: '调整金额', - adjustmentAmountHint: '正数增加,负数减少', - currentBalance: '当前余额', - currentConcurrency: '当前并发数', - saving: '保存中...', - noUsers: '暂无用户', - noUsersDescription: '创建您的第一个用户以开始使用系统。', - userCreatedSuccess: '用户创建成功', - userUpdatedSuccess: '用户更新成功', - userDeletedSuccess: '用户删除成功', - balanceAdjustedSuccess: '余额调整成功', - concurrencyAdjustedSuccess: '并发数调整成功', - failedToSave: '保存用户失败', - failedToAdjust: '调整失败', - emailRequired: '请输入邮箱', - concurrencyMin: '并发数不能小于1', - soraStorageQuota: 'Sora 存储配额', - soraStorageQuotaHint: '单位 GB,0 表示使用分组或系统默认配额', - amountRequired: '请输入有效金额', - insufficientBalance: '余额不足', - setAllowedGroups: '设置允许分组', - allowedGroupsHint: '选择此用户可以使用的标准分组。订阅类型分组请在订阅管理中配置。', - noStandardGroups: '暂无标准分组', - allowAllGroups: '允许全部分组', - allowAllGroupsHint: '用户可以使用任何非专属分组', - allowedGroupsUpdated: '允许分组更新成功', - failedToLoadGroups: '加载分组列表失败', - failedToUpdateAllowedGroups: '更新允许分组失败', - // 用户分组配置 - groupConfig: '用户分组配置', - groupConfigHint: '为用户 {email} 配置专属分组倍率(覆盖分组默认倍率)', - exclusiveGroups: '专属分组', - publicGroups: '公开分组(默认可用)', - defaultRate: '默认倍率', - customRate: '专属倍率', - useDefaultRate: '使用默认', - customRatePlaceholder: '留空使用默认', - groupConfigUpdated: '分组配置更新成功', - replaceGroup: '替换分组', - clickToReplace: '点击替换分组', - replaceGroupTitle: '替换专属分组', - replaceGroupHint: '选择新分组替换「{old}」,将自动迁移绑定的 Key 并更新分组权限', - replaceGroupConfirm: '确认替换', - replaceGroupSuccess: '分组替换成功,已迁移 {count} 个 Key', - selectNewGroup: '请选择目标分组', - noOtherGroups: '没有其他可用的专属分组', - deposit: '充值', - withdraw: '退款', - depositAmount: '充值金额', - withdrawAmount: '退款金额', - withdrawAll: '全部', - depositNotesPlaceholder: '例如:新用户注册奖励、活动充值、补偿充值等', - withdrawNotesPlaceholder: '例如:服务问题退款、错误充值退回、账户注销退款等', - notesOptional: '备注为可选项,有助于未来查账', - amountHint: '请输入正数金额', - newBalance: '操作后余额', - depositing: '充值中...', - withdrawing: '退款中...', - confirmDeposit: '确认充值', - confirmWithdraw: '确认退款', - depositSuccess: '充值成功', - withdrawSuccess: '退款成功', - failedToDeposit: '充值失败', - failedToWithdraw: '退款失败', - useDepositWithdrawButtons: '请使用充值/退款按钮调整余额', - // 余额变动记录 - balanceHistory: '充值记录', - balanceHistoryTip: '点击查看充值记录', - columnAlwaysVisible: '该列固定显示,不可隐藏', - // 平台用量明细(悬浮显示) - platformBreakdown: '按平台拆分', - platformBreakdownEmpty: '暂无平台明细', - platformBreakdownHint: '悬浮查看各平台用量', - platformOther: '其他', - balanceHistoryTitle: '用户充值和并发变动记录', - noBalanceHistory: '暂无变动记录', - allTypes: '全部类型', - typeBalance: '余额(兑换码)', - typeAffiliateBalance: '余额(返利转入)', - typeAdminBalance: '余额(管理员调整)', - typeConcurrency: '并发(兑换码)', - typeAdminConcurrency: '并发(管理员调整)', - typeSubscription: '订阅', - failedToLoadBalanceHistory: '加载余额记录失败', - createdAt: '创建时间', - totalRecharged: '总充值', - // Settings Dropdowns - filterSettings: '筛选设置', - columnSettings: '列设置', - filterValue: '输入值', - // User Attributes - attributes: { - title: '用户属性配置', - description: '配置用户的自定义属性字段', - configButton: '属性配置', - addAttribute: '添加属性', - editAttribute: '编辑属性', - deleteAttribute: '删除属性', - deleteConfirm: "确定要删除属性 '{name}' 吗?所有用户的该属性值将被删除。", - noAttributes: '暂无自定义属性', - noAttributesHint: '点击上方按钮添加自定义属性', - key: '属性键', - keyHint: '用于程序引用,只能包含字母、数字和下划线', - name: '显示名称', - nameHint: '在表单中显示的名称', - type: '属性类型', - fieldDescription: '描述', - fieldDescriptionHint: '属性的说明文字', - placeholder: '占位符', - placeholderHint: '输入框的提示文字', - required: '必填', - enabled: '启用', - options: '选项配置', - optionsHint: '用于单选/多选类型', - addOption: '添加选项', - optionValue: '选项值', - optionLabel: '显示文本', - validation: '验证规则', - minLength: '最小长度', - maxLength: '最大长度', - min: '最小值', - max: '最大值', - pattern: '正则表达式', - patternMessage: '验证失败提示', - types: { - text: '单行文本', - textarea: '多行文本', - number: '数字', - email: '邮箱', - url: '链接', - date: '日期', - select: '单选', - multi_select: '多选' - }, - created: '属性创建成功', - updated: '属性更新成功', - deleted: '属性删除成功', - reordered: '属性排序更新成功', - failedToLoad: '加载属性列表失败', - failedToCreate: '创建属性失败', - failedToUpdate: '更新属性失败', - keyRequired: '请输入属性键', - nameRequired: '请输入显示名称', - optionsRequired: '请至少添加一个选项', - failedToDelete: '删除属性失败', - failedToReorder: '更新排序失败', - keyExists: '属性键已存在', - dragToReorder: '拖拽排序' - }, - platformQuota: { - menuItem: '平台限额', - title: '平台限额', - subtitle: '为用户 {email} 配置各上游平台的日 / 周 / 月用量上限', - columns: { - platform: '平台', - daily: '日 (USD)', - weekly: '周 (USD)', - monthly: '月 (USD, 30天滚动)', - usage: '当前用量', - }, - placeholder: '不限制', - save: '保存', - saving: '保存中...', - cancel: '取消', - clearAll: '全部清空(取消所有限额)', - clearAllConfirm: '确认清空全部平台的日 / 周 / 月限额?所有平台将变为"无限额",本地无法撤销,需要在保存前手动重填。', - reset: { - button: '重置该窗口', - confirm: '确认重置该用户 {platform} 平台的 {window} 用量?此操作立即生效。', - success: '已重置 {platform} {window} 用量', - failed: '重置失败', - }, - updateSuccess: '平台限额已更新', - updateFailed: '保存失败', - loadFailed: '加载失败', - hint: '留空 = 不限制该窗口。', - windowDaily: '日', - windowWeekly: '周', - windowMonthly: '月', - cellNotConfigured: '未配置', - cellColumnTooltip: '仅展示已设限额的平台', - subscriptionWarning: '此用户有活跃订阅,平台限额仅在余额(标准)模式下生效,订阅模式请求不受此限额约束。', - invalidNumber: '以下字段填写不是合法数字,请修正后再保存:{fields}', - } - }, - - // Groups Management - groups: { - title: '分组管理', - description: '管理 API 密钥分组和费率配置', - searchGroups: '搜索分组...', - createGroup: '创建分组', - editGroup: '编辑分组', - deleteGroup: '删除分组', - sortOrder: '排序', - columnSettings: '列设置', - sortOrderHint: '拖拽分组调整显示顺序,排在前面的分组会优先显示', - sortOrderUpdated: '排序已更新', - failedToUpdateSortOrder: '更新排序失败', - deleteConfirm: "确定要删除分组 '{name}' 吗?所有关联的 API 密钥将不再属于任何分组。", - deleteConfirmSubscription: - "确定要删除订阅分组 '{name}' 吗?此操作会让所有绑定此订阅的用户的 API Key 失效,并删除所有相关的订阅记录。此操作无法撤销。", - columns: { - name: '名称', - platform: '平台', - rateMultiplier: '费率倍数', - rpmOverride: 'RPM 覆盖', - rpmOverrideHint: '该用户在此分组的 RPM 上限;留空 = 使用分组默认;0 = 不限制', - rateDefault: '默认', - rpmDefault: '默认', - exclusive: '独占', - type: '类型', - priority: '优先级', - apiKeys: 'API 密钥数', - accounts: '账号数', - capacity: '容量', - usage: '用量', - status: '状态', - actions: '操作', - billingType: '计费类型', - userName: '用户名', - userEmail: '邮箱', - userNotes: '备注', - userStatus: '状态' - }, - usageToday: '今日', - usageTotal: '累计', - accountsAvailable: '可用:', - accountsRateLimited: '限流:', - accountsTotal: '总量:', - accountsUnit: '个账号', - form: { - name: '名称', - description: '描述', - platform: '平台', - rateMultiplier: '费率倍数', - status: '状态', - exclusive: '专属分组', - nameLabel: '分组名称', - namePlaceholder: '请输入分组名称', - descriptionLabel: '描述', - descriptionPlaceholder: '请输入描述(可选)', - rateMultiplierLabel: '费率倍数', - rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍', - rpmLimit: '每分钟请求数 (RPM)', - rpmLimitPlaceholder: '0 表示不限制', - rpmLimitHint: '每用户在本分组每分钟最大请求数,0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit)', - exclusiveLabel: '专属分组', - exclusiveHint: '专属分组,可以手动指定给用户', - platformLabel: '平台限制', - platformPlaceholder: '选择平台(留空则不限制)', - accountsLabel: '指定账号', - accountsPlaceholder: '选择账号(留空则不限制)', - priorityLabel: '优先级', - priorityHint: '数值越小优先级越高,用于账号调度', - statusLabel: '状态' - }, - exclusiveObj: { - yes: '是', - no: '否' - }, - exclusive: '专属', - exclusiveHint: '专属分组,可以手动指定给特定用户', - exclusiveTooltip: { - title: '什么是专属分组?', - description: - '开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。', - example: '使用场景:', - exampleContent: - '公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。' - }, - rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍', - platforms: { - all: '全部平台', - anthropic: 'Anthropic', - openai: 'OpenAI', - gemini: 'Gemini', - antigravity: 'Antigravity', - grok: 'Grok', - }, - saving: '保存中...', - noGroups: '暂无分组', - noGroupsDescription: '创建分组以更好地管理 API 密钥和费率。', - groupCreatedSuccess: '分组创建成功', - groupUpdatedSuccess: '分组更新成功', - groupDeletedSuccess: '分组删除成功', - failedToLoad: '加载分组列表失败', - failedToSave: '保存分组失败', - failedToDelete: '删除分组失败', - allPlatforms: '全部平台', - allStatus: '全部状态', - allGroups: '全部分组', - exclusiveFilter: '专属', - nonExclusive: '公开', - public: '公开', - rateAndAccounts: '{rate}x 费率 · {count} 个账号', - accountsCount: '{count} 个账号', - rateLabel: '倍率', - accountFilters: { - title: '账号过滤控制', - oauthOnly: '仅允许 OAuth 账号', - oauthOnlyEnabled: '已启用 — 排除 API Key 类型账号', - privacySetOnly: '仅允许隐私保护已设置的账号', - privacySetOnlyEnabled: '已启用 — Privacy 未设置的账号将被排除', - disabled: '未启用' - }, - enterGroupName: '请输入分组名称', - optionalDescription: '可选描述', - platformHint: '选择此分组关联的平台', - platformNotEditable: '创建后不可更改平台', - noGroupsYet: '暂无分组', - createFirstGroup: '创建您的第一个分组来组织 API 密钥。', - creating: '创建中...', - updating: '更新中...', - limitDay: '日', - limitWeek: '周', - limitMonth: '月', - groupCreated: '分组创建成功', - groupUpdated: '分组更新成功', - groupDeleted: '分组删除成功', - failedToCreate: '创建分组失败', - failedToUpdate: '更新分组失败', - nameRequired: '请输入分组名称', - rateMultipliers: '专属倍率', - rateMultipliersTitle: '分组专属倍率管理', - addUserRate: '添加用户专属倍率', - rpmOverrides: '专属 RPM', - rpmOverridesTitle: '分组专属 RPM 管理', - addUserRpm: '添加用户专属 RPM', - noRpmOverrides: '暂无用户设置了专属 RPM', - rpmSaved: '专属 RPM 已保存', - groupRpmDefault: '分组默认 RPM', - searchUserPlaceholder: '搜索用户邮箱...', - noRateMultipliers: '暂无用户设置了专属倍率', - rateUpdated: '专属倍率已更新', - rateDeleted: '专属倍率已删除', - rateAdded: '专属倍率已添加', - clearAll: '全部清空', - confirmClearAll: '确定要清空该分组所有用户的专属倍率设置吗?此操作不可撤销。', - rateCleared: '已清空所有专属倍率', - batchAdjust: '批量调整倍率', - multiplierFactor: '乘数', - applyMultiplier: '应用', - rateAdjusted: '倍率已批量调整', - rateSaved: '专属倍率已保存', - finalRate: '最终倍率', - unsavedChanges: '有未保存的修改', - revertChanges: '撤销修改', - userInfo: '用户信息', - subscription: { - title: '订阅设置', - type: '计费类型', - typeHint: '标准计费从用户余额扣除。订阅模式使用配额限制。', - typeNotEditable: '分组创建后无法修改计费类型。', - standard: '标准(余额)', - subscription: '订阅(配额)', - dailyLimit: '每日限额(USD)', - weeklyLimit: '每周限额(USD)', - monthlyLimit: '每月限额(USD)', - defaultValidityDays: '默认有效期(天)', - validityHint: '分配给用户时订阅的有效天数', - noLimit: '无限制' - }, - imagePricing: { - title: '图片生成计费', - description: '配置图片生成能力和图片基础单价,留空则使用默认价格', - allowImageGeneration: '允许当前分组生图', - allowBatchImageGeneration: '允许当前分组批量生图', - independentMultiplier: '生图倍率独立', - imageMultiplier: '生图独立倍率', - batchDiscountMultiplier: '批量生图折扣倍率', - batchHoldMultiplier: '批量冻结价格比例', - batchSectionHint: '批量生图仅影响批量任务:结算价格会叠加批量折扣倍率,提交时冻结金额按普通生图原价 × 批量冻结价格比例计算。参考图也会产生上游输入 token 消耗,建议批量生图折扣倍率设置大于 0.5。', - batchDisabledHint: '请先开启当前分组生图,才能开启批量生图。', - batchGeminiOnlyHint: '批量生图当前仅支持 Gemini 分组。', - modeHint: '默认关闭独立倍率时,图片费用 = 图片价格 × 当前分组有效倍率;开启独立倍率后,图片费用 = 图片价格 × 生图独立倍率。', - finalPricePreview: '最终单张价格预览', - notConfigured: '未配置' - }, - peakRate: { - enable: '启用高峰倍率', - peakStart: '高峰开始', - peakEnd: '高峰结束', - peakMultiplier: '高峰倍率', - multiplierHint: '作用于 token 计费倍率;token 计费的图片 token 同样适用,0 表示高峰 token 请求按 0 倍计费' - }, - modelsList: { - title: '自定义 /v1/models 模型列表', - hint: '仅影响 /v1/models 展示结果,不影响白名单模型调用和账号调度。', - loading: '正在加载模型列表...', - empty: '暂无可展示模型', - selectedSummary: '已选 {selected} / {total}', - selectAll: '全选', - invertSelection: '反选' - }, - claudeCode: { - title: 'Claude Code 客户端限制', - tooltip: - '启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。', - enabled: '仅限 Claude Code', - disabled: '允许所有客户端', - fallbackGroup: '降级分组', - fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝', - noFallback: '不降级(直接拒绝)' - }, - openaiMessages: { - title: 'OpenAI Messages 调度配置', - allowDispatch: '允许 /v1/messages 调度', - allowDispatchHint: '启用后,此 OpenAI 分组的 API Key 可以通过 /v1/messages 端点调度请求', - familyMappingTitle: '系列默认映射', - familyMappingHint: '当请求命中 Opus、Sonnet、Haiku 系列时,会优先使用这里配置的目标模型。', - opusModel: 'Opus 映射模型', - opusModelPlaceholder: '例如: gpt-5.4', - sonnetModel: 'Sonnet 映射模型', - sonnetModelPlaceholder: '例如: gpt-5.3-codex', - haikuModel: 'Haiku 映射模型', - haikuModelPlaceholder: '例如: gpt-5.4-mini', - exactMappingTitle: '精确模型覆盖', - exactMappingHint: '精确 Claude 模型覆盖优先级高于系列默认映射,可将某个具体 Claude 模型单独映射到不同的目标模型。', - noExactMappings: '暂无精确模型覆盖规则', - addExactMapping: '添加精确映射', - claudeModel: 'Claude 模型', - claudeModelPlaceholder: '例如: claude-sonnet-4-5-20250929', - targetModel: '目标模型', - targetModelPlaceholder: '例如: gpt-5.4', - removeExactMapping: '删除精确映射' - }, - invalidRequestFallback: { - title: '无效请求兜底分组', - hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底', - noFallback: '不兜底' - }, - copyAccounts: { - title: '从分组复制账号', - tooltip: '选择一个或多个相同平台的分组,创建后会自动将这些分组的所有账号绑定到新分组(去重)。', - tooltipEdit: '选择一个或多个相同平台的分组,保存后当前分组的账号会被替换为这些分组的账号(去重)。', - selectPlaceholder: '选择分组以复制其账号...', - hint: '可选多个分组,账号会自动去重', - hintEdit: '⚠️ 注意:这会替换当前分组的所有账号绑定' - }, - modelRouting: { - title: '模型路由配置', - tooltip: - '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。', - enabled: '已启用', - disabled: '已禁用', - disabledHint: '启用后,配置的路由规则才会生效', - addRule: '添加路由规则', - modelPattern: '模型模式', - modelPatternPlaceholder: 'claude-opus-*', - modelPatternHint: '支持 * 通配符,如 claude-opus-* 匹配所有 opus 模型', - accounts: '优先账号', - selectAccounts: '选择账号', - noAccounts: '此分组暂无账号', - loadingAccounts: '加载账号中...', - removeRule: '删除规则', - noRules: '暂无路由规则', - noRulesHint: '添加路由规则以将特定模型请求优先路由到指定账号', - searchAccountPlaceholder: '搜索账号...', - accountsHint: '选择此模型模式优先使用的账号' - }, - mcpXml: { - title: 'MCP XML 协议注入', - tooltip: '启用后,当请求包含 MCP 工具时,会在 system prompt 中注入 XML 格式调用协议提示词。关闭此选项可避免对某些客户端造成干扰。', - enabled: '已启用', - disabled: '已禁用' - }, - supportedScopes: { - title: '支持的模型系列', - tooltip: '选择此分组支持的模型系列。未勾选的系列将不会被路由到此分组。', - claude: 'Claude', - geminiText: 'Gemini Text', - geminiImage: 'Gemini Image', - hint: '至少选择一个模型系列' - } - }, - - // Available Channels (aggregated read-only view) - availableChannels: { - title: '可用渠道', - description: '按渠道聚合查看关联分组与支持模型(已展开通配符)', - searchPlaceholder: '搜索渠道或模型...', - columns: { - name: '渠道名', - status: '状态', - billingSource: '计费模型来源', - groups: '关联分组', - supportedModels: '支持模型' - }, - empty: '暂无数据', - noGroups: '未关联分组', - noModels: '未配置模型映射', - noPricing: '未配置定价', - statusActive: '启用', - statusDisabled: '停用', - billingSource: { - requested: '请求模型', - upstream: '上游模型', - channel_mapped: '映射后模型' - }, - pricing: { - billingMode: '计费模式', - billingModeToken: '按 Token', - billingModePerRequest: '按次', - billingModeImage: '按图片', - inputPrice: '输入', - outputPrice: '输出', - cacheWritePrice: '缓存写入', - cacheReadPrice: '缓存读取', - imageOutputPrice: '图片输出', - perRequestPrice: '每次请求', - intervals: '阶梯定价', - unitPerMillion: '/ 1M token', - unitPerRequest: '/ 次' - } - }, - - // Channel Management - channels: { - title: '渠道管理', - description: '管理渠道和自定义模型定价', - searchChannels: '搜索渠道...', - createChannel: '创建渠道', - editChannel: '编辑渠道', - deleteChannel: '删除渠道', - statusActive: '启用', - statusDisabled: '停用', - allStatus: '全部状态', - groupsUnit: '个分组', - pricingUnit: '条定价', - noChannelsYet: '暂无渠道', - createFirstChannel: '创建第一个渠道来管理模型定价', - loadError: '加载渠道列表失败', - createSuccess: '渠道创建成功', - updateSuccess: '渠道更新成功', - deleteSuccess: '渠道删除成功', - createError: '创建渠道失败', - updateError: '更新渠道失败', - deleteError: '删除渠道失败', - nameRequired: '请输入渠道名称', - duplicateModels: '模型「{0}」在多个定价条目中重复', - modelConflict: "模型模式 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。模型名称按大小写不敏感匹配,已有条目已覆盖其所有大小写变体,无需重复添加。", - mappingConflict: "模型映射源 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。源模式按大小写不敏感匹配,已有条目已覆盖其所有大小写变体。", - intervalValidation: { - negativeMin: '区间 #{index}:最小 token 数({value})不能为负数', - maxPositive: '区间 #{index}:最大 token 数({value})必须大于 0', - maxGreaterThanMin: '区间 #{index}:最大 token 数({max})必须大于最小 token 数({min})', - negativePrice: '区间 #{index}:{field}不能为负数', - unboundedLast: '区间 #{index}:无上限区间(最大 token 数为空)必须放在最后', - overlap: '区间 #{previousIndex} 和 #{currentIndex} 重叠:前一个上界({previousMax})大于当前下界({currentMin})', - price: { - inputPrice: '输入价格', - outputPrice: '输出价格', - cacheWritePrice: '缓存写入价格', - cacheReadPrice: '缓存读取价格', - perRequestPrice: '单次价格' - } - }, - deleteConfirm: '确定要删除渠道「{name}」吗?此操作不可撤销。', - columns: { - name: '名称', - description: '描述', - status: '状态', - groups: '分组', - pricing: '定价', - createdAt: '创建时间', - actions: '操作' - }, - billingMode: { - token: 'Token', - perRequest: '按次', - image: '图片(按次)' - }, - form: { - name: '名称', - namePlaceholder: '输入渠道名称', - description: '描述', - descriptionPlaceholder: '可选描述', - status: '状态', - groups: '关联分组', - noGroupsAvailable: '暂无可用分组', - inOtherChannel: '已属于「{name}」', - modelPricing: '模型定价', - models: '模型列表', - modelsPlaceholder: '输入完整模型名后按回车添加', - modelInputHint: '按回车添加,支持粘贴批量导入', - billingMode: '计费模式', - defaultPrices: '默认价格(未命中区间时使用)', - inputPrice: '输入', - outputPrice: '输出', - cacheWritePrice: '缓存写入', - cacheReadPrice: '缓存读取', - cacheWritePriceShort: '缓存写', - cacheReadPriceShort: '缓存读', - imageTokenPrice: '图片输出', - imageOutputPrice: '图片输出价格', - pricePlaceholder: '默认', - intervals: '上下文区间定价(可选)', - minTokens: '最小', - maxTokens: '最大', - inclusive: '(含)', - addInterval: '添加区间', - requestTiers: '按次计费层级', - imageTiers: '图片计费层级(按次)', - addTier: '添加层级', - noTiersYet: '暂无层级,点击添加配置按次计费价格', - noPricingRules: '暂无定价规则,点击"添加"创建', - perRequestPrice: '单次价格', - perRequestPriceRequired: '按次/图片计费模式必须设置默认价格或至少一个计费层级', - tierLabel: '层级', - resolution: '分辨率', - modelMapping: '模型映射', - modelMappingHint: '将请求中的模型名映射为实际模型名。在账号级别映射之前执行。', - noMappingRules: '暂无映射规则,点击"添加"创建', - mappingSource: '源模型', - mappingTarget: '目标模型', - billingModelSource: '计费基准', - billingModelSourceChannelMapped: '以渠道映射后的模型计费', - billingModelSourceRequested: '以请求模型计费', - billingModelSourceUpstream: '以最终模型计费', - billingModelSourceHint: '控制使用哪个模型名称进行定价查找', - selectedCount: '已选 {count} 个', - searchGroups: '搜索分组...', - noGroupsMatch: '没有匹配的分组', - restrictModels: '限制模型', - restrictModelsHint: '开启后,仅允许模型定价列表中的模型。不在列表中的模型请求将被拒绝。', - defaultPerRequestPrice: '默认单次价格(未命中层级时使用)', - defaultImagePrice: '默认图片价格(未命中层级时使用)', - platformConfig: '平台配置', - webSearchEmulation: 'Web Search 模拟', - webSearchEmulationHint: '⚠️ 开启后该渠道下所有 Anthropic 分组的账号将自动拦截 web_search 请求,请谨慎操作', - webSearchEmulationGlobalDisabled: '请先在系统设置 → 网关 → Web Search 模拟中启用全局开关', - codexImageGenerationBridge: 'Codex 图片生成桥接', - codexImageGenerationBridgeHint: '开启后,OpenAI 分组的 Codex /responses 文本请求可能会被自动注入 image_generation 工具。仅在路由账号支持图片生成时开启。', - bedrockCCCompat: 'Bedrock CC 兼容', - bedrockCCCompatHint: '⚠️ 开启后,该渠道下 Bedrock 账号的请求将进行 Claude Code 兼容处理(thinking 类型转换、tool_use ID 清理)', - basicSettings: '基础设置', - addPlatform: '添加平台', - noPlatforms: '点击"添加平台"开始配置渠道', - mappingCount: '条映射', - pricingEntry: '定价配置', - noModels: '未添加模型', - applyPricingToAccountStats: '应用模型定价到账号统计', - applyPricingToAccountStatsDesc: '启用后,未被自定义规则匹配的请求将使用模型定价文件中的标准价格计算账号统计费用', - accountStatsPricingRules: '自定义账号统计定价规则', - addRule: '添加规则', - noRulesConfigured: '未配置自定义规则,将使用上方的模型定价。', - ruleName: '规则名称(可选)', - ruleGroups: '分组', - ruleAccounts: '账号', - searchAccountPlaceholder: '搜索账号...', - ruleAccountsHint: '留空表示匹配所有账号', - ruleModelPricing: '模型定价', - noGroupsInChannel: '上方平台标签页中未选择分组', - unnamed: '未命名', - syncLatestModels: '同步最新模型', - syncingModels: '同步中...', - syncModelsSuccess: '已同步 {count} 个新模型', - syncModelsAlreadyUpToDate: '模型列表已是最新', - syncModelsError: '同步模型失败' - } - }, - - riskControl: { - title: '风控中心', - description: '配置内容审计策略并查看审核记录', - loadFailed: '加载风控中心失败', - saveFailed: '保存内容审计配置失败', - logsFailed: '加载审核记录失败', - saved: '内容审计配置已保存', - refresh: '刷新', - config: '内容审计配置', - configHint: '调用 OpenAI Moderations 进行请求内容评分,命中阈值后按模式处理。', - openSettings: '内容审计设置', - settingsTitle: '内容审计设置', - refreshStatus: '刷新状态', - records: '审核记录', - recordsHint: '展示命中、拦截、异常和已采样记录。', - saveConfig: '保存内容审计配置', - statusFailed: '加载运行状态失败', - enabled: '开启内容审计', - enabledHint: '关闭后即使风控中心菜单启用,也不会审核网关请求。', - mode: '全局模式', - modePreBlock: '前置拦截', - modePreBlockDesc: '每次请求先同步审核最新用户输入,命中后立即拒绝请求。', - modeObserve: '仅观察', - modeObserveDesc: '请求直接放行,最新用户输入进入异步审核队列;命中后只记录、通知和按规则累计。', - modeOff: '关闭', - modeOffDesc: '不执行内容审计,也不会写入审核记录。', - baseUrl: 'OpenAI Base URL', - model: '模型名', - apiKey: 'OpenAI API Key', - apiKeys: 'OpenAI API Keys', - apiKeyCount: '{count} 个 Key', - apiKeyPlaceholder: '请输入 API Key', - apiKeysPlaceholder: '新增 API Key,每行一个;保存后会追加到已保存 Key', - apiKeysPlaceholderReplace: '覆盖保存 API Key,每行一个;保存后会替换全部已保存 Key', - apiKeysPlaceholderKeep: '新增 API Key,每行一个;保存后会追加到已保存 Key', - apiKeysHint: '当前已保存 {count} 个 Key;输入区只用于新增,保存时会增量追加并自动去重。', - apiKeysWriteMode: '写入方式', - apiKeysModeAppend: '增量添加', - apiKeysModeReplace: '覆盖保存', - apiKeysModeAppendHint: '默认模式:保存时追加输入区 Key,并保留已保存 Key。', - apiKeysModeReplaceHint: '覆盖模式:保存时用输入区 Key 替换全部已保存 Key。', - apiKeysReplaceWarning: '覆盖模式', - apiKeysReplaceNoInput: '覆盖保存至少需要输入 1 个 API Key', - apiKeyPlaceholderKeep: '留空保持不变', - apiKeyWillClear: '保存后清除已配置 Key', - apiKeyConfigured: '已配置', - apiKeyTemporary: '待保存', - apiKeyPendingDelete: '待删除', - apiKeyPendingDeleteCount: '待删除 {count} 个 Key', - deleteApiKey: '删除这个 Key', - undoDeleteApiKey: '撤销删除', - inputApiKeyCount: '输入区 {count} 个 Key', - storedApiKeyCount: '已保存 {count} 个 Key', - testInputApiKeys: '测试输入区 Key', - testStoredApiKeys: '测试已保存 Key', - testContentWithStoredApiKey: '用已保存 Key 试跑内容', - testingApiKeys: '测试中', - apiKeyTestNoInput: '请先输入需要测试的 OpenAI API Key', - apiKeyTestDone: 'Key 测试完成,共 {count} 个', - apiKeyTestFailed: '测试 OpenAI API Key 失败', - apiKeyHealth: 'Key 可用状态', - apiKeyFreezeRule: '400 不冻结;401/403 冻结 10 分钟;429/529 冻结 1 分钟;其他 HTTP 错误冻结 10 秒。', - apiKeyRows: '{count} 个 Key', - apiKeyRowsCollapsed: '已隐藏 {count} 个 Key', - apiKeyRowsExpanded: '正在显示全部 {count} 个 Key', - expandApiKeyRows: '展开', - collapseApiKeyRows: '收起', - apiKeyHealthEmpty: '暂无 Key 状态', - apiKeyHealthEmptyHint: '保存 Key 或测试输入区 Key 后会显示可用性。', - apiKeyStatusOk: '可用', - apiKeyStatusError: '异常', - apiKeyStatusFrozen: '冻结', - apiKeyStatusUnknown: '未测试', - apiKeyFailureCount: '失败 {count} 次', - apiKeyLatency: '{ms} ms', - apiKeyHTTPStatus: 'HTTP {status}', - apiKeyFrozenUntil: '冻结至 {time}', - apiKeyLastChecked: '检查于 {time}', - apiKeyNotTested: '尚未测试', - auditTestInput: '审计试跑输入', - auditTestInputHint: '可填写提示词并上传或粘贴图片;图片以 base64 发送,不会保存文件。', - auditTestPromptPlaceholder: '输入要测试的用户提示词;留空时仅测试 Key 可用性。', - auditTestImages: '测试图片', - auditTestImagesHint: '支持上传、拖拽或粘贴图片,最多 1 张,每张不超过 8MB。', - addAuditTestImage: '添加图片', - clearAuditTest: '清空试跑', - auditTestImageLimit: '最多只能添加 {count} 张测试图片', - auditTestImageTooLarge: '单张测试图片不能超过 8MB', - auditTestImageReadFailed: '读取测试图片失败', - auditTestResult: '审计试跑结果', - auditTestHighest: '最高分类 {category},分数 {score}', - auditTestComposite: '综合评分', - auditTestFlagged: '命中阈值', - auditTestPassed: '未命中', - notConfigured: '未配置', - clearApiKey: '清除已保存 Key', - keepApiKey: '保留已保存 Key', - timeoutMs: 'HTTP 超时 (ms)', - retryCount: '失败重试次数', - sampleRate: '采样率', - recordNonHits: '记录未命中输入', - recordNonHitsHint: '开启后会记录抽样但未命中的请求摘要,摘要会先脱敏再入库。', - preHashCheck: '启用前置哈希比对', - preHashCheckHint: '异步审核命中过的输入哈希会被前置拦截;该拦截不发送邮件,也不累计封禁次数。', - flaggedHashCount: '当前哈希集合数量:{count} 个', - flaggedHashHint: '哈希永久保存在 Redis 集合中;可粘贴完整 64 位哈希删除误拦截项,或一键清空全部风险哈希。', - flaggedHashPlaceholder: '粘贴完整 64 位输入哈希', - deleteFlaggedHash: '删除指定哈希', - clearFlaggedHashes: '一键清空', - clearFlaggedHashesConfirm: '确定要清空全部风险输入哈希吗?此操作不会删除审核记录,但会取消所有历史哈希拦截。', - flaggedHashDeleted: '风险哈希已删除', - flaggedHashNotFound: '该风险哈希不存在', - flaggedHashDeleteFailed: '删除风险哈希失败', - flaggedHashesCleared: '已清空 {count} 个风险哈希', - flaggedHashesClearFailed: '清空风险哈希失败', - workerCount: 'Worker 数', - queueSize: '异步队列大小', - blockStatus: '拦截 HTTP 状态码', - blockMessage: '自定义拦截提示', - defaultBlockMessage: '内容审计命中风险规则,请调整输入后重试', - emailOnHit: '命中后发送邮件', - emailOnHitHint: '开启后每次达到阈值都会向用户发送风控提醒邮件;自动封禁通知始终发送。', - autoBan: '自动封禁用户', - autoBanHint: '命中次数达到阈值后将禁用用户账号、刷新认证缓存并发送封禁通知邮件。', - cyberPolicyExcludeBan: 'cyber_policy 不计入封号次数', - cyberPolicyExcludeBanHint: '开启后,cyber_policy 拦截不再计入自动封号的违规次数:当次不判定封号,历史累计亦排除。风控日志与通知邮件照常。', - violationNotCounted: '未计入封号', - banThreshold: '封禁触发次数', - violationWindowHours: '累计窗口(小时)', - hitRetentionDays: '命中记录保留(天)', - nonHitRetentionDays: '未命中记录保留(天,最多 3 天)', - violationCount: '{count} 次', - emailSent: '已发邮件', - emailNotSent: '未发邮件', - autoBanned: '已封禁', - unbanUser: '解封', - unbanSuccess: '用户已解封', - unbanFailed: '解封用户失败', - inputDetailTitle: '输入摘要详情', - inputDetailContent: '完整内容', - matchedKeyword: '命中关键词', - queueDelay: '排队 {ms} ms', - allGroups: '全部分组', - allGroupsHint: '当前审计全部分组', - selectedGroupsHint: '当前审计指定分组', - groupScope: '审计分组', - groupScopeHint: '开启右侧开关表示全部分组,关闭后选择指定分组。', - selectedGroups: '指定分组', - searchGroups: '搜索分组名称或平台', - noGroups: '暂无可用分组', - modelFilter: '模型范围', - modelFilterHint: '按客户端请求的模型名决定是否执行内容审计,模型映射后仍以请求模型判断。', - modelFilterAll: '所有模型', - modelFilterAllDesc: '所有模型请求都会进入内容审计。', - modelFilterInclude: '仅指定模型', - modelFilterIncludeDesc: '只有列表中的模型会执行内容审计。', - modelFilterExclude: '排除指定模型', - modelFilterExcludeDesc: '列表中的模型跳过内容审计,其余模型执行审计。', - modelFilterModels: '模型列表', - modelFilterModelCount: '已配置 {count} 个模型', - modelFilterModelsRequired: '当前模型范围至少需要配置 1 个模型', - modelFilterAllSummary: '全部模型生效', - modelFilterIncludeSummary: '仅 {count} 个模型生效', - modelFilterExcludeSummary: '排除 {count} 个模型', - emptyLogs: '暂无审核记录', - preBlockSyncStatus: '前置拦截同步状态', - preBlockSyncHint: '同步审核链路的实时计数,不包含异步写记录任务。', - preBlockActive: '同步处理中', - preBlockActiveHint: '当前正在审核', - preBlockChecked: '已检查', - preBlockCheckedHint: '进入前置拦截链路', - preBlockAllowed: '已放行', - preBlockAllowedHint: '未触发拦截', - preBlockBlocked: '已拦截', - preBlockBlockedHint: '命中后拒绝请求', - preBlockErrors: '审核异常', - preBlockErrorsHint: '失败或无可用 Key', - preBlockAvgLatency: '平均耗时', - preBlockAvgLatencyHint: '同步链路平均值', - preBlockAPIKeyLoad: '审核 Key 负载', - preBlockAPIKeyLoadHint: '同步前置拦截直接轮询可用审核 Key。', - preBlockAPIKeyLoadSummary: '同步并发 {active} / 可用 Key {available},累计 {total} 次,worker:{workerActive} / {workerTotal}', - preBlockAPIKeyTotals: '累计 {total},成功 {success},异常 {errors}', - preBlockAPIKeyLoadEmpty: '暂无审核 Key 负载数据', - preBlockKeyActiveShort: '并发', - preBlockKeyTotalShort: '累计', - preBlockKeyAvgShort: '平均', - preBlockKeyLastShort: '最近', - workerStatus: 'Worker 运行状态', - workerStatusHint: '异步审计任务和前置拦截记录任务的队列与 Worker 池状态,不包含同步前置拦截审核请求。', - workerPool: 'Worker 池', - workerPoolMeta: '{active} 个处理中,{idle} 个空闲可用,共 {total} 个', - queueUsage: '队列占用', - activeWorkers: '处理中', - idleWorkers: '空闲可用', - workerActive: '正在处理异步审计或记录任务', - workerIdle: '已启动,当前空闲可用', - workerDisabled: '风控或内容审计未启用', - processed: '已处理', - droppedErrors: '丢弃/异常', - autoRefresh: '每 15 秒自动刷新', - lastCleanup: '上次清理:{time}', - cleanupStats: '上次清理删除命中 {hit} 条,未命中 {nonHit} 条', - riskSwitchOff: '系统开关关闭', - riskThresholds: '风险阈值', - riskThresholdsHint: '按 OpenAI Moderations 分类调整命中阈值,分数达到或超过阈值即视为命中。', - riskThresholdDefault: '默认 {value}', - riskThresholdReset: '恢复默认阈值', - riskThresholdPercent: '阈值百分比', - tabs: { - basic: '基础', - scope: '审计范围', - runtime: '运行队列', - response: '命中通知', - riskThresholds: '风险阈值', - keywords: '关键词拦截', - retention: '日志保留', - }, - blockedKeywords: '拦截关键词', - blockedKeywordsPlaceholder: '每行输入一个关键词,例如:\n敏感词1\n敏感词2', - blockedKeywordsDescription: '匹配忽略大小写;命中后会按下方策略决定是否调用上游审计接口。', - blockedKeywordsPreBlockHint: '关键词拦截仅在「前置拦截」模式下生效。', - blockedKeywordsModeWarning: '当前为「{mode}」模式,关键词拦截不会生效;请切换到「前置拦截」模式后再保存关键词。', - blockedKeywordCount: '已配置 {count} 个关键词', - blockedKeywordsLimit: '最多保存 {max} 个关键词,单个长度不超过 200 个字符;重复项会自动去重。', - keywordBlockingMode: '审计策略', - keywordModeKeywordAndApi: '关键词 + API', - keywordModeKeywordAndApiDesc: '命中关键词直接拦截;未命中时再调用上游审计接口。', - keywordModeKeywordOnly: '仅关键词', - keywordModeKeywordOnlyDesc: '只用关键词判断,未命中即放行,不调用上游审计接口,可显著降低 API 用量。', - keywordModeKeywordOnlyNotice: '当前为「仅关键词」策略:未命中关键词的请求将直接放行,不调用上游审计接口。', - keywordModeApiOnly: '仅 API', - keywordModeApiOnlyDesc: '只调用上游审计接口判断,本页的关键词列表将不会生效。', - keywordModeApiOnlyNotice: '当前为「仅 API」策略:关键词列表不会生效,请求会全部交给上游审计接口判断。', - overview: { - status: '运行状态', - enabled: '已启用', - disabled: '未启用', - apiKey: 'API Key', - groupScope: '审计范围', - logs: '审核记录', - currentFilter: '当前筛选结果', - }, - filters: { - search: '按用户/Key/摘要搜索', - from: '开始时间', - to: '结束时间', - allGroups: '全部分组', - allEndpoints: '全部端点', - }, - table: { - time: '时间', - group: '分组', - user: '用户', - apiKey: 'API Key', - endpoint: '端点', - result: '结果', - highest: '最高分', - actionMeta: '处置', - latency: '上游耗时', - input: '输入摘要', - }, - result: { - all: '全部结果', - hit: '命中', - blocked: '已拦截', - pass: '未命中', - error: '异常', - }, - action: { - block: '拦截', - keywordBlock: '关键词拦截', - cyberPolicy: '网络安全策略', - error: '异常', - }, - }, - - // Channel Monitor - channelMonitor: { - title: '渠道监控', - description: '监测各渠道的可用性、延迟和状态', - searchPlaceholder: '搜索监控名称...', - allProviders: '全部供应商', - allStatus: '全部状态', - enabledFilter: '启用状态', - onlyEnabled: '仅启用', - onlyDisabled: '仅禁用', - createButton: '新增监控', - createTitle: '新增渠道监控', - editTitle: '编辑渠道监控', - runNow: '立即检测', - runSuccess: '检测完成', - runFailed: '检测失败', - apiKeyDecryptFailed: 'API Key 解密失败,请重新编辑该监控并填入新的 Key', - createSuccess: '监控创建成功', - updateSuccess: '监控更新成功', - deleteSuccess: '监控删除成功', - loadError: '加载监控列表失败', - deleteConfirm: '确定要删除监控「{name}」吗?此操作不可撤销。', - nameRequired: '请输入监控名称', - primaryModelRequired: '请输入主模型', - columns: { - name: '名称', - provider: '供应商', - primaryModel: '主模型', - availability7d: '7 天可用率', - latency: '延迟 (ms)', - enabled: '启用', - actions: '操作' - }, - form: { - name: '名称', - namePlaceholder: '输入监控名称', - provider: '平台', - apiMode: 'OpenAI 协议', - apiModeChatCompletions: 'OpenAI Compatible', - apiModeChatCompletionsHint: '使用 /v1/chat/completions,发送 messages;适合大多数兼容站。', - apiModeResponses: 'Responses API', - apiModeResponsesHint: '使用 /v1/responses,默认带 instructions + input;适合本站自检/Codex。', - endpoint: '上游地址', - endpointPlaceholder: 'https://api.example.com', - useCurrentDomain: '使用当前服务', - apiKey: 'API Key', - apiKeyPlaceholder: '请输入 API Key', - apiKeyEditPlaceholder: '留空表示不修改', - useMyKey: '使用我的 Key', - selectKeyTitle: '选择我的 API Key', - selectKeyHint: '仅显示当前账号下处于「启用」状态且未过期的 Key。', - noActiveKey: '没有可用的启用状态 Key', - primaryModel: '主模型', - primaryModelPlaceholder: 'gpt-4o-mini', - extraModels: '附加模型', - extraModelsPlaceholder: '回车添加附加模型', - groupName: '分组名称', - groupNamePlaceholder: '可选,用于在用户视图中聚合显示', - intervalSeconds: '检测间隔 (秒)', - intervalSecondsHint: '范围:15 - 3600 秒', - jitterSeconds: '随机抖动 (± 秒)', - jitterSecondsHint: '每次检测在间隔基础上正负随机偏移该秒数,0 表示固定间隔;需满足 间隔 - 抖动 ≥ 15 秒', - enabled: '启用监控', - kindRequired: '请选择供应商' - }, - runResultTitle: '检测结果', - noMonitorsYet: '暂无监控', - createFirstMonitor: '创建第一个监控来跟踪渠道可用性', - advanced: { - section: '高级(可选)', - sectionHint: '自定义请求头和请求体,用于突破上游的客户端识别限制(如仅允许 Claude Code 客户端)。', - headers: '自定义请求头', - headersPlaceholder: 'User-Agent: claude-cli/1.0.83 (external, cli)\nx-app: cli\nanthropic-beta: claude-code-20250219', - headerNamePlaceholder: 'Header 名', - headerValuePlaceholder: 'Value', - headerAddRow: '添加 Header', - headerNameInvalid: 'Header 名不能包含空格或冒号:{name}', - headersHint: '与默认请求头合并,用户值优先。hop-by-hop 类 header(Host/Content-Length/...)会被忽略。', - headersParseError: '无法解析这一行:{line}', - bodyMode: '请求体处理', - bodyModeOff: '默认', - bodyModeMerge: '合并', - bodyModeReplace: '覆盖', - bodyModeHintOff: '使用 adapter 默认请求体(带 challenge 数学题校验)。', - bodyModeHintMerge: '与默认请求体浅合并,用户字段优先;但 model / messages / contents 会被保护不允许覆盖(动这些字段请用「覆盖」模式)。', - bodyModeHintReplace: '完全用下方 JSON 作为请求体。注意:此模式下跳过 challenge 校验,改为 HTTP 2xx + 响应文本非空即视为可用。', - bodyJson: 'Body JSON', - bodyJsonFormat: '格式化', - bodyJsonHint: '失焦时自动解析校验。留空等价于没有覆盖。', - bodyJsonError: 'JSON 解析失败', - bodyJsonObjectError: '请求体必须是一个 JSON 对象(不能是数组或基本类型)' - }, - templateField: { - label: '请求模板', - none: '不使用模板', - placeholder: '选择一个模板(按当前平台过滤)', - applyHint: '选中模板后,会把模板的请求头和请求体拷贝到此监控(快照)。后续模板变动不自动同步。' - }, - template: { - manageButton: '模板管理', - managerTitle: '请求模板管理', - createButton: '新建模板', - emptyState: '当前平台下还没有请求模板', - missingName: '请输入模板名称', - createSuccess: '模板创建成功', - updateSuccess: '模板更新成功', - deleteSuccess: '模板删除成功', - applyButton: '应用到关联监控', - applyTooltip: '把当前模板配置覆盖到所有关联的监控上', - applyTitle: '应用模板', - applyConfirm: '确认应用', - applyConfirmMessage: '将把模板「{name}」的当前配置覆盖到 {n} 个关联监控。监控本地已编辑的自定义修改会被丢弃,是否继续?', - applySuccess: '已应用到 {n} 个监控', - applyPickerTitle: '应用模板「{name}」', - applyPickerHint: '勾选要覆盖请求头/请求体的监控(默认全选)。监控本地已编辑的自定义修改会被丢弃。', - applyPickerEmpty: '当前模板没有关联监控', - applyPickerConfirm: '应用到 {n} 个监控', - selectNone: '全不选', - selectedCount: '已选 {n} / {total}', - deleteConfirm: '确定要删除模板「{name}」吗?{n} 个关联监控会解除关联但保留自己的快照继续工作。', - associatedCount: '{n} 个关联监控', - headersSummary: '{n} 个自定义请求头', - form: { - name: '模板名称', - namePlaceholder: '例:Claude Code 伪装', - description: '说明', - descriptionPlaceholder: '可选:说明这个模板的用途和来源(抓包日期等)' - } - } - }, - - // Subscriptions Management - subscriptions: { - title: '订阅管理', - description: '管理用户订阅和配额限制', - assignSubscription: '分配订阅', - adjustSubscription: '调整订阅', - revokeSubscription: '撤销订阅', - restoreSubscription: '恢复订阅', - allStatus: '全部状态', - allGroups: '全部分组', - allPlatforms: '全部平台', - daily: '每日', - weekly: '每周', - monthly: '每月', - noLimits: '未配置限额', - unlimited: '无限制', - resetNow: '即将重置', - windowNotActive: '窗口未激活', - resetInMinutes: '{minutes} 分钟后重置', - resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置', - resetInDaysHours: '{days} 天 {hours} 小时后重置', - quotaEndsInMinutes: '额度将在 {minutes} 分钟后结束', - quotaEndsInHoursMinutes: '额度将在 {hours} 小时 {minutes} 分钟后结束', - quotaEndsInDaysHours: '额度将在 {days} 天 {hours} 小时后结束', - daysRemaining: '天剩余', - remainingDays: '剩余天数', - noExpiration: '无过期时间', - status: { - active: '生效中', - expired: '已过期', - revoked: '已撤销', - suspended: '已暂停' - }, - columns: { - user: '用户', - group: '分组', - usage: '用量', - expires: '到期时间', - status: '状态', - actions: '操作' - }, - form: { - user: '用户', - group: '订阅分组', - validityDays: '有效期(天)', - adjustDays: '调整天数' - }, - selectUser: '选择用户', - selectGroup: '选择订阅分组', - groupHint: '仅显示订阅计费类型的分组', - validityHint: '订阅的有效天数', - adjustingFor: '为以下用户调整订阅', - currentExpiration: '当前到期时间', - adjustDaysPlaceholder: '正数延长,负数缩短', - adjustHint: '输入正数延长订阅,负数缩短订阅(缩短后剩余天数需大于0)', - assign: '分配', - assigning: '分配中...', - adjust: '调整', - adjusting: '调整中...', - revoke: '撤销', - restore: '恢复', - resetQuota: '重置配额', - resetQuotaTitle: '重置用量配额', - resetQuotaConfirm: "确定要重置 '{user}' 的每日、每周和每月用量配额吗?用量将归零并从今天开始重新计算。", - quotaResetSuccess: '配额重置成功', - failedToResetQuota: '重置配额失败', - noSubscriptionsYet: '暂无订阅', - assignFirstSubscription: '分配一个订阅以开始使用。', - subscriptionAssigned: '订阅分配成功', - subscriptionAdjusted: '订阅调整成功', - subscriptionRevoked: '订阅撤销成功', - subscriptionRestored: '订阅已恢复', - failedToLoad: '加载订阅列表失败', - failedToAssign: '分配订阅失败', - failedToAdjust: '调整订阅失败', - failedToRevoke: '撤销订阅失败', - failedToRestore: '恢复订阅失败', - adjustWouldExpire: '调整后剩余天数必须大于0', - adjustOutOfRange: '调整天数必须在 -36500 到 36500 之间', - pleaseSelectUser: '请选择用户', - pleaseSelectGroup: '请选择分组', - validityDaysRequired: '请输入有效的天数(至少1天)', - revokeConfirm: "确定要撤销 '{user}' 的订阅吗?可稍后在已撤销列表中恢复。", - restoreConfirm: "确定要恢复 '{user}' 的订阅吗?如果原订阅已过期,恢复后将显示为已过期。", - guide: { - title: '订阅管理教程', - subtitle: '订阅模式允许你按时间周期为用户分配使用额度,支持日/周/月配额限制。按照以下步骤即可完成配置。', - showGuide: '使用指南', - step1: { - title: '创建订阅分组', - line1: '前往「分组管理」页面,点击「创建分组」', - line2: '将计费类型设为「订阅」,配置日/周/月额度限制', - line3: '保存分组,确保状态为「正常」', - link: '前往分组管理' - }, - step2: { - title: '分配订阅给用户', - line1: '点击本页右上角「分配订阅」按钮', - line2: '在弹窗中搜索用户邮箱并选择目标用户', - line3: '选择订阅分组、设置有效期天数,点击「分配」' - }, - step3: { - title: '管理已有订阅' - }, - actions: { - adjust: '调整', - adjustDesc: '延长或缩短订阅有效期', - resetQuota: '重置配额', - resetQuotaDesc: '将日/周/月用量归零,重新开始计算', - revoke: '撤销', - revokeDesc: '立即终止该用户的订阅,可在已撤销列表中恢复' - }, - tip: '提示:订阅分组下拉列表中只会显示计费类型为「订阅」且状态为「正常」的分组。如果没有可选项,请先到分组管理中创建。' - } - }, - - // Accounts Management - accounts: { - title: '账号管理', - description: '管理 AI 平台账号和 Cookie', - createAccount: '添加账号', - autoRefresh: '自动刷新', - enableAutoRefresh: '启用自动刷新', - refreshInterval5s: '5 秒', - refreshInterval10s: '10 秒', - refreshInterval15s: '15 秒', - refreshInterval30s: '30 秒', - autoRefreshCountdown: '自动刷新:{seconds}s', - listPendingSyncHint: '列表存在待同步变更,点击同步可补齐最新数据。', - listPendingSyncAction: '立即同步', - syncFromCrs: '从 CRS 同步', - dataExport: '导出', - dataExportSelected: '导出选中', - dataExportIncludeProxies: '导出代理(导出账号关联的代理)', - dataImport: '导入', - moreActions: '更多操作', - dataActions: '数据操作', - toolActions: '工具', - viewColumns: '列显示', - selectedCount: '已选 {count}', - dataExportConfirmMessage: '导出的数据包含账号与代理的敏感信息,请妥善保存。', - dataExportConfirm: '确认导出', - dataExported: '数据导出成功', - dataExportedSkippedShadows: '数据已导出。已跳过 {count} 个 spark 影子账号:其调度配置不在备份内,还原后需在重建的影子上重新调优。', - dataExportFailed: '数据导出失败', - dataImportTitle: '导入数据', - dataImportHint: '上传导出的 JSON 文件以批量导入账号与代理。', - dataImportWarning: '导入将创建新账号与代理,分组需手工绑定;请确认已有数据不会冲突。', - dataImportFile: '数据文件', - dataImportButton: '开始导入', - dataImporting: '导入中...', - dataImportSelectFile: '请选择数据文件', - dataImportParseFailed: '数据解析失败', - dataImportParseFailedFile: '文件 {name} 解析失败', - dataImportInvalidFile: '文件 {name} 不是受支持的导出数据文件', - dataImportIgnoredFiles: '已忽略 {count} 个非 JSON 文件', - dataImportFailed: '数据导入失败', - dataImportResult: '导入结果', - dataImportResultSummary: '代理创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed};账号创建 {account_created},失败 {account_failed}', - dataImportErrors: '失败详情', - dataImportSuccess: '导入完成:账号 {account_created},失败 {account_failed}', - dataImportCompletedWithErrors: '导入完成但有错误:账号失败 {account_failed},代理失败 {proxy_failed}', - syncFromCrsTitle: '从 CRS 同步账号', - syncFromCrsDesc: - '将 claude-relay-service(CRS)中的账号同步到当前系统(不会在浏览器侧直接请求 CRS)。', - crsVersionRequirement: '⚠️ 注意:CRS 版本必须 ≥ v1.1.240 才支持此功能', - crsBaseUrl: 'CRS 服务地址', - crsBaseUrlPlaceholder: '例如:http://127.0.0.1:3000', - crsUsername: '用户名', - crsPassword: '密码', - syncProxies: '同时同步代理(按 host/port/账号匹配或自动创建)', - syncNow: '开始同步', - syncing: '同步中...', - syncMissingFields: '请填写服务地址、用户名和密码', - syncResult: '同步结果', - syncResultSummary: '创建 {created},更新 {updated},跳过 {skipped},失败 {failed}', - syncErrors: '错误/跳过详情', - syncCompleted: '同步完成:创建 {created},更新 {updated},跳过 {skipped}', - syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated},跳过 {skipped})', - syncFailed: '同步失败', - crsPreview: '预览', - crsPreviewing: '预览中...', - crsPreviewFailed: '预览失败', - crsExistingAccounts: '将自动更新的已有账号', - crsNewAccounts: '新账号(可选择)', - crsSelectAll: '全选', - crsSelectNone: '全不选', - crsNoNewAccounts: '所有 CRS 账号均已同步。', - crsWillUpdate: '将更新 {count} 个已有账号。', - crsSelectedCount: '已选择 {count} 个新账号', - crsUpdateBehaviorNote: - '已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。', - crsBack: '返回', - editAccount: '编辑账号', - deleteAccount: '删除账号', - deleteConfirmMessage: "确定要删除账号 '{name}' 吗?", - refreshCookie: '刷新 Cookie', - testAccount: '测试账号', - searchAccounts: '搜索账号...', - notes: '备注', - notesPlaceholder: '请输入备注', - notesHint: '备注可选', - // Filter options - allPlatforms: '全部平台', - allTypes: '全部类型', - allStatus: '全部状态', - allGroups: '全部分组', - ungroupedGroup: '未分配分组', - oauthType: 'OAuth', - // Schedulable toggle - schedulable: '参与调度', - schedulableHint: '开启后账号参与API请求调度', - schedulableEnabled: '调度已开启', - schedulableDisabled: '调度已关闭', - failedToToggleSchedulable: '切换调度状态失败', - groupCountTotal: '共 {count} 个分组', - columns: { - name: '名称', - id: '账号ID', - platformType: '平台/类型', - platform: '平台', - type: '类型', - capacity: '容量', - notes: '备注', - priority: '优先级', - billingRateMultiplier: '账号倍率', - weight: '权重', - schedulerScore: '调度权值', - status: '状态', - schedulable: '调度', - todayStats: '今日统计', - groups: '分组', - usageWindows: '用量窗口', - proxy: '代理', - lastUsed: '最近使用', - createdAt: '创建时间', - expiresAt: '过期时间', - actions: '操作' - }, - schedulerScore: { - baseShort: '普通', - stickyShort: '粘性', - ungrouped: '未分组', - hint: '显示格式为“分组名 / 基础分 / 粘性加分”。基础分按当前筛选条件限定的候选账号计算,包含优先级、负载、排队、错误率、首包延迟、重置窗口、额度余量等因子;粘性加分只在开启粘性加权时用于 previous_response_id 或 session_hash。分数越大越优先。' - }, - usageWindowsHint: '“5h / 7d”是上游账号(如 OpenAI ChatGPT、Claude)官方的滚动用量窗口限制,由上游对账号设定,并非 sub2api 配置,也与你映射的模型无关。窗口滚动到期后用量会自动重置,无法在 sub2api 端解除该限制。', - allPrivacyModes: '全部Privacy状态', - privacyUnset: '未设置', - privacyTrainingOff: '已关闭训练数据共享', - privacyCfBlocked: '被 Cloudflare 拦截,训练可能仍开启', - privacyFailed: '关闭训练数据共享失败', - privacyAntigravitySet: '已关闭遥测和营销邮件', - privacyAntigravityFailed: '隐私设置失败', - setPrivacy: '设置隐私', - subscriptionAbnormal: '异常', - subscriptionExpires: '到期', - // 容量状态提示 - capacity: { - windowCost: { - blocked: '5h窗口费用超限,账号暂停调度', - stickyOnly: '5h窗口费用达阈值,仅允许粘性会话', - normal: '5h窗口费用正常' - }, - sessions: { - full: '活跃会话已满,新会话需等待(空闲超时:{idle}分钟)', - normal: '活跃会话正常(空闲超时:{idle}分钟)' - }, - rpm: { - full: '已达 RPM 上限', - warning: 'RPM 接近上限', - normal: 'RPM 正常', - tieredNormal: 'RPM 限制 (三区模型) - 正常', - tieredWarning: 'RPM 限制 (三区模型) - 接近阈值', - tieredStickyOnly: 'RPM 限制 (三区模型) - 仅粘性会话 | 缓冲区: {buffer}', - tieredBlocked: 'RPM 限制 (三区模型) - 已阻塞 | 缓冲区: {buffer}', - stickyExemptNormal: 'RPM 限制 (粘性豁免) - 正常', - stickyExemptWarning: 'RPM 限制 (粘性豁免) - 接近阈值', - stickyExemptOver: 'RPM 限制 (粘性豁免) - 超限,仅粘性会话' - }, - quota: { - exceeded: '配额已用完,账号暂停调度', - normal: '配额正常' - }, - }, - clearRateLimit: '清除速率限制', - resetQuota: '重置配额', - quotaLimit: '配额限制', - quotaLimitPlaceholder: '0 表示不限制', - quotaLimitHint: '设置日/周/总使用额度(美元),任一维度达到限额后账号暂停调度。Anthropic API Key 账号还可配置客户端亲和。修改限额不会重置已用额度。', - quotaLimitToggle: '启用配额限制', - quotaLimitToggleHint: '开启后,当账号用量达到设定额度时自动暂停调度', - quotaDailyLimit: '日限额', - quotaDailyLimitHint: '从首次使用起每 24 小时自动重置。', - quotaWeeklyLimit: '周限额', - quotaWeeklyLimitHint: '从首次使用起每 7 天自动重置。', - quotaTotalLimit: '总限额', - quotaTotalLimitHint: '累计消费上限,不会自动重置 — 使用「重置配额」手动清零。', - quotaResetMode: '重置方式', - quotaResetModeRolling: '滚动窗口', - quotaResetModeFixed: '固定时间', - quotaResetHour: '重置时间', - quotaWeeklyResetDay: '重置日', - quotaResetTimezone: '重置时区', - quotaDailyLimitHintFixed: '每天 {hour}:00({timezone})重置。', - quotaWeeklyLimitHintFixed: '每{day} {hour}:00({timezone})重置。', - dayOfWeek: { - monday: '周一', - tuesday: '周二', - wednesday: '周三', - thursday: '周四', - friday: '周五', - saturday: '周六', - sunday: '周日', - }, - quotaLimitAmount: '总限额', - quotaLimitAmountHint: '累计消费上限,不会自动重置。', - quotaNotify: { - alert: '提醒阈值', - enabled: '启用告警', - threshold: '告警金额', - thresholdPlaceholder: '输入百分比', - }, - testConnection: '测试连接', - reAuthorize: '重新授权', - refreshToken: '刷新令牌', - noAccountsYet: '暂无账号', - createFirstAccount: '添加 AI 平台账号以开始使用 API 网关。', - tokenRefreshed: 'Token 刷新成功', - accountDeleted: '账号删除成功', - rateLimitCleared: '速率限制已清除', - setupToken: 'Setup Token', - apiKey: 'API Key', - deleteConfirm: "确定要删除账号 '{name}' 吗?此操作无法撤销。", - failedToClearRateLimit: '清除速率限制失败', - platforms: { - claude: 'Claude', - openai: 'OpenAI', - anthropic: 'Anthropic', - gemini: 'Gemini', - antigravity: 'Antigravity', - grok: 'Grok', - }, - types: { - oauth: 'OAuth', - chatgptOauth: 'ChatGPT OAuth', - responsesApi: 'Responses API', - googleOauth: 'Google OAuth', - codeAssist: 'Code Assist', - antigravityOauth: 'Antigravity OAuth', - grokOauth: 'Grok OAuth', - antigravityApikey: '通过 Base URL + API Key 连接', - upstream: '对接上游', - upstreamDesc: '通过 Base URL + API Key 连接上游', - api_key: 'API Key', - cookie: 'Cookie' - }, - antigravityProjectIdLabel: 'GCP Project ID(可选)', - antigravityProjectIdPlaceholder: 'your-gcp-project-id', - antigravityProjectIdHint: - 'standard-tier 且未自动返回 project_id 的 Antigravity 账号需要填写用户自带 GCP project。', - status: { - active: '正常', - inactive: '停用', - error: '错误', - cooldown: '冷却中', - paused: '暂停', - limited: '限流', - rateLimited: '限流中', - overloaded: '过载中', - tempUnschedulable: '临时不可调度', - quotaExceeded: '配额超限', - unschedulable: '不可调度', - rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复', - rateLimitedAutoResume: '{time} 自动恢复', - modelRateLimitedUntil: '{model} 限流至 {time}', - modelCreditOveragesUntil: '{model} 正在使用 AI Credits,至 {time}', - creditsExhausted: '积分已用尽', - creditsExhaustedUntil: 'AI Credits 已用尽,预计 {time} 恢复', - overloadedUntil: '负载过重,重置时间:{time}', - viewTempUnschedDetails: '查看临时不可调度详情' - }, - tempUnschedulable: { - title: '临时不可调度', - statusTitle: '临时不可调度状态', - hint: '当错误码与关键词同时匹配时,账号会在指定时间内被临时禁用。', - notice: '规则按顺序匹配,需同时满足错误码与关键词。', - addRule: '添加规则', - ruleOrder: '规则序号', - ruleIndex: '规则 #{index}', - errorCode: '错误码', - errorCodePlaceholder: '例如 429', - durationMinutes: '持续时间(分钟)', - durationPlaceholder: '例如 30', - keywords: '关键词', - keywordsPlaceholder: '例如 overloaded, too many requests', - keywordsHint: '多个关键词用逗号分隔,匹配时必须命中其中之一。', - description: '描述', - descriptionPlaceholder: '可选,便于记忆规则用途', - rulesInvalid: '请至少填写一条包含错误码、关键词和时长的规则。', - viewDetails: '查看临时不可调度详情', - accountName: '账号', - triggeredAt: '触发时间', - until: '解除时间', - remaining: '剩余时间', - matchedKeyword: '匹配关键词', - errorMessage: '错误详情', - reset: '恢复状态', - resetSuccess: '账号状态已恢复', - resetFailed: '恢复账号状态失败', - failedToLoad: '加载临时不可调度状态失败', - notActive: '当前账号未处于临时不可调度状态。', - expired: '已到期', - remainingMinutes: '约 {minutes} 分钟', - remainingHours: '约 {hours} 小时', - remainingHoursMinutes: '约 {hours} 小时 {minutes} 分钟', - presets: { - overloadLabel: '529 过载', - overloadDesc: '服务过载 - 暂停 60 分钟', - rateLimitLabel: '429 限流', - rateLimitDesc: '触发限流 - 暂停 10 分钟', - unavailableLabel: '503 维护', - unavailableDesc: '服务不可用 - 暂停 30 分钟' - } - }, - usageWindow: { - statsTitle: '5小时窗口用量统计', - statsTitleDaily: '每日用量统计', - geminiProDaily: 'Pro', - geminiFlashDaily: 'Flash', - gemini3Pro: 'G3P', - gemini3Flash: 'G3F', - gemini3Image: 'G31FI', - claude: 'Claude', - grokRequests: '请求', - grokTokens: 'Token', - grokUnknown: 'Grok 配额需等待首次上游响应返回 xAI rate-limit 头后显示。', - grokRetryAfter: '{time} 后重试', - grokProbe: '探测', - grokProbeTooltip: '发送最小 xAI Responses 探测请求并读取配额响应头', - grokResetUnsupported: '不支持重置', - grokResetUnsupportedTooltip: 'xAI 未向 Grok OAuth 账号开放重置额度接口', - grokNoHeaders: '未观察到配额响应头', - grokLastStatus: '状态 {status}', - grokLastProbe: '探测 {time}', - grokLastHeadersSeen: '响应头 {time}', - passiveSampled: '被动采样', - activeQuery: '查询' - }, - openaiQuotaReset: { - count: '次数', - reset: '重置', - countTooltipLoad: '点击查询剩余重置次数', - countTooltipRefresh: '点击刷新剩余重置次数', - resetTooltipReady: '消耗 1 次重置次数以立即恢复当前窗口', - resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数', - resetTooltipNoCredits: '没有可用的重置次数', - resetTooltipShadow: 'Spark 影子账号不能重置次数;请在母账号上重置', - expiresAt: '到期 {time}', - expiresAtFull: '重置次数到期时间: {time}', - expandExpirations: '展开其余 {count} 张重置次数到期时间', - collapseExpirations: '收起重置次数到期时间', - expirationDetails: '重置次数到期明细', - noCreditsAvailable: '没有可用的重置次数', - resetSuccess: '已重置 {windows} 个窗口', - confirmTitle: '确认重置周限', - confirmMessage: '将消耗 1 次重置次数立即恢复当前窗口,剩余 {count} 次。此操作不可撤销,确定继续吗?' - }, - tier: { - free: 'Free', - pro: 'Pro', - ultra: 'Ultra', - aiPremium: 'AI Premium', - standard: '标准版', - basic: '基础版', - personal: '个人版', - unlimited: '无限制' - }, - ineligibleWarning: - '该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。', - forbidden: '已封禁', - forbiddenValidation: '需要验证', - forbiddenViolation: '违规封禁', - openVerification: '打开验证链接', - copyLink: '复制链接', - linkCopied: '链接已复制', - needsReauth: '需要重新授权', - rateLimited: '限流中', - usageError: '获取失败', - form: { - nameLabel: '账号名称', - namePlaceholder: '请输入账号名称', - platformLabel: '平台', - selectPlatform: '选择平台', - typeLabel: '类型', - selectType: '选择类型', - credentialsLabel: '凭证', - credentialsPlaceholder: '请输入 Cookie 或 API Key', - priorityLabel: '优先级', - priorityHint: '数值越小优先级越高', - weightLabel: '权重', - weightHint: '用于负载均衡的权重值', - statusLabel: '状态' - }, - filters: { - platform: '平台', - allPlatforms: '全部平台', - type: '类型', - allTypes: '全部类型', - status: '状态', - allStatuses: '全部状态' - }, - saving: '保存中...', - refreshing: '刷新中...', - testing: '测试中...', - noAccounts: '暂无账号', - noAccountsDescription: '添加 AI 平台账号以开始使用 API 网关。', - accountCreatedSuccess: '账号添加成功', - accountUpdatedSuccess: '账号更新成功', - accountDeletedSuccess: '账号删除成功', - bulkSchedulableEnabled: '成功启用 {count} 个账号的调度', - bulkSchedulableDisabled: '成功停止 {count} 个账号的调度', - bulkSchedulablePartial: '部分调度更新成功:成功 {success} 个,失败 {failed} 个', - bulkSchedulableResultUnknown: '批量调度结果不完整,请稍后重试或刷新列表', - bulkActions: { - selected: '已选择 {count} 个账号', - selectCurrentPage: '本页全选', - clear: '清除选择', - edit: '批量编辑账号', - delete: '批量删除', - enableScheduling: '批量启用调度', - disableScheduling: '批量停止调度', - resetStatus: '批量重置状态', - refreshToken: '批量刷新令牌', - resetStatusSuccess: '已成功重置 {count} 个账号状态', - refreshTokenSuccess: '已成功刷新 {count} 个账号令牌', - partialSuccess: '操作部分完成:{success} 成功,{failed} 失败' - }, - bulkEdit: { - title: '批量编辑账号', - selectionInfo: '已选择 {count} 个账号。只更新您勾选或填写的字段,未勾选的字段保持不变。', - baseUrlPlaceholder: 'https://api.anthropic.com 或 https://api.openai.com', - baseUrlNotice: '仅适用于 API Key 账号,留空则不修改', - submit: '批量更新', - updating: '更新中...', - success: '成功更新 {count} 个账号', - partialSuccess: '部分更新成功:成功 {success} 个,失败 {failed} 个', - failed: '批量更新失败', - noSelection: '请选择要编辑的账号', - noFieldsSelected: '请至少选择一个要更新的字段', - mixedPlatformWarning: '所选账号跨越多个平台({platforms})。显示的模型映射预设为合并结果——请确保映射对每个平台都适用。' - }, - bulkDeleteTitle: '批量删除账号', - bulkDeleteConfirm: '确定要删除选中的 {count} 个账号吗?此操作无法撤销。', - bulkDeleteSuccess: '成功删除 {count} 个账号', - bulkDeletePartial: '部分删除成功:成功 {success} 个,失败 {failed} 个', - bulkDeleteFailed: '批量删除失败', - recoverState: '恢复状态', - recoverStateHint: '用于恢复错误、限流和临时不可调度等可恢复状态。', - recoverStateSuccess: '账号状态已恢复', - recoverStateFailed: '恢复账号状态失败', - fallbackActive: '已回退', - fallbackActiveTip: '原代理 {origin} 已到期,当前使用备用代理', - revertProxy: '切回原代理', - revertProxySuccess: '已成功切回原代理', - revertProxyFailed: '切回原代理失败', - createSparkShadow: '创建 Spark 影子账号', - createSparkShadowConfirm: '为「{name}」创建链接型 Spark 影子账号?影子共享母账号凭据、仅服务 spark 模型。', - createSparkShadowSuccess: 'Spark 影子账号已创建', - createSparkShadowFailed: '创建 Spark 影子账号失败', - resetStatus: '重置状态', - statusReset: '账号状态已重置', - failedToResetStatus: '重置账号状态失败', - cookieRefreshedSuccess: 'Cookie 刷新成功', - testSuccess: '账号测试通过', - testFailed: '账号测试失败', - failedToLoad: '加载账号列表失败', - failedToSave: '保存账号失败', - failedToDelete: '删除账号失败', - failedToRefresh: '刷新 Cookie 失败', - // Create/Edit Account Modal - platform: '平台', - accountName: '账号名称', - enterAccountName: '请输入账号名称', - accountType: '账号类型', - claudeCode: 'Claude Code', - claudeConsole: 'Claude Console', - bedrockLabel: 'AWS Bedrock', - bedrockDesc: 'SigV4 / API Key', - vertexLabel: 'Vertex', - vertexDesc: 'Service Account', - vertexAnthropicHint: '使用 Google Cloud Service Account JSON 通过 Vertex AI 调用 Anthropic Claude。建议配置模型映射,将客户端 Claude 模型名映射到 Vertex 模型 ID。', - vertexGeminiHint: '使用 Google Cloud Service Account JSON 访问 Vertex AI Gemini。建议将 Vertex 账号放入独立分组,避免和 AI Studio/Gemini OAuth 同模型混调。', - vertexSaJsonLabel: 'Service Account JSON', - vertexSaJsonLoaded: '已读取 Service Account JSON', - vertexSaJsonDrop: '拖入 Service Account JSON', - vertexSaJsonKeyHidden: '密钥内容不会在表单中显示。', - vertexSaJsonDropHint: '把 .json 文件拖到这里,或点击按钮选择文件。', - vertexSaJsonSelectBtn: '选择 JSON', - vertexSaJsonUploadHint: '上传或拖入 JSON 后会自动读取 project_id,密钥内容仅用于创建账号提交。', - vertexSaJsonEditHint: 'Service Account JSON 不在编辑页显示;需要更换 JSON 时请删除账号后重新创建。', - vertexProjectIdPlaceholder: '从 JSON 自动读取', - vertexLocationHint: '不同 Vertex 模型可用 location 可能不同,这里选择账号默认 endpoint location。', - vertexLocationRequired: '请填写 Vertex location', - vertexSaJsonMissingFields: 'Service Account JSON 缺少 project_id、client_email 或 private_key', - vertexSaJsonMissingProjectId: 'Service Account JSON 缺少 project_id', - vertexSaJsonMissingClientEmail: 'Service Account JSON 缺少 client_email', - vertexSaJsonInvalid: 'Service Account JSON 格式无效', - vertexSaJsonRequired: '请上传 Service Account JSON', - oauthSetupToken: 'OAuth / Setup Token', - addMethod: '添加方式', - setupTokenLongLived: 'Setup Token(长期有效)', - baseUrl: 'Base URL', - baseUrlHint: '留空使用官方 Anthropic API', - apiKeyRequired: 'API Key *', - apiKeyPlaceholder: 'sk-ant-api03-...', - apiKeyHint: '您的 Claude Console API Key', - // OpenAI specific hints - openai: { - baseUrlHint: '留空使用官方 OpenAI API', - apiKeyHint: '您的 OpenAI API Key', - oauthPassthrough: '自动透传(仅替换认证)', - oauthPassthroughDesc: - '开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。', - responsesWebsocketsV2: 'Responses WebSocket v2', - responsesWebsocketsV2Desc: - '默认关闭。开启后可启用 responses_websockets_v2 协议能力(受网关全局开关与账号类型开关约束)。', - wsMode: 'WS mode', - wsModeDesc: '仅对当前 OpenAI 账号类型生效。', - wsModeOff: '关闭(off)', - wsModeCtxPool: '上下文池(ctx_pool)', - wsModePassthrough: '透传(passthrough)', - wsModeHttpBridge: 'HTTP 桥接(http_bridge)', - wsModeShared: '共享(shared)', - wsModeDedicated: '独享(dedicated)', - wsModeConcurrencyHint: '启用 WS mode 后,该账号并发数将作为该账号 WS 连接池上限。', - wsModePassthroughHint: 'passthrough 模式不使用 WS 连接池。', - oauthResponsesWebsocketsV2: 'OAuth WebSocket Mode', - oauthResponsesWebsocketsV2Desc: - '仅对 OpenAI OAuth 生效。开启后该账号才允许使用 OpenAI WebSocket Mode 协议。', - apiKeyResponsesWebsocketsV2: 'API Key WebSocket Mode', - apiKeyResponsesWebsocketsV2Desc: - '仅对 OpenAI API Key 生效。开启后该账号才允许使用 OpenAI WebSocket Mode 协议。', - responsesWebsocketsV2PassthroughHint: '当前已开启自动透传:仅影响 HTTP 透传链路,不影响 WS mode。', - responsesMode: 'Responses API 支持', - responsesModeDesc: - '仅对 OpenAI API Key 的文本转发链路生效。自动跟随探测结果,强制模式会覆盖自动探测。', - responsesModeAuto: '自动', - responsesModeForceResponses: '强制 Responses', - responsesModeForceChatCompletions: '强制 Chat Completions', - responsesModeTextDisabledHint: '未启用 Responses / Chat Completions 端点时,此设置不适用。', - endpointCapabilities: '端点能力', - endpointCapabilitiesDesc: - '用于调度筛选。文本端点会跟随上方 Responses API 支持显示为 Responses、Chat Completions 或自动模式;Embeddings 独立控制 /v1/embeddings。', - capabilityResponses: 'Responses', - capabilityTextAuto: 'Responses / Chat Completions(自动)', - capabilityResponsesAuto: 'Responses(自动探测)', - capabilityChatCompletions: 'Chat Completions', - capabilityChatCompletionsAuto: 'Chat Completions(自动探测)', - capabilityEmbeddings: 'Embeddings', - responsesStatusAutoSupported: '自动探测:Responses', - responsesStatusAutoUnsupported: '自动探测:Chat Completions', - responsesStatusAutoUnknown: '自动探测:未探测', - responsesStatusForcedResponses: '已强制 Responses', - responsesStatusForcedChatCompletions: '已强制 Chat Completions', - codexCLIOnly: '仅允许 Codex 官方客户端', - codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。', - codexCLIOnlyAppServer: '允许 Codex app-server 客户端', - codexCLIOnlyAppServerDesc: '仅在上方开关开启时生效。开启后本账号额外放行内嵌 Codex 引擎、经 app-server 协议接入的第三方客户端(如 Claude Code 的 codex 插件),仍需通过全局引擎指纹门;与全局 app-server 开关取 OR(任一开即放行)。', - codexImageTool: 'Codex 图片工具', - codexImageToolDesc: - '统一控制 Codex /responses 文本请求的 image_generation 图片工具:是否自动注入,以及客户端自带该工具时是否放行。账号级策略优先于渠道和全局配置,不影响独立图片生成接口。', - codexImageToolInherit: '跟随渠道', - codexImageToolInheritDesc: '不写入账号覆盖,是否注入由渠道或全局策略决定;客户端自带的图片工具照常放行。', - codexImageToolEnabled: '强制注入', - codexImageToolEnabledDesc: '始终为 Codex /responses 请求注入图片工具。', - codexImageToolDisabled: '关闭注入', - codexImageToolDisabledDesc: '不自动注入;客户端自带的图片工具仍会放行。', - codexImageToolBlock: '完全阻断', - codexImageToolBlockDesc: '不注入,并移除客户端自带的图片工具及指向它的 tool_choice。', - codexImageToolBadgeInherit: '渠道策略', - codexImageToolBadgeEnabled: '强制注入', - codexImageToolBadgeDisabled: '关闭注入', - codexImageToolBadgeBlock: '完全阻断', - compactMode: 'Compact 模式', - compactModeDesc: - '控制本账号在 /responses/compact 调度中的参与方式。Auto 跟随探测结果,Force On 强制允许,Force Off 强制排除。', - compactModeAuto: '自动', - compactModeForceOn: '强制开启', - compactModeForceOff: '强制关闭', - compactModelMapping: 'Compact 专属模型映射', - compactModelMappingDesc: - '仅在 /responses/compact 请求中生效。当上游 compact 端点需要特殊 compact 模型时使用。', - compactSupported: '支持 Compact', - compactUnsupported: '不支持 Compact', - compactAuto: 'Compact Auto', - compactUnknown: 'Compact Auto', - compactLastChecked: '最近探测', - testMode: '测试模式', - testModeDefault: '常规请求', - testModeCompact: 'Compact 探测', - modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。', - }, - grok: { - baseUrlHint: 'Grok OAuth 账号会转发到官方 xAI API Base URL。', - apiKeyHint: 'Grok 订阅支持使用 OAuth refresh token;API Key 账号不在本次范围内。' - }, - anthropic: { - apiKeyPassthrough: '自动透传(仅替换认证)', - apiKeyPassthroughDesc: - '仅对 Anthropic API Key 生效。开启后,messages/count_tokens 请求将透传上游并仅替换认证,保留计费/并发/审计及必要安全过滤;关闭即可回滚到现有兼容链路。', - apiKeyAuthScheme: '上游认证方式', - apiKeyAuthSchemeDesc: '选择转发到 Anthropic-compatible 上游时使用的 API Key 认证头。Ollama Cloud 使用 Authorization: Bearer。', - apiKeyAuthSchemeXApiKey: 'x-api-key', - apiKeyAuthSchemeBearer: 'Authorization: Bearer', - webSearchEmulation: 'Web Search 模拟', - webSearchEmulationDesc: - '为该 API Key 账号启用 web search 模拟。客户端发送纯 web_search 请求时,由网关调用第三方搜索 API 并构造响应返回。默认跟随渠道配置。', - webSearchDefault: '默认', - webSearchEnabled: '开启', - webSearchDisabled: '关闭', - }, - modelRestriction: '模型限制(可选)', - modelWhitelist: '模型白名单', - modelMapping: '模型映射', - selectAllowedModels: '选择允许的模型。留空则支持所有模型。', - mapRequestModels: '将请求模型映射到实际模型。左边是请求的模型,右边是发送到 API 的实际模型。', - selectedModels: '已选择 {count} 个模型', - supportsAllModels: '(支持所有模型)', - requestModel: '请求模型', - actualModel: '实际模型', - addMapping: '添加映射', - mappingExists: '模型 {model} 的映射已存在', - wildcardOnlyAtEnd: '通配符 * 只能放在末尾', - targetNoWildcard: '目标模型不能包含通配符 *', - searchModels: '搜索模型...', - noMatchingModels: '没有匹配的模型', - fillRelatedModels: '同步最新支持模型', - syncUpstreamModels: '同步上游支持的模型', - syncUpstreamModelsLoading: '同步上游中...', - syncUpstreamModelsSuccess: '已从上游同步 {count} 个新模型(上游共 {total} 个)', - syncUpstreamModelsNoChanges: '上游 {count} 个模型均已在白名单中', - syncUpstreamModelsEmpty: '上游没有返回可同步的模型', - syncUpstreamModelsFailed: '同步上游模型失败', - syncUpstreamModelsError: '同步上游模型失败:{message}', - clearAllModels: '清除所有模型', - customModelName: '自定义模型名称', - enterCustomModelName: '输入自定义模型名称', - addModel: '填入', - modelExists: '该模型已存在', - modelCount: '{count} 个模型', - poolMode: '池模式', - poolModeHint: '上游为账号池时启用,错误不标记本地账号状态', - poolModeInfo: - '启用后,上游 429/403/401 错误将自动重试而不标记账号限流或错误,适用于上游指向另一个 sub2api 实例的场景。', - poolModeRetryCount: '同账号重试次数', - poolModeRetryCountHint: '仅在池模式下生效。0 表示不原地重试;默认 {default},最大 {max}。', - poolModeRetryStatusCodes: '同账号重试状态码', - poolModeRetryStatusCodesHint: '仅在池模式下生效。以英文逗号分隔的 HTTP 状态码(100-599),命中时触发同账号重试。留空使用默认值({default})。', - customErrorCodes: '自定义错误码', - customErrorCodesHint: '仅对选中的错误码停止调度', - customErrorCodesWarning: '仅选中的错误码会停止调度,其他错误将返回 500。', - customErrorCodes429Warning: - '429 已有内置的限流处理机制。添加到自定义错误码后,将直接停止调度而非临时限流。确定要添加吗?', - customErrorCodes529Warning: - '529 已有内置的过载处理机制。添加到自定义错误码后,将直接停止调度而非临时标记过载。确定要添加吗?', - selectedErrorCodes: '已选择', - noneSelectedUsesDefault: '未选择(使用默认策略)', - enterErrorCode: '输入错误码 (100-599)', - invalidErrorCode: '请输入有效的 HTTP 错误码 (100-599)', - errorCodeExists: '该错误码已被选中', - interceptWarmupRequests: '拦截预热请求', - interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token', - headerOverride: { - title: '请求头覆写', - hint: '转发时用配置值覆盖同名请求头(不区分大小写)', - info: '仅对本账号的出站请求生效:配置的请求头会在转发前覆盖客户端/网关生成的同名头。认证头(authorization、x-api-key)与连接控制头不允许覆写。', - namePlaceholder: '请求头名称(如 user-agent)', - valuePlaceholder: '覆写值(留空表示不覆写)', - addRow: '添加请求头', - fillTemplate: '填入模板', - emptyValueHint: '值留空的行不会参与覆盖,仅作为待填写的占位。', - bulkDisableHint: '保存后将关闭所选账号的请求头覆写并清空已有配置。', - bulkReplaceHint: '保存后将用下方配置整体替换所选账号已有的请求头覆写配置。', - bulkEmptyRows: '请至少添加一行请求头再保存;如需清空已有配置,请关闭上方开关。', - invalidName: '请求头名称格式不正确(仅允许字母、数字和 !#$%&\'*+-.^_`|~ 字符)', - blockedName: '该请求头不允许覆写(认证头与连接控制头由系统管理)', - duplicateName: '存在重复的请求头名称(匹配不区分大小写)', - invalidValue: '请求头值不合法(不允许控制字符,长度不超过 8192)', - tooManyEntries: '请求头覆写条目过多(最多 64 条)' - }, - autoPauseOnExpired: '过期自动暂停调度', - autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度', - autoPause5hThreshold: '5h 用量阈值(%)', - autoPause7dThreshold: '7d 用量阈值(%)', - autoPauseThresholdHint: '留空或填 0 表示使用全局默认阈值(在运维设置中配置);填具体值则覆盖全局默认。达到阈值后仅在调度时跳过账号,不修改 schedulable。', - autoPause5hDisabled: '禁用 5h 自动暂停', - autoPause7dDisabled: '禁用 7d 自动暂停', - autoPauseDisabledHint: '开启后该账号永不进入自动暂停(即使全局默认阈值已配置)。', - // Quota control (Anthropic OAuth/SetupToken only) - quotaControl: { - title: '配额控制', - hint: '配置费用窗口、会话限制、客户端亲和等调度控制。', - windowCost: { - label: '5h窗口费用控制', - hint: '限制账号在5小时窗口内的费用使用', - limit: '费用阈值', - limitPlaceholder: '50', - limitHint: '达到阈值后不参与新请求调度', - stickyReserve: '粘性预留额度', - stickyReservePlaceholder: '10', - stickyReserveHint: '为粘性会话预留的额外额度' - }, - sessionLimit: { - label: '会话数量控制', - hint: '限制同时活跃的会话数量', - maxSessions: '最大会话数', - maxSessionsPlaceholder: '3', - maxSessionsHint: '同时活跃的最大会话数量', - idleTimeout: '空闲超时', - idleTimeoutPlaceholder: '5', - idleTimeoutHint: '会话空闲超时后自动释放' - }, - rpmLimit: { - label: 'RPM 限制', - hint: '限制每分钟请求数量,保护上游账号', - baseRpm: '基础 RPM', - baseRpmPlaceholder: '15', - baseRpmHint: '每分钟最大请求数,0 或留空表示不限制', - strategy: 'RPM 策略', - strategyTiered: '三区模型', - strategyStickyExempt: '粘性豁免', - strategyTieredHint: '绿区→黄区→仅粘性→阻塞,逐步限流', - strategyStickyExemptHint: '超限后仅允许粘性会话', - strategyHint: '三区模型: 超限后逐步限制; 粘性豁免: 已有会话不受限', - stickyBuffer: '粘性缓冲区', - stickyBufferPlaceholder: '默认: base RPM 的 20%', - stickyBufferHint: '超过 base RPM 后,粘性会话额外允许的请求数。为空则使用默认值(base RPM 的 20%,最小为 1)', - userMsgQueue: '用户消息限速', - userMsgQueueHint: '对用户消息施加发送限制,避免触发上游 RPM 限制', - umqModeOff: '关闭', - umqModeThrottle: '软性限速', - umqModeSerialize: '串行队列', - }, - tlsFingerprint: { - label: 'TLS 指纹模拟', - hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹', - defaultProfile: '内置默认', - randomProfile: '随机' - }, - sessionIdMasking: { - label: '会话 ID 伪装', - hint: '启用后将在 15 分钟内固定 metadata.user_id 中的 session ID,使上游认为请求来自同一会话' - }, - cacheTTLOverride: { - label: '缓存 TTL 强制替换', - hint: '将所有缓存创建 token 强制按指定的 TTL 类型(5分钟或1小时)计费', - target: '目标 TTL', - targetHint: '选择计费使用的 TTL 类型' - }, - customBaseUrl: { - label: '自定义转发地址', - hint: '启用后将请求转发到自定义中继服务,代理地址将作为 URL 参数传递给中继服务', - urlHint: '中继服务地址(如 https://relay.example.com)', - }, - clientAffinity: { - label: '客户端亲和调度', - hint: '启用后,新会话会优先调度到该客户端之前使用过的账号,避免频繁切换账号' - } - }, - affinityNoClients: '无亲和客户端', - affinityClients: '{count} 个亲和客户端:', - affinitySection: '客户端亲和', - affinitySectionHint: '控制客户端在账号间的分布。通过配置区域阈值来平衡负载。', - affinityToggle: '启用客户端亲和', - affinityToggleHint: '新会话优先调度到该客户端之前使用过的账号', - affinityBase: '基础限额(绿区)', - affinityBasePlaceholder: '留空表示不限制', - affinityBaseHint: '绿区最大客户端数量(完整优先级调度)', - affinityBaseOffHint: '未开启绿区限制,所有客户端均享受完整优先级调度', - affinityBuffer: '缓冲区(黄区)', - affinityBufferPlaceholder: '例如 3', - affinityBufferHint: '黄区允许的额外客户端数量(降级优先级调度)', - affinityBufferInfinite: '不限制', - expired: '已过期', - proxy: '代理', - noProxy: '无代理', - concurrency: '并发数', - loadFactor: '负载因子', - loadFactorHint: '提高负载因子可以提高对账号的调度频率', - priority: '优先级', - priorityHint: '优先级越小的账号优先使用', - billingRateMultiplier: '账号计费倍率', - billingRateMultiplierHint: '0 表示不计费,仅影响账号计费', - expiresAt: '过期时间', - expiresAtHint: '留空表示不过期', - higherPriorityFirst: '数值越小优先级越高', - mixedScheduling: '在 /v1/messages 中使用', - mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度', - mixedSchedulingTooltip: - '!!注意!! Antigravity Claude 和 Anthropic Claude 无法在同个上下文中使用,如果你同时有 Anthropic 账号和 Antigravity 账号,开启此选项会导致经常 400 报错。开启后,请用分组功能做好 Antigravity 账号和 Anthropic 账号的隔离。一定要弄明白再开启!!', - aiCreditsBalance: 'AI Credits', - allowOverages: '允许超量请求 (AI Credits)', - allowOveragesTooltip: - '仅在免费配额被明确判定为耗尽后才会使用 AI Credits。普通并发 429 限流不会切换到超量请求。', - creating: '创建中...', - updating: '更新中...', - accountCreated: '账号创建成功', - accountUpdated: '账号更新成功', - failedToCreate: '创建账号失败', - failedToUpdate: '更新账号失败', - pleaseSelectStatus: '请选择有效的账号状态', - mixedChannelWarningTitle: '混合渠道警告', - mixedChannelWarning: '警告:分组 "{groupName}" 中同时包含 {currentPlatform} 和 {otherPlatform} 账号。混合使用不同渠道可能导致 thinking block 签名验证问题,会自动回退到非 thinking 模式。确定要继续吗?', - pleaseEnterAccountName: '请输入账号名称', - pleaseEnterApiKey: '请输入 API Key', - bedrockAccessKeyId: 'AWS Access Key ID', - bedrockSecretAccessKey: 'AWS Secret Access Key', - bedrockSessionToken: 'AWS Session Token', - bedrockRegion: 'AWS Region', - bedrockRegionHint: '例如 us-east-1, us-west-2, eu-west-1', - bedrockForceGlobal: '强制使用 Global 跨区域推理', - bedrockForceGlobalHint: '启用后模型 ID 使用 global. 前缀(如 global.anthropic.claude-...),请求可路由到全球任意支持的区域,获得更高可用性', - bedrockAccessKeyIdRequired: '请输入 AWS Access Key ID', - bedrockSecretAccessKeyRequired: '请输入 AWS Secret Access Key', - bedrockRegionRequired: '请选择 AWS Region', - bedrockSessionTokenHint: '可选,用于临时凭证', - bedrockSecretKeyLeaveEmpty: '留空以保持当前密钥', - bedrockAuthMode: '认证方式', - bedrockAuthModeSigv4: 'SigV4 签名', - bedrockAuthModeApikey: 'Bedrock API Key', - bedrockApiKeyLabel: 'Bedrock API Key', - bedrockApiKeyDesc: 'Bearer Token 认证', - bedrockApiKeyInput: 'API Key', - bedrockApiKeyRequired: '请输入 Bedrock API Key', - bedrockApiKeyLeaveEmpty: '留空以保持当前密钥', - apiKeyIsRequired: 'API Key 是必需的', - leaveEmptyToKeep: '留空以保持当前密钥', - // Upstream type - upstream: { - baseUrl: '上游 Base URL', - baseUrlHint: '上游 Antigravity 服务的地址,例如:https://cloudcode-pa.googleapis.com', - apiKey: '上游 API Key', - apiKeyHint: '上游服务的 API Key', - pleaseEnterBaseUrl: '请输入上游 Base URL', - pleaseEnterApiKey: '请输入上游 API Key' - }, - // OAuth flow - oauth: { - title: 'Claude 账号授权', - authMethod: '授权方式', - manualAuth: '手动授权', - cookieAutoAuth: 'Cookie 自动授权', - cookieAutoAuthDesc: '使用 claude.ai sessionKey 自动完成 OAuth 授权,无需手动打开浏览器。', - sessionKey: 'sessionKey', - keysCount: '{count} 个密钥', - batchCreateAccounts: '将批量创建 {count} 个账号', - sessionKeyPlaceholder: - '每行一个 sessionKey,例如:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...', - sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...', - howToGetSessionKey: '如何获取 sessionKey', - step1: '在浏览器中登录 claude.ai', - step2: '按 F12 打开开发者工具', - step3: '切换到 Application 标签', - step4: '找到 Cookies → https://claude.ai', - step5: '找到 sessionKey 所在行', - step6: '复制 Value 列的值', - sessionKeyFormat: 'sessionKey 通常以 sk-ant-sid01- 开头', - startAutoAuth: '开始自动授权', - authorizing: '授权中...', - followSteps: '按照以下步骤授权您的 Claude 账号:', - step1GenerateUrl: '点击下方按钮生成授权 URL', - generateAuthUrl: '生成授权 URL', - generating: '生成中...', - regenerate: '重新生成', - step2OpenUrl: '在浏览器中打开 URL 并完成授权', - openUrlDesc: '在新标签页中打开授权 URL,登录您的 Claude 账号并授权。', - proxyWarning: '注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。', - step3EnterCode: '输入授权码', - authCodeDesc: '授权完成后,页面会显示一个授权码。复制并粘贴到下方:', - authCode: '授权码', - authCodePlaceholder: '粘贴 Claude 页面的授权码...', - authCodeHint: '粘贴从 Claude 页面复制的授权码', - completeAuth: '完成授权', - verifying: '验证中...', - pleaseEnterSessionKey: '请输入至少一个有效的 sessionKey', - authFailed: '授权失败', - cookieAuthFailed: 'Cookie 授权失败', - keyAuthFailed: '密钥 {index}: {error}', - successCreated: '成功创建 {count} 个账号', - batchSuccess: '成功创建 {count} 个账号', - batchPartialSuccess: '部分成功:{success} 个成功,{failed} 个失败', - batchFailed: '批量创建失败', - // OpenAI specific - openai: { - title: 'OpenAI 账户授权', - followSteps: '请按照以下步骤完成 OpenAI 账户的授权:', - step1GenerateUrl: '点击下方按钮生成授权链接', - generateAuthUrl: '生成授权链接', - step2OpenUrl: '在浏览器中打开链接并完成授权', - openUrlDesc: '请在新标签页中打开授权链接,登录您的 OpenAI 账户并授权。', - importantNotice: - '重要提示:授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 http://localhost... 开头时,表示授权已完成。', - step3EnterCode: '输入授权链接或 Code', - authCodeDesc: - '授权完成后,当页面地址变为 http://localhost:xxx/auth/callback?code=... 时:', - authCode: '授权链接或 Code', - authCodePlaceholder: - '方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值', - authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别', - failedToGenerateUrl: '生成 OpenAI 授权链接失败', - failedToExchangeCode: 'OpenAI 授权码兑换失败', - failedToValidateRT: '验证 Refresh Token 失败', - errors: { - OPENAI_OAUTH_PROXY_REQUIRED: - '未设置代理,当前服务器无法直连 OpenAI,导致 OpenAI OAuth 请求失败。请先选择可访问 OpenAI 的代理后重试;如果授权码已失效,请重新生成授权链接。' - }, - // Refresh Token auth - refreshTokenAuth: '手动输入 RT', - refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。', - refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个', - codexSessionAuth: 'Codex JSON / AT 批量输入', - codexSessionDesc: '粘贴 Codex JSON 或 accessToken,按第一步配置创建账号。', - codexSessionInputLabel: 'Codex JSON 或 accessToken', - codexSessionPlaceholder: '支持多行,每行一个 token 或 JSON', - codexSessionHint: 'sessionToken 不会作为 refresh_token 保存;未包含 refresh_token 时会按 accessToken 过期时间设置账号过期,无法解析且第一步未设置过期时间时会拒绝导入。', - codexSessionImportAndCreate: '导入并创建账号', - codexSessionEmpty: '请输入 Codex JSON 或 accessToken', - codexSessionImportFailed: 'Codex 账号导入失败', - codexSessionImportSuccess: '导入完成:新增 {created},更新 {updated},跳过 {skipped}', - codexSessionImportPartial: '部分成功:新增 {created},更新 {updated},跳过 {skipped},失败 {failed}', - codexPatAuth: 'Codex Personal Access Token', - codexPatDesc: '输入 Codex at- Personal Access Token,系统会先调用 OpenAI whoami 校验后再创建账号。', - codexPatInputLabel: 'Codex PAT', - codexPatPlaceholder: 'at-...', - codexPatHint: '这是独立认证模式,不保存 refresh_token,也不会写入 OAuth access_token 过期时间。', - codexPatImportAndCreate: '校验并创建 Codex PAT 账号', - codexPatEmpty: '请输入 Codex Personal Access Token', - codexPatImportFailed: 'Codex PAT 账号创建失败', - sessionTokenAuth: '手动输入 ST', - sessionTokenDesc: '输入您已有的 Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。', - sessionTokenPlaceholder: '粘贴您的 Session Token...\n支持多个,每行一个', - sessionTokenRawLabel: '原始字符串', - sessionTokenRawPlaceholder: '粘贴 /api/auth/session 原始数据或 Session Token...', - sessionTokenRawHint: '支持粘贴完整 JSON,系统会自动解析 ST 和 AT。', - openSessionUrl: '打开获取链接', - copySessionUrl: '复制链接', - sessionUrlHint: '该链接通常可获取 AT。若返回中无 sessionToken,请从浏览器 Cookie 复制 __Secure-next-auth.session-token 作为 ST。', - parsedSessionTokensLabel: '解析出的 ST', - parsedSessionTokensEmpty: '未解析到 ST,请检查输入内容', - parsedAccessTokensLabel: '解析出的 AT', - validating: '验证中...', - validateAndCreate: '验证并创建账号', - pleaseEnterRefreshToken: '请输入 Refresh Token', - pleaseEnterSessionToken: '请输入 Session Token' - }, - grok: { - title: 'Grok 账号授权', - followSteps: '请按照以下步骤授权您的 xAI/Grok 账号:', - step1GenerateUrl: '生成 xAI 授权链接', - generateAuthUrl: '生成授权链接', - step2OpenUrl: '在浏览器中打开链接并完成授权', - openUrlDesc: '在新标签页中打开授权链接,登录 xAI 并授权 API 访问。', - importantNotice: '当浏览器跳转到本地 callback URL 后,请复制完整 URL 或 code 参数回填到这里。', - step3EnterCode: '输入授权链接或 Code', - authCodeDesc: '授权完成后,粘贴 callback URL、查询字符串或授权码:', - authCode: '授权链接或 Code', - authCodePlaceholder: '粘贴完整 callback URL、?code=... 查询字符串或 code 值', - authCodeHint: '支持完整 callback URL、查询字符串或裸 code。', - refreshTokenAuth: '手动输入 RT', - refreshTokenDesc: '输入已有的 xAI refresh token,支持批量输入(每行一个)。', - refreshTokenPlaceholder: '粘贴您的 xAI refresh token...\n支持多个,每行一个', - validating: '验证中...', - validateAndCreate: '验证并创建账号', - pleaseEnterRefreshToken: '请输入 Refresh Token', - failedToGenerateUrl: '生成 Grok 授权链接失败', - missingExchangeParams: '缺少授权码、state 或 OAuth 会话', - failedToExchangeCode: 'Grok 授权码兑换失败', - failedToValidateRT: '验证 Grok refresh token 失败', - oauthOnlyHint: '首版 Grok 支持仅包含 OAuth 订阅的 Responses API 文本/推理转发。' - }, - // Gemini specific - gemini: { - title: 'Gemini 账户授权', - followSteps: '请按照以下步骤完成 Gemini 账户的授权:', - step1GenerateUrl: '生成授权链接', - generateAuthUrl: '生成授权链接', - projectIdLabel: 'Project ID(可选)', - projectIdPlaceholder: '例如:my-gcp-project 或 cloud-ai-companion-xxxxx', - projectIdHint: - '留空则在兑换授权码后自动探测;若自动探测失败,可填写后重新生成授权链接再授权。', - howToGetProjectId: '如何获取', - step2OpenUrl: '在浏览器中打开链接并完成授权', - openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。', - step3EnterCode: '输入回调链接或 Code', - authCodeDesc: - '授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code,粘贴到下方即可。', - authCode: '回调链接或 Code', - authCodePlaceholder: '方式1(推荐):粘贴回调链接\n方式2:仅粘贴 code 参数的值', - authCodeHint: '系统会自动从链接中解析 code/state。', - redirectUri: 'Redirect URI', - redirectUriHint: '需要在 Google OAuth Client 中配置,且必须与此处完全一致。', - confirmRedirectUri: '我已在 Google OAuth Client 中配置了该 Redirect URI(必须完全一致)', - invalidRedirectUri: 'Redirect URI 必须是合法的 http(s) URL', - redirectUriNotConfirmed: '请确认 Redirect URI 已在 Google OAuth Client 中正确配置', - missingRedirectUri: '缺少 Redirect URI', - failedToGenerateUrl: '生成 Gemini 授权链接失败', - missingExchangeParams: '缺少 code / session_id / state', - failedToExchangeCode: 'Gemini 授权码兑换失败', - missingProjectId: - 'GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。', - modelPassthrough: 'Gemini 直接转发模型', - modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。', - stateWarningTitle: '提示', - stateWarningDesc: '建议粘贴完整回调链接(包含 code 和 state)。', - oauthTypeLabel: 'OAuth 类型', - needsProjectId: '内置授权(Code Assist)', - needsProjectIdDesc: '需要 GCP 项目与 Project ID', - noProjectIdNeeded: '自定义授权(AI Studio)', - noProjectIdNeededDesc: '需管理员配置 OAuth Client', - aiStudioNotConfiguredShort: '未配置', - aiStudioNotConfiguredTip: - 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback(Consent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever)', - aiStudioNotConfigured: - 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback' - }, - // Antigravity specific - antigravity: { - title: 'Antigravity 账户授权', - followSteps: '请按照以下步骤完成 Antigravity 账户的授权:', - step1GenerateUrl: '生成授权链接', - generateAuthUrl: '生成授权链接', - step2OpenUrl: '在浏览器中打开链接并完成授权', - openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。', - importantNotice: - '重要提示:授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 http://localhost... 开头时,表示授权已完成。', - step3EnterCode: '输入授权链接或 Code', - authCodeDesc: - '授权完成后,当页面地址变为 http://localhost:xxx/auth/callback?code=... 时:', - authCode: '授权链接或 Code', - authCodePlaceholder: - '方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值', - authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别', - failedToGenerateUrl: '生成 Antigravity 授权链接失败', - missingExchangeParams: '缺少 code / session_id / state', - failedToExchangeCode: 'Antigravity 授权码兑换失败', - // Refresh Token auth - refreshTokenAuth: '手动输入 RT', - refreshTokenDesc: '输入您已有的 Antigravity Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。', - refreshTokenPlaceholder: '粘贴您的 Antigravity Refresh Token...\n支持多个,每行一个', - validating: '验证中...', - validateAndCreate: '验证并创建账号', - pleaseEnterRefreshToken: '请输入 Refresh Token', - failedToValidateRT: '验证 Refresh Token 失败' - } - }, - // Gemini specific (platform-wide) - gemini: { - helpButton: '使用帮助', - helpDialog: { - title: 'Gemini 使用指南', - apiKeySection: 'API Key 相关链接' - }, - modelPassthrough: 'Gemini 直接转发模型', - modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。', - baseUrlHint: '留空使用官方 Gemini API', - apiKeyHint: '您的 Gemini API Key(以 AIza 开头)', - tier: { - label: '账号等级', - hint: '提示:系统会优先尝试自动识别账号等级;若自动识别不可用或失败,则使用你选择的等级作为回退(本地模拟配额)。', - aiStudioHint: - 'AI Studio 的配额是按模型分别限流(Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。', - googleOne: { - free: 'Google One Free', - pro: 'Google One Pro', - ultra: 'Google One Ultra' - }, - gcp: { - standard: 'GCP Standard', - enterprise: 'GCP Enterprise' - }, - aiStudio: { - free: 'Google AI Free', - paid: 'Google AI Pay-as-you-go' - } - }, - accountType: { - oauthTitle: 'OAuth 授权(Gemini)', - oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。', - apiKeyTitle: 'API 密钥(AI Studio)', - apiKeyDesc: '最快接入方式,使用 AIza API Key。', - apiKeyNote: '适合轻量测试。免费层限流严格,数据可能用于训练。', - apiKeyLink: '获取 API Key', - quotaLink: '配额说明' - }, - oauthType: { - builtInTitle: '内置授权(Gemini CLI / Code Assist)', - builtInDesc: '使用 Google 内置客户端 ID,无需管理员配置。', - builtInRequirement: '需要 GCP 项目并填写 Project ID。', - googleOneDesc: '个人账号,享受 Google One 订阅配额', - codeAssistDesc: '企业级,需要 GCP 项目', - codeAssistRequirement: '需要激活 GCP 项目并绑定信用卡', - showAdvanced: '显示高级选项(自建 OAuth Client)', - hideAdvanced: '隐藏高级选项(自建 OAuth Client)', - gcpProjectLink: '创建项目', - customTitle: '自定义授权(AI Studio OAuth)', - customDesc: '使用管理员预设的 OAuth 客户端,适合组织管理。', - customRequirement: '需管理员配置 Client ID 并加入测试用户白名单。', - badges: { - recommended: '推荐', - highConcurrency: '高并发', - individuals: '推荐个人用户', - noGcp: '无需 GCP', - enterprise: '企业用户', - noAdmin: '无需管理员配置', - orgManaged: '组织管理', - adminRequired: '需要管理员' - } - }, - setupGuide: { - title: 'Gemini 使用准备', - checklistTitle: '准备工作', - checklistItems: { - usIp: '使用美国 IP,并确保账号归属地为美国。', - age: '账号需满 18 岁。' - }, - activationTitle: '服务激活', - activationItems: { - geminiWeb: '激活 Gemini Web,避免 User not initialized。', - gcpProject: '激活 GCP 项目,获取 Code Assist 所需 Project ID。' - }, - links: { - countryCheck: '检查归属地', - countryChange: '修改归属地', - geminiWebActivation: '激活 Gemini Web', - gcpProject: '打开 GCP 控制台' - } - }, - quotaPolicy: { - title: 'Gemini 配额与限流政策(参考)', - note: '注意:Gemini 官方未提供用量查询接口。此处显示的“每日配额”是由系统根据账号等级模拟计算的估算值,仅供调度参考,请以 Google 官方实际报错为准。', - columns: { - channel: '授权通道', - account: '账号状态', - limits: '限流政策', - docs: '官方文档' - }, - docs: { - codeAssist: 'Code Assist 配额', - aiStudio: 'AI Studio 定价', - vertex: 'Vertex AI 配额' - }, - simulatedNote: '本地模拟配额,仅供参考', - rows: { - googleOne: { - channel: 'Google One OAuth(个人版 / Code Assist for Individuals)', - limitsFree: '共享池:1000 RPD / 60 RPM(不分模型)', - limitsPro: '共享池:1500 RPD / 120 RPM(不分模型)', - limitsUltra: '共享池:2000 RPD / 120 RPM(不分模型)' - }, - gcp: { - channel: 'GCP Code Assist OAuth(企业版)', - limitsStandard: '共享池:1500 RPD / 120 RPM(不分模型)', - limitsEnterprise: '共享池:2000 RPD / 120 RPM(不分模型)' - }, - cli: { - channel: 'Gemini CLI(官方 Google 登录 / Code Assist)', - free: '免费 Google 账号', - premium: 'Google One AI Premium', - limitsFree: 'RPD ~1000;RPM ~60(软限制)', - limitsPremium: 'RPD ~1500+;RPM ~60+(优先队列)' - }, - gcloud: { - channel: 'GCP Code Assist(gcloud 登录)', - account: '未购买 Code Assist 订阅', - limits: 'RPD ~1000;RPM ~60(预览期)' - }, - aiStudio: { - channel: 'AI Studio API Key / OAuth', - free: '未绑卡(免费层)', - paid: '已绑卡(按量付费)', - limitsFree: 'RPD 50;RPM 2(Pro)/ 15(Flash)', - limitsPaid: 'RPD 不限;RPM 1000(Pro)/ 2000(Flash)(按模型配额)' - }, - customOAuth: { - channel: 'Custom OAuth Client(GCP)', - free: '项目未绑卡', - paid: '项目已绑卡', - limitsFree: 'RPD 50;RPM 2(项目配额)', - limitsPaid: 'RPD 不限;RPM 1000+(项目配额)' - } - } - }, - rateLimit: { - ok: '未限流', - unlimited: '无限流', - limited: '限流 {time}', - now: '现在' - } - }, - // Re-Auth Modal - reAuthorizeAccount: '重新授权账号', - claudeCodeAccount: 'Claude Code 账号', - openaiAccount: 'OpenAI 账号', - geminiAccount: 'Gemini 账号', - antigravityAccount: 'Antigravity 账号', - grokAccount: 'Grok 账号', - inputMethod: '输入方式', - reAuthorizedSuccess: '账号重新授权成功', - // Test Modal - testAccountConnection: '测试账号连接', - account: '账号', - readyToTest: '准备测试。点击"开始测试"按钮开始...', - connectingToApi: '连接 API 中...', - testCompleted: '测试完成!', - connectedToApi: '已连接到 API', - usingModel: '使用模型:{model}', - sendingTestMessage: '发送测试消息:"hi"', - sendingImageRequest: '发送生图测试请求...', - response: '响应:', - startTest: '开始测试', - retry: '重试', - copyOutput: '复制输出', - outputCopied: '输出已复制', - startingTestForAccount: '开始测试账号:{name}', - testAccountTypeLabel: '账号类型:{type}', - selectTestModel: '选择测试模型', - testModel: '测试模型', - testPrompt: '提示词:"hi"', - imagePromptLabel: '生图提示词', - imagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。', - imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.', - imageTestHint: '选择图片模型后,这里会直接发起生图测试,并在下方展示返回图片。', - imageTestMode: '模式:生图测试', - imagePreview: '生成结果:', - imageReceived: '已收到第 {count} 张测试图片', - // Stats Modal - viewStats: '查看统计', - usageStatistics: '使用统计', - last30DaysUsage: '近30天使用统计(日均基于实际使用天数)', - stats: { - totalCost: '30天总费用', - accumulatedCost: '累计成本', - standardCost: '标准计费', - totalRequests: '30天总请求', - totalCalls: '累计调用次数', - avgDailyCost: '日均费用', - basedOnActualDays: '基于 {days} 天实际使用', - avgDailyRequests: '日均请求', - avgDailyUsage: '平均每日调用', - todayOverview: '今日概览', - cost: '费用', - requests: '请求', - tokens: 'Token', - highestCostDay: '最高费用日', - highestRequestDay: '最高请求日', - date: '日期', - accumulatedTokens: '累计 Token', - totalTokens: '30天总计', - dailyAvgTokens: '日均 Token', - performance: '性能', - avgResponseTime: '平均响应', - daysActive: '活跃天数', - recentActivity: '最近统计', - todayRequests: '今日请求', - todayTokens: '今日 Token', - todayCost: '今日费用', - usageTrend: '30天费用与请求趋势', - noData: '该账号暂无使用数据' - } - }, - - // Scheduled Tests - scheduledTests: { - title: '定时测试', - addPlan: '添加计划', - editPlan: '编辑计划', - deletePlan: '删除计划', - model: '模型', - cronExpression: 'Cron 表达式', - enabled: '启用', - lastRun: '上次运行', - nextRun: '下次运行', - maxResults: '最大结果数', - noPlans: '暂无定时测试计划', - confirmDelete: '确定要删除此计划吗?', - createSuccess: '计划创建成功', - updateSuccess: '计划更新成功', - deleteSuccess: '计划删除成功', - results: '测试结果', - noResults: '暂无测试结果', - responseText: '响应', - errorMessage: '错误', - success: '成功', - failed: '失败', - running: '运行中', - schedule: '定时测试', - cronHelp: '标准 5 字段 cron 表达式(例如 */30 * * * *)', - cronTooltipTitle: 'Cron 表达式示例:', - cronTooltipMeaning: '用于定义自动执行测试的时间规则,格式依次为:分钟 小时 日 月 星期。', - cronTooltipExampleEvery30Min: '*/30 * * * *:每 30 分钟运行一次', - cronTooltipExampleHourly: '0 * * * *:每小时整点运行一次', - cronTooltipExampleDaily: '0 9 * * *:每天 09:00 运行一次', - cronTooltipExampleWeekly: '0 9 * * 1:每周一 09:00 运行一次', - cronTooltipRange: '推荐填写范围:使用标准 5 字段 cron;如果只是健康检查,建议从每 30 分钟、每 1 小时或每天固定时间开始,不建议一开始就设置过高频率。', - maxResultsTooltipTitle: '最大结果数说明:', - maxResultsTooltipMeaning: '用于限制单个计划最多保留多少条历史测试结果,避免结果列表无限增长。', - maxResultsTooltipBody: '系统只会保留最近的测试结果;当保存数量超过这个值时,更早的历史记录会自动清理,避免列表过长和存储持续增长。', - maxResultsTooltipExample: '例如填写 100,表示最多保存最近 100 次测试结果;第 101 次结果写入后,最早的一条会被清理。', - maxResultsTooltipRange: '推荐填写范围:一般可填 20 到 200。只关注近期可用性时可填 20-50;需要回看较长时间的波动趋势时可填 100-200。', - autoRecover: '自动恢复', - autoRecoverHelp: '测试成功后自动恢复异常状态的账号' - }, - - // Proxies Management - proxies: { - title: 'IP管理', - description: '管理代理服务器配置', - createProxy: '添加代理', - editProxy: '编辑代理', - deleteProxy: '删除代理', - ad: { - inline: '正在寻找合适的代理 IP?' - }, - deleteConfirmMessage: "确定要删除代理 '{name}' 吗?", - testProxy: '测试代理', - dataImport: '导入', - dataExportSelected: '导出选中', - dataImportTitle: '导入代理', - dataImportHint: '上传代理导出的 JSON 文件以批量导入代理。', - dataImportWarning: '导入将创建或复用代理,保留状态并在完成后自动触发延迟检测。', - dataImportFile: '数据文件', - dataImportButton: '开始导入', - dataImporting: '导入中...', - dataImportSelectFile: '请选择数据文件', - dataImportParseFailed: '数据解析失败', - dataImportFailed: '数据导入失败', - dataImportResult: '导入结果', - dataImportResultSummary: '创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed}', - dataImportErrors: '失败详情', - dataImportSuccess: '导入完成:创建 {proxy_created},复用 {proxy_reused}', - dataImportCompletedWithErrors: '导入完成但有错误:失败 {proxy_failed}', - dataExport: '导出', - dataExportConfirmMessage: '导出的数据包含代理的敏感信息,请妥善保存。', - dataExportConfirm: '确认导出', - dataExported: '数据导出成功', - dataExportFailed: '数据导出失败', - columns: { - name: '名称', - protocol: '协议', - address: '地址', - auth: '认证', - location: '地理位置', - status: '状态', - accounts: '账号数', - latency: '延迟', - expiry: '有效期', - createdAt: '创建时间', - actions: '操作', - nameLabel: '名称', - namePlaceholder: '请输入代理名称', - protocolLabel: '协议', - selectProtocol: '选择协议', - hostLabel: '主机', - hostPlaceholder: '请输入主机地址', - portLabel: '端口', - portPlaceholder: '请输入端口', - usernameLabel: '用户名(可选)', - usernamePlaceholder: '请输入用户名', - passwordLabel: '密码(可选)', - passwordPlaceholder: '请输入密码', - priorityLabel: '优先级', - statusLabel: '状态' - }, - filters: { - protocol: '协议', - allProtocols: '全部协议', - status: '状态', - allStatuses: '全部状态' - }, - // Additional keys used in ProxiesView - copyProxyUrl: '复制代理 URL', - urlCopied: '代理 URL 已复制', - allProtocols: '全部协议', - allStatus: '全部状态', - searchProxies: '搜索代理...', - protocols: { - http: 'HTTP', - https: 'HTTPS', - socks5: 'SOCKS5', - socks5h: 'SOCKS5H (远程 DNS)', - }, - name: '名称', - protocol: '协议', - host: '主机', - port: '端口', - username: '用户名(可选)', - password: '密码(可选)', - status: '状态', - enterProxyName: '请输入代理名称', - optionalAuth: '可选认证信息', - leaveEmptyToKeep: '留空保持不变', - form: { - hostPlaceholder: '请输入主机地址', - portPlaceholder: '请输入端口' - }, - noProxiesYet: '暂无代理', - createFirstProxy: '添加您的第一个代理以开始使用。', - testConnection: '测试连接', - qualityCheck: '质量检测', - batchQualityCheck: '批量质量检测', - batchTest: '批量测试', - testFailed: '失败', - latencyFailed: '链接失败', - batchTestEmpty: '暂无可测试的代理', - batchTestDone: '批量测试完成,共测试 {count} 个代理', - batchTestFailed: '批量测试失败', - batchDeleteAction: '删除', - batchDelete: '批量删除', - batchDeleteConfirm: '确定删除选中的 {count} 个代理吗?已被账号使用的将自动跳过。', - batchDeleteDone: '已删除 {deleted} 个代理,跳过 {skipped} 个', - batchDeleteSkipped: '已跳过 {skipped} 个代理', - batchDeleteFailed: '批量删除失败', - deleteBlockedInUse: '该代理已有账号使用,无法删除', - accountsTitle: '使用该IP的账号', - accountsEmpty: '暂无账号使用此代理', - accountsFailed: '获取账号列表失败', - accountName: '账号名称', - accountPlatform: '所属平台', - accountNotes: '备注', - // Batch import - standardAdd: '标准添加', - batchAdd: '快捷添加', - batchInput: '代理列表', - batchInputPlaceholder: - "每行输入一个代理,支持以下格式:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443", - batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口", - parsedCount: '有效 {count} 个', - invalidCount: '无效 {count} 个', - duplicateCount: '重复 {count} 个', - importing: '导入中...', - importProxies: '导入 {count} 个代理', - batchImportSuccess: '成功导入 {created} 个代理,跳过 {skipped} 个重复', - batchImportAllSkipped: '全部 {skipped} 个代理已存在,跳过导入', - failedToImport: '批量导入失败', - // Other messages - saving: '保存中...', - testing: '测试中...', - creating: '创建中...', - updating: '更新中...', - noProxies: '暂无代理', - noProxiesDescription: '添加代理服务器以增强 API 访问稳定性。', - proxyCreated: '代理添加成功', - proxyUpdated: '代理更新成功', - proxyDeleted: '代理删除成功', - proxyWorking: '代理连接正常', - proxyWorkingWithLatency: '代理连接正常,延迟 {latency}ms', - proxyTestFailed: '代理测试失败', - qualityCheckDone: '质量检测完成:评分 {score}({grade})', - qualityCheckFailed: '代理质量检测失败', - batchQualityDone: '批量质量检测完成,共检测 {count} 个;优质 {healthy} 个,告警 {warn} 个,挑战 {challenge} 个,异常 {failed} 个', - batchQualityFailed: '批量质量检测失败', - batchQualityEmpty: '暂无可检测质量的代理', - qualityReportTitle: '代理质量检测报告', - qualityGrade: '等级 {grade}', - qualityExitIP: '出口 IP', - qualityCountry: '出口地区', - qualityBaseLatency: '基础延迟', - qualityCheckedAt: '检测时间', - qualityTableTarget: '检测项', - qualityTableStatus: '状态', - qualityTableLatency: '延迟', - qualityTableMessage: '说明', - qualityInline: '质量 {grade}/{score}', - qualityStatusHealthy: '优质', - qualityStatusPass: '通过', - qualityStatusWarn: '告警', - qualityStatusFail: '失败', - qualityStatusChallenge: '挑战', - qualityTargetBase: '基础连通性', - proxyCreatedSuccess: '代理添加成功', - proxyUpdatedSuccess: '代理更新成功', - proxyDeletedSuccess: '代理删除成功', - testSuccess: '代理测试通过', - failedToLoad: '加载代理列表失败', - failedToSave: '保存代理失败', - failedToDelete: '删除代理失败', - failedToCreate: '创建代理失败', - failedToUpdate: '更新代理失败', - failedToTest: '测试代理失败', - nameRequired: '请输入代理名称', - hostRequired: '请输入主机地址', - portInvalid: '端口必须在 1-65535 之间', - deleteConfirm: "确定要删除代理 '{name}' 吗?使用此代理的账号将被移除代理设置。", - neverExpires: '永不过期', - expired: '已过期', - overdueDays: '已超期 {days} 天', - expiringInDays: '{days} 天后到期', - remainingDays: '剩余 {days} 天', - expiresAt: '有效期', - nDays: '{days} 天', - expiryDaysPlaceholder: '自定义天数,留空 = 永不过期', - expiryWarnDays: '到期提醒提前天数', - fallbackMode: '失败回退', - fallbackNone: '不回退', - fallbackProxy: '指定备用代理', - fallbackDirect: '回退直连', - backupProxy: '备用代理', - }, - - // Redeem Codes Management - redeem: { - title: '兑换码管理', - description: '生成和管理兑换码', - generateCodes: '生成兑换码', - columns: { - code: '兑换码', - type: '类型', - value: '面值', - status: '状态', - usedBy: '使用者', - usedAt: '使用时间', - expiresAt: '过期时间', - createdAt: '创建时间', - actions: '操作' - }, - types: { - balance: '余额', - concurrency: '并发数', - subscription: '订阅', - invitation: '邀请码', - // 管理员在用户管理页面调整余额/并发时产生的记录 - admin_balance: '余额(管理员)', - admin_concurrency: '并发数(管理员)' - }, - // 用于选择器和筛选器的直接键 - balance: '余额', - concurrency: '并发数', - subscription: '订阅', - invitation: '邀请码', - invitationHint: '邀请码用于限制用户注册,使用后自动标记为已使用。', - allTypes: '全部类型', - allStatus: '全部状态', - unused: '未使用', - used: '已使用', - searchCodes: '搜索兑换码或邮箱...', - exportCsv: '导出 CSV', - batchUpdate: '批量修改', - batchUpdateTitle: '批量修改兑换码', - selectedCount: '已选择 {count} 个兑换码', - clearSelection: '清空选择', - selectCodesFirst: '请先选择兑换码', - noBatchFieldsSelected: '请至少勾选一个要修改的字段', - batchUpdateSuccess: '成功修改 {count} 个兑换码', - failedToBatchUpdate: '批量修改兑换码失败', - batchFields: { - status: '状态', - expiresAt: '过期时间', - notes: '备注', - group: '分组' - }, - batchNotesPlaceholder: '输入新的备注,留空可清空备注', - clearGroup: '清空分组', - deleteAllUnused: '删除全部未使用', - deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。', - deleteAllUnusedConfirm: '确定要删除全部未使用的兑换码吗?此操作无法撤销。', - deleteAll: '全部删除', - generateCodesTitle: '生成兑换码', - generatedSuccessfully: '生成成功', - codesCreated: '已创建 {count} 个兑换码', - codeType: '类型', - amount: '金额 ($)', - value: '面值', - count: '数量', - generate: '生成', - copyAll: '全部复制', - download: '下载', - codesExported: '兑换码导出成功', - codeDeleted: '兑换码删除成功', - codesDeleted: '成功删除 {count} 个未使用的兑换码', - noUnusedCodes: '没有未使用的兑换码可删除', - userPrefix: '用户 #{id}', - failedToExport: '导出兑换码失败', - failedToDeleteUnused: '删除未使用的兑换码失败', - failedToCopy: '复制失败', - selectGroup: '选择分组', - selectGroupPlaceholder: '选择订阅分组', - validityDays: '有效天数', - codeExpiry: '兑换码过期', - neverExpires: '永不过期', - expiryPresetDays: '{days} 天', - customExpiry: '自定义', - customExpiryDays: '自定义天数', - expiryDaysRequired: '请输入有效的过期天数', - groupRequired: '请选择订阅分组', - days: '天', - status: { - unused: '未使用', - used: '已使用', - expired: '已过期', - disabled: '已禁用' - }, - form: { - typeLabel: '类型', - selectType: '选择类型', - valueLabel: '面值', - valuePlaceholder: '请输入面值', - balanceHint: '余额金额(美元)', - concurrencyHint: '并发数增量', - countLabel: '数量', - countPlaceholder: '请输入数量', - countHint: '要生成的兑换码数量', - prefixLabel: '前缀(可选)', - prefixPlaceholder: '例如:GIFT', - expiresLabel: '过期时间(可选)' - }, - filters: { - type: '类型', - allTypes: '全部类型', - status: '状态', - allStatuses: '全部状态', - search: '搜索兑换码' - }, - generating: '生成中...', - copyCode: '复制', - copied: '已复制!', - disableCode: '禁用', - enableCode: '启用', - deleteCode: '删除', - deleteConfirmMessage: '确定要删除此兑换码吗?', - noCodes: '暂无兑换码', - noCodesDescription: '生成兑换码以向用户分发余额或并发数。', - codesGeneratedSuccess: '兑换码生成成功,共 {count} 个', - codeDisabledSuccess: '兑换码已禁用', - codeEnabledSuccess: '兑换码已启用', - codeDeletedSuccess: '兑换码删除成功', - failedToLoad: '加载兑换码列表失败', - failedToGenerate: '生成兑换码失败', - failedToUpdate: '更新兑换码失败', - failedToDelete: '删除兑换码失败' - }, - - // Announcements - announcements: { - title: '公告管理', - description: '创建公告并按条件投放', - createAnnouncement: '创建公告', - editAnnouncement: '编辑公告', - deleteAnnouncement: '删除公告', - searchAnnouncements: '搜索公告...', - status: '状态', - allStatus: '全部状态', - columns: { - title: '标题', - status: '状态', - notifyMode: '通知方式', - targeting: '展示条件', - timeRange: '有效期', - createdAt: '创建时间', - actions: '操作' - }, - statusLabels: { - draft: '草稿', - active: '展示中', - archived: '已归档' - }, - notifyModeLabels: { - silent: '静默', - popup: '弹窗' - }, - form: { - title: '标题', - content: '内容(支持 Markdown)', - status: '状态', - notifyMode: '通知方式', - notifyModeHint: '弹窗模式会自动弹出通知给用户', - startsAt: '开始时间', - endsAt: '结束时间', - startsAtHint: '留空表示立即生效', - endsAtHint: '留空表示永久生效', - targetingMode: '展示条件', - targetingAll: '所有用户', - targetingCustom: '按条件', - addOrGroup: '添加 OR 条件组', - addAndCondition: '添加 AND 条件', - conditionType: '条件类型', - conditionSubscription: '订阅套餐', - conditionBalance: '余额', - operator: '运算符', - balanceValue: '余额阈值', - selectPackages: '选择套餐' - }, - operators: { - gt: '>', - gte: '≥', - lt: '<', - lte: '≤', - eq: '=' - }, - targetingSummaryAll: '全部用户', - targetingSummaryCustom: '自定义({groups} 组)', - timeImmediate: '立即', - timeNever: '永久', - readStatus: '已读情况', - eligible: '符合条件', - readAt: '已读时间', - unread: '未读', - searchUsers: '搜索用户...', - failedToLoad: '加载公告失败', - failedToCreate: '创建公告失败', - failedToUpdate: '更新公告失败', - failedToDelete: '删除公告失败', - failedToLoadReadStatus: '加载已读情况失败', - deleteConfirm: '确定要删除该公告吗?此操作无法撤销。' - }, - - // Promo Codes - promo: { - title: '优惠码管理', - description: '创建和管理注册优惠码', - createCode: '创建优惠码', - editCode: '编辑优惠码', - deleteCode: '删除优惠码', - searchCodes: '搜索优惠码...', - allStatus: '全部状态', - columns: { - code: '优惠码', - bonusAmount: '赠送金额', - maxUses: '最大使用次数', - usedCount: '已使用', - usage: '使用量', - status: '状态', - expiresAt: '过期时间', - createdAt: '创建时间', - actions: '操作' - }, - // 表单标签(扁平结构便于模板使用) - code: '优惠码', - autoGenerate: '留空自动生成', - codePlaceholder: '输入优惠码或留空', - bonusAmount: '赠送金额 ($)', - maxUses: '最大使用次数', - zeroUnlimited: '0 = 无限制', - expiresAt: '过期时间', - notes: '备注', - notesPlaceholder: '可选备注信息', - status: '状态', - neverExpires: '永不过期', - // 状态标签 - statusActive: '启用', - statusDisabled: '禁用', - statusExpired: '已过期', - statusMaxUsed: '已用完', - // 使用记录 - usageRecords: '使用记录', - viewUsages: '查看使用记录', - noUsages: '暂无使用记录', - userPrefix: '用户 #{id}', - copied: '已复制!', - // 消息 - noCodesYet: '暂无优惠码', - createFirstCode: '创建您的第一个优惠码,为新用户提供注册奖励。', - codeCreated: '优惠码创建成功', - codeUpdated: '优惠码更新成功', - codeDeleted: '优惠码删除成功', - deleteCodeConfirm: '确定要删除此优惠码吗?此操作无法撤销。', - copyRegisterLink: '复制注册链接', - registerLinkCopied: '注册链接已复制到剪贴板', - failedToLoad: '加载优惠码失败', - failedToCreate: '创建优惠码失败', - failedToUpdate: '更新优惠码失败', - failedToDelete: '删除优惠码失败', - failedToLoadUsages: '加载使用记录失败' - }, - - // Usage Records - usage: { - title: '使用记录', - description: '查看和管理所有用户的使用记录', - userFilter: '用户', - searchUserPlaceholder: '按邮箱搜索用户...', - searchApiKeyPlaceholder: '按名称搜索 API 密钥...', - searchAccountPlaceholder: '按名称搜索账号...', - selectedUser: '已选择', - user: '用户', - account: '账户', - group: '分组', - requestId: '请求ID', - requestIdCopied: '请求ID已复制', - allModels: '全部模型', - allAccounts: '全部账户', - allGroups: '全部分组', - allTypes: '全部类型', - inputCost: '输入费用', - outputCost: '输出费用', - cacheCreationCost: '缓存创建费用', - cacheReadCost: '缓存读取费用', - inputTokens: '输入 Token', - outputTokens: '输出 Token', - cacheCreationTokens: '缓存创建 Token', - cacheCreation5mTokens: '缓存创建', - cacheCreation1hTokens: '缓存创建', - cacheReadTokens: '缓存读取 Token', - failedToLoad: '加载使用记录失败', - billingType: '计费类型', - allBillingTypes: '全部计费类型', - billingTypeBalance: '钱包余额', - billingTypeSubscription: '订阅套餐', - billingMode: '计费模式', - billingModeToken: '按量', - billingModePerRequest: '按次', - billingModeImage: '按次(图片)', - allBillingModes: '全部计费模式', - ipAddress: 'IP', - clickToViewBalance: '点击查看充值记录', - failedToLoadUser: '加载用户信息失败', - userDeletedBadge: '已删除', - cleanup: { - button: '清理', - title: '清理使用记录', - warning: '清理不可恢复,且会影响历史统计回看。', - submit: '提交清理', - submitting: '提交中...', - confirmTitle: '确认清理', - confirmMessage: '确定要提交清理任务吗?清理不可恢复。', - confirmSubmit: '确认清理', - cancel: '取消任务', - cancelConfirmTitle: '确认取消', - cancelConfirmMessage: '确定要取消该清理任务吗?', - cancelConfirm: '确认取消', - cancelSuccess: '清理任务已取消', - cancelFailed: '取消清理任务失败', - recentTasks: '最近清理任务', - loadingTasks: '正在加载任务...', - noTasks: '暂无清理任务', - range: '时间范围', - deletedRows: '删除数量', - missingRange: '请选择时间范围', - submitSuccess: '清理任务已创建', - submitFailed: '创建清理任务失败', - loadFailed: '加载清理任务失败', - status: { - pending: '待执行', - running: '执行中', - succeeded: '已完成', - failed: '失败', - canceled: '已取消' - } - } - }, - - // Ops Monitoring - ops: { - title: '运维监控', - description: '运维监控与排障', - // Dashboard - systemHealth: '系统健康', - overview: '概览', - noSystemMetrics: '尚未收集系统指标。', - collectedAt: '采集时间:', - window: '窗口', - memory: '内存', - db: '数据库', - goroutines: '协程', - jobs: '后台任务', - jobsHelp: '点击“明细”查看任务心跳与报错信息', - active: '活跃', - idle: '空闲', - waiting: '等待', - conns: '连接', - queue: '队列', - accountSwitches: '账号切换', - ok: '正常', - lastRun: '最近运行', - lastSuccess: '最近成功', - lastError: '最近错误', - result: '结果', - noData: '暂无数据', - loadingText: '加载中...', - ready: '就绪', - autoRefreshRemaining: '剩余 {seconds}s', - systemLogs: { - title: '系统日志', - description: '优先显示最新日志,可按条件筛选、搜索和清理。', - queue: '队列', - written: '已写入', - dropped: '已丢弃', - failed: '写入失败', - runtimeConfig: '运行时日志配置(立即生效)', - all: '全部', - level: '级别', - stacktraceThreshold: '堆栈阈值', - samplingInitial: '采样初始条数', - samplingThereafter: '后续采样间隔', - retentionDays: '保留天数', - caller: '调用方', - sampling: '采样', - saveAndApply: '保存并应用', - resetDefaults: '重置默认值', - latestWriteError: '最近写入错误:', - timeRange: '时间范围', - startTime: '开始时间(可选)', - endTime: '结束时间(可选)', - component: '组件', - componentPlaceholder: '例如 http.access', - keyId: 'KEY ID', - platform: '平台', - model: '模型', - keyword: '关键词', - keywordPlaceholder: 'message/request_id', - search: '搜索', - cleanCurrentFilters: '清理当前筛选结果', - refreshHealth: '刷新健康状态', - empty: '暂无系统日志', - time: '时间', - logDetails: '日志详情', - loadFailed: '加载系统日志失败', - runtimeConfigActive: '运行时日志配置已生效', - runtimeConfigSaveFailed: '保存日志配置失败', - resetRuntimeConfigConfirm: '确定要重置为启动配置(env/yaml)并立即应用吗?', - runtimeConfigReset: '已重置为启动日志配置', - runtimeConfigResetFailed: '重置日志配置失败', - cleanupConfirm: '确定要清理匹配当前筛选条件的系统日志吗?此操作不可撤销。', - cleanupSuccess: '清理完成,已删除 {count} 条日志。', - cleanupFailed: '清理系统日志失败' - }, - requestsTotal: '请求(总计)', - slaScope: 'SLA 范围:', - tokens: 'Token数', - tps: 'TPS', - current: '当前', - peak: '峰值', - average: '平均', - totalRequests: '总请求', - avgQps: '平均 QPS', - avgTps: '平均 TPS', - avgLatency: '平均请求时长', - avgTtft: '平均首 Token 延迟', - exceptions: '异常数', - requestErrors: '请求错误', - errorCount: '错误数', - upstreamErrors: '上游错误', - errorCountExcl429529: '错误数(排除429/529)', - sla: 'SLA(排除业务限制)', - businessLimited: '业务限制:', - errors: '错误', - errorRate: '错误率:', - upstreamRate: '上游错误率:', - latencyDuration: '请求时长', - ttftLabel: '首 Token 延迟(毫秒)', - p50: 'p50', - p90: 'p90', - p95: 'p95', - p99: 'p99', - avg: 'avg', - max: 'max', - requests: '请求数', - requestsTitle: '请求', - upstream: '上游', - client: '客户端', - system: '系统', - other: '其他', - errorsSla: '错误(SLA范围)', - upstreamExcl429529: '上游(排除429/529)', - failedToLoadData: '加载运维数据失败', - failedToLoadOverview: '加载概览数据失败', - failedToLoadThroughputTrend: '加载吞吐趋势失败', - failedToLoadSwitchTrend: '加载平均账号切换趋势失败', - failedToLoadLatencyHistogram: '加载请求时长分布失败', - failedToLoadErrorTrend: '加载错误趋势失败', - failedToLoadErrorDistribution: '加载错误分布失败', - failedToLoadErrorDetail: '加载错误详情失败', - retryFailed: '重试失败', - tpsK: 'TPS(千)', - top: '最高:', - throughputTrend: '吞吐趋势', - switchRateTrend: '平均账号切换趋势', - latencyHistogram: '请求时长分布', - errorTrend: '错误趋势', - errorDistribution: '错误分布', - switchRate: '平均账号切换', - // Health Score & Diagnosis - health: '健康', - healthCondition: '健康状况', - healthHelp: '基于 SLA、错误率和资源使用情况的系统整体健康评分', - healthyStatus: '健康', - riskyStatus: '风险', - idleStatus: '待机', - timeRange: { - '5m': '近5分钟', - '30m': '近30分钟', - '1h': '近1小时', - '1d': '近1天', - '15d': '近15天', - '6h': '近6小时', - '24h': '近24小时', - '7d': '近7天', - '30d': '近30天', - custom: '自定义' - }, - openaiTokenStats: { - title: 'OpenAI Token 请求统计', - viewModeTopN: 'TopN', - viewModePagination: '分页', - prevPage: '上一页', - nextPage: '下一页', - pageInfo: '第 {page}/{total} 页', - totalModels: '模型总数:{total}', - failedToLoad: '加载 OpenAI Token 统计失败', - empty: '当前筛选条件下暂无 OpenAI Token 请求统计数据', - table: { - model: '模型', - requestCount: '请求数', - avgTokensPerSec: '平均 Tokens/秒', - avgFirstTokenMs: '平均首 Token 延迟(ms)', - totalOutputTokens: '输出 Token 总数', - avgDurationMs: '平均时长(ms)', - requestsWithFirstToken: '首 Token 样本数' - } - }, - customTimeRange: { - startTime: '开始时间', - endTime: '结束时间' - }, - fullscreen: { - enter: '进入全屏' - }, - diagnosis: { - title: '智能诊断', - footer: '基于当前指标的自动诊断建议', - idle: '系统当前处于待机状态', - idleImpact: '无活跃流量', - // Resource diagnostics - dbDown: '数据库连接失败', - dbDownImpact: '所有数据库操作将失败', - dbDownAction: '检查数据库服务状态、网络连接和连接配置', - redisDown: 'Redis连接失败', - redisDownImpact: '缓存功能降级,性能可能下降', - redisDownAction: '检查Redis服务状态和网络连接', - cpuCritical: 'CPU使用率严重过高 ({usage}%)', - cpuCriticalImpact: '系统响应变慢,可能影响所有请求', - cpuCriticalAction: '检查CPU密集型任务,考虑扩容或优化代码', - cpuHigh: 'CPU使用率偏高 ({usage}%)', - cpuHighImpact: '系统负载较高,需要关注', - cpuHighAction: '监控CPU趋势,准备扩容方案', - memoryCritical: '内存使用率严重过高 ({usage}%)', - memoryCriticalImpact: '可能触发OOM,系统稳定性受威胁', - memoryCriticalAction: '检查内存泄漏,考虑增加内存或优化内存使用', - memoryHigh: '内存使用率偏高 ({usage}%)', - memoryHighImpact: '内存压力较大,需要关注', - memoryHighAction: '监控内存趋势,检查是否有内存泄漏', - ttftHigh: '首 Token 时间偏高 ({ttft}ms)', - ttftHighImpact: '用户感知时长增加', - ttftHighAction: '优化请求处理流程,减少前置逻辑耗时', - // Error rate diagnostics - upstreamCritical: '上游错误率严重偏高 ({rate}%)', - upstreamCriticalImpact: '可能影响大量用户请求', - upstreamCriticalAction: '检查上游服务健康状态,启用降级策略', - upstreamHigh: '上游错误率偏高 ({rate}%)', - upstreamHighImpact: '建议检查上游服务状态', - upstreamHighAction: '联系上游服务团队,准备降级方案', - errorHigh: '错误率过高 ({rate}%)', - errorHighImpact: '大量请求失败', - errorHighAction: '查看错误日志,定位错误根因,紧急修复', - errorElevated: '错误率偏高 ({rate}%)', - errorElevatedImpact: '建议检查错误日志', - errorElevatedAction: '分析错误类型和分布,制定修复计划', - // SLA diagnostics - slaCritical: 'SLA 严重低于目标 ({sla}%)', - slaCriticalImpact: '用户体验严重受损', - slaCriticalAction: '紧急排查错误原因,必要时采取限流保护', - slaLow: 'SLA 低于目标 ({sla}%)', - slaLowImpact: '需要关注服务质量', - slaLowAction: '分析SLA下降原因,优化系统性能', - // Health score diagnostics - healthCritical: '综合健康评分过低 ({score})', - healthCriticalImpact: '多个指标可能同时异常,建议优先排查错误与资源使用情况', - healthCriticalAction: '全面检查系统状态,优先处理critical级别问题', - healthLow: '综合健康评分偏低 ({score})', - healthLowImpact: '可能存在轻度波动,建议关注 SLA 与错误率', - healthLowAction: '监控指标趋势,预防问题恶化', - healthy: '所有系统指标正常', - healthyImpact: '服务运行稳定' - }, - // Error Log - errorLog: { - timeId: '时间 / ID', - commonErrors: { - contextDeadlineExceeded: '请求超时', - connectionRefused: '连接被拒绝', - rateLimit: '触发限流' - }, - time: '时间', - type: '类型', - context: '上下文', - platform: '平台', - model: '模型', - group: '分组', - user: '用户', - userId: '用户 ID', - apiKey: 'API Key', - keyDeletedBadge: 'Key 已删除', - account: '账号', - accountId: '账号 ID', - status: '状态码', - message: '响应内容', - ip: 'IP', - latency: '请求时长', - action: '操作', - noErrors: '该窗口内暂无错误。', - grp: 'GRP:', - acc: 'ACC:', - details: '详情', - phase: '阶段', - id: 'ID:', - typeUpstream: '上游', - typeRequest: '请求', - typeAuth: '认证', - typeRouting: '路由', - typeInternal: '内部', - endpoint: '端点', - requestType: '类型', - requestTypeSync: '同步', - requestTypeStream: '流式', - requestTypeWs: 'WS' - }, - // Error Details Modal - errorDetails: { - upstreamErrors: '上游错误', - requestErrors: '请求错误', - unresolved: '未解决', - resolved: '已解决', - viewErrors: '错误', - viewExcluded: '排除项', - statusCodeOther: '其他', - owner: { - provider: '服务商', - client: '客户端', - platform: '平台' - }, - phase: { - request: '请求', - auth: '认证', - routing: '路由', - upstream: '上游', - network: '网络', - internal: '内部' - }, - total: '总计:', - searchPlaceholder: '搜索 request_id / client_request_id / message' - }, - // Error Detail Modal - errorDetail: { - title: '错误详情', - titleWithId: '错误 #{id}', - noErrorSelected: '未选择错误。', - resolution: '已解决:', - failedToUpdateResolvedStatus: '更新解决状态失败', - classificationKeys: { - phase: '阶段', - owner: '归属方', - source: '来源', - resolvedAt: '解决时间', - resolvedBy: '解决人' - }, - source: { - upstream_http: '上游 HTTP' - }, - upstreamKeys: { - status: '状态码', - message: '消息', - detail: '详情', - upstreamErrors: '上游错误列表' - }, - upstreamEvent: { - account: '账号', - status: '状态码', - requestId: '请求ID' - }, - responsePreview: { - expand: '响应内容(点击展开)', - collapse: '响应内容(点击收起)' - }, - loading: '加载中…', - requestId: '请求 ID', - time: '时间', - phase: '阶段', - status: '状态码', - message: '消息', - basicInfo: '基本信息', - platform: '平台', - model: '模型', - group: '分组', - user: '用户', - account: '账号', - latency: '请求时长', - businessLimited: '业务限制', - requestPath: '请求路径', - inboundEndpoint: '入站端点', - upstreamEndpoint: '上游端点', - requestedModel: '请求模型', - upstreamModel: '上游模型', - requestType: '请求类型', - requestTypeUnknown: '未知', - requestTypeSync: '同步', - requestTypeStream: '流式', - requestTypeWs: 'WebSocket', - modelMapping: '模型映射', - timings: '时序信息', - auth: '认证', - routing: '路由', - upstream: '上游', - response: '响应', - classification: '错误分类', - errorBody: '错误体', - trimmed: '已截断', - markResolved: '标记已解决', - markUnresolved: '标记未解决', - tabOverview: '概览', - tabRequest: '请求详情', - tabResponse: '响应详情', - responseBody: '响应详情', - compareA: '对比 A', - compareB: '对比 B', - suggestion: '处理建议', - suggestUpstream: '⚠️ 上游服务不稳定,建议:检查上游账号状态 / 考虑切换账号', - suggestRequest: '⚠️ 客户端请求错误,建议:联系客户修正请求参数 / 手动标记已解决', - suggestAuth: '⚠️ 认证失败,建议:检查 API Key 是否有效 / 联系客户更新凭证', - suggestPlatform: '🚨 平台错误,建议立即排查修复', - suggestGeneric: '查看详情了解更多信息', - apiKeyPrefix: 'Key 前缀', - attemptedKeyPrefix: '尝试的 Key 前缀', - deletedKeyOwner: '已删除 Key 所有者', - keyDeletedBadge: 'Key 已删除' - }, - requestDetails: { - title: '请求明细', - details: '明细', - rangeLabel: '窗口:{range}', - rangeMinutes: '{n} 分钟', - rangeHours: '{n} 小时', - empty: '该窗口内暂无请求。', - emptyHint: '可尝试调整时间范围或取消部分筛选。', - failedToLoad: '加载请求明细失败', - requestIdCopied: '请求ID已复制', - copyFailed: '复制失败', - copy: '复制', - viewError: '查看错误', - kind: { - success: '成功', - error: '失败' - }, - table: { - time: '时间', - kind: '类型', - platform: '平台', - model: '模型', - duration: '耗时', - status: '状态码', - requestId: '请求ID', - actions: '操作' - } - }, - alertEvents: { - title: '告警事件', - description: '最近的告警触发/恢复记录(仅邮件通知)', - loading: '加载中...', - empty: '暂无告警事件', - loadFailed: '加载告警事件失败', - status: { - firing: '告警中', - resolved: '已恢复', - manualResolved: '手动已解决' - }, - detail: { - title: '告警详情', - loading: '加载详情中...', - empty: '暂无详情', - loadFailed: '加载告警详情失败', - manualResolve: '标记为已解决', - manualResolvedSuccess: '已标记为手动解决', - manualResolvedFailed: '标记为手动解决失败', - silence: '忽略此告警', - silenceSuccess: '已静默该告警', - silenceFailed: '静默失败', - viewRule: '查看规则', - viewLogs: '查看相关日志', - firedAt: '触发时间', - resolvedAt: '解决时间', - ruleId: '规则 ID', - dimensions: '维度信息', - historyTitle: '历史记录', - historyHint: '同一规则 + 相同维度的最近事件', - historyLoading: '加载历史中...', - historyEmpty: '暂无历史记录' - }, - table: { - time: '时间', - status: '状态', - severity: '级别', - platform: '平台', - ruleId: '规则ID', - title: '标题', - duration: '持续时间', - metric: '指标 / 阈值', - dimensions: '维度', - email: '邮件已发送', - emailSent: '已发送', - emailIgnored: '已忽略' - } - }, - alertRules: { - title: '告警规则', - description: '创建与管理系统阈值告警(仅邮件通知)', - loading: '加载中...', - empty: '暂无告警规则', - loadFailed: '加载告警规则失败', - saveSuccess: '警报规则保存成功', - saveFailed: '保存告警规则失败', - deleteSuccess: '警报规则删除成功', - deleteFailed: '删除告警规则失败', - create: '新建规则', - createTitle: '新建告警规则', - editTitle: '编辑告警规则', - deleteConfirmTitle: '确认删除该规则?', - deleteConfirmMessage: '将删除该规则及其关联的告警事件,是否继续?', - manage: '预警规则', - metricGroups: { - system: '系统指标', - group: '分组级别指标(需 group_id)', - account: '账号级别指标' - }, - metrics: { - successRate: '成功率 (%)', - errorRate: '错误率 (%)', - upstreamErrorRate: '上游错误率 (%)', - p95: 'P95 请求时长 (ms)', - p99: 'P99 请求时长 (ms)', - cpu: 'CPU 使用率 (%)', - memory: '内存使用率 (%)', - queueDepth: '并发排队深度', - groupAvailableAccounts: '分组可用账号数', - groupAvailableRatio: '分组可用比例 (%)', - groupRateLimitRatio: '分组限流比例 (%)', - accountRateLimitedCount: '限流账号数', - accountErrorCount: '错误账号数(不含临时不可调度)', - accountErrorRatio: '错误账号比例 (%)', - accountTempUnscheduledCount: '临时不可调度账号数', - overloadAccountCount: '过载账号数' - }, - metricDescriptions: { - successRate: '统计窗口内成功请求占比(0~100)。', - errorRate: '统计窗口内失败请求占比(0~100)。', - upstreamErrorRate: '统计窗口内上游错误占比(0~100)。', - p95: '统计窗口内 P95 请求耗时(毫秒)。', - p99: '统计窗口内 P99 请求耗时(毫秒)。', - cpu: '当前实例 CPU 使用率(0~100)。', - memory: '当前实例内存使用率(0~100)。', - queueDepth: '统计窗口内并发队列排队深度(等待中的请求数)。', - groupAvailableAccounts: '指定分组中当前可用账号数量(需要 group_id 过滤)。', - groupAvailableRatio: '指定分组中可用账号占比(0~100,需要 group_id 过滤)。', - groupRateLimitRatio: '指定分组中账号被限流的比例(0~100,需要 group_id 过滤)。', - accountRateLimitedCount: '统计窗口内被限流的账号数量。', - accountErrorCount: '统计窗口内产生错误的账号数量(不含临时不可调度)。', - accountErrorRatio: '统计窗口内错误账号占比(0~100)。', - accountTempUnscheduledCount: '当前处于临时不可调度状态的账号数量(如代理/凭据故障被自动摘除)。', - overloadAccountCount: '统计窗口内过载账号数量。' - }, - hints: { - recommended: '推荐:运算符 {operator},阈值 {threshold}{unit}', - groupRequired: '该指标为分组级别指标,必须选择分组(group_id)。', - groupOptional: '可选:通过 group_id 将规则限定到某个分组。' - }, - table: { - name: '名称', - metric: '指标', - severity: '级别', - enabled: '启用', - actions: '操作' - }, - form: { - name: '名称', - description: '描述', - metric: '指标', - operator: '运算符', - groupId: '分组(group_id)', - groupPlaceholder: '请选择分组', - allGroups: '全部分组', - threshold: '阈值', - severity: '级别', - window: '统计窗口(分钟)', - sustained: '连续样本数(每分钟)', - cooldown: '冷却期(分钟)', - enabled: '启用', - notifyEmail: '发送邮件通知' - }, - validation: { - title: '请先修正以下问题', - invalid: '规则不合法', - nameRequired: '名称不能为空', - metricRequired: '指标不能为空', - groupIdRequired: '分组级别指标必须指定 group_id', - operatorRequired: '运算符不能为空', - thresholdRequired: '阈值必须为数字', - windowRange: '统计窗口必须为 1 / 5 / 60 分钟之一', - sustainedRange: '连续样本数必须在 1 到 1440 之间', - cooldownRange: '冷却期必须在 0 到 1440 分钟之间' - } - }, - runtime: { - title: '运维监控运行设置', - description: '配置存储在数据库中,无需修改 config 文件即可生效。', - loading: '加载中...', - noData: '暂无运行设置', - loadFailed: '加载运行设置失败', - saveSuccess: '运行设置已保存', - saveFailed: '保存运行设置失败', - alertTitle: '告警评估器', - groupAvailabilityTitle: '分组可用性监控', - evalIntervalSeconds: '评估间隔(秒)', - silencing: { - title: '告警静默(维护模式)', - enabled: '启用静默', - globalUntil: '静默截止时间(RFC3339)', - untilHint: '建议填写截止时间,避免忘记关闭静默。', - reason: '原因', - reasonPlaceholder: '例如:计划维护', - entries: { - title: '高级:定向静默', - hint: '可选:仅静默特定规则或特定级别。字段留空表示匹配全部。', - add: '新增条目', - empty: '暂无定向静默条目', - entryTitle: '条目 #{n}', - ruleId: '规则ID(可选)', - ruleIdPlaceholder: '例如:1', - severities: '级别(可选)', - severitiesPlaceholder: '例如:P0,P1(留空=全部)', - until: '截止时间(RFC3339)', - reason: '原因', - validation: { - untilRequired: '条目截止时间不能为空', - untilFormat: '条目截止时间必须为合法的 RFC3339 时间戳', - ruleIdPositive: '条目 rule_id 必须为正整数', - severitiesFormat: '条目级别必须为 P0..P3 的逗号分隔列表' - } - }, - validation: { - timeFormat: '静默时间必须为合法的 RFC3339 时间戳' - } - }, - lockEnabled: '启用分布式锁', - lockKey: '分布式锁 Key', - lockTTLSeconds: '分布式锁 TTL(秒)', - showAdvancedDeveloperSettings: '显示高级开发者设置 (Distributed Lock)', - advancedSettingsSummary: '高级设置 (分布式锁)', - evalIntervalHint: '检测任务的执行频率,建议保持默认。', - validation: { - title: '请先修正以下问题', - invalid: '设置不合法', - evalIntervalRange: '评估间隔必须在 1 到 86400 秒之间', - lockKeyRequired: '启用分布式锁时必须填写 Lock Key', - lockKeyPrefix: '分布式锁 Key 必须以「{prefix}」开头', - lockKeyHint: '建议以「{prefix}」开头以避免冲突', - lockTtlRange: '分布式锁 TTL 必须在 1 到 86400 秒之间', - slaMinPercentRange: 'SLA 最低值必须在 0-100 之间', - ttftP99MaxRange: 'TTFT P99 最大值必须大于或等于 0', - requestErrorRateMaxRange: '请求错误率最大值必须在 0-100 之间', - upstreamErrorRateMaxRange: '上游错误率最大值必须在 0-100 之间' - } - }, - email: { - title: '邮件通知配置', - description: '配置告警/报告邮件通知(存储在数据库中)。', - loading: '加载中...', - noData: '暂无邮件通知配置', - loadFailed: '加载邮件通知配置失败', - saveSuccess: '邮件通知配置已保存', - saveFailed: '保存邮件通知配置失败', - alertTitle: '告警邮件', - reportTitle: '报告邮件', - recipients: '收件人', - recipientsHint: '若为空,系统可能会回退使用第一个管理员邮箱。', - minSeverity: '最低级别', - minSeverityAll: '全部级别', - rateLimitPerHour: '每小时限额', - batchWindowSeconds: '合并窗口(秒)', - includeResolved: '包含恢复通知', - dailySummary: '每日摘要', - weeklySummary: '每周摘要', - errorDigest: '错误摘要', - errorDigestMinCount: '错误摘要最小数量', - accountHealth: '账号健康报告', - accountHealthThreshold: '错误率阈值(%)', - cronPlaceholder: 'Cron 表达式', - reportHint: '发送时间使用 Cron 语法;留空将使用默认值。', - validation: { - title: '请先修正以下问题', - invalid: '邮件通知配置不合法', - alertRecipientsRequired: '已启用告警邮件,但未配置任何收件人', - reportRecipientsRequired: '已启用报告邮件,但未配置任何收件人', - invalidRecipients: '存在不合法的收件人邮箱', - rateLimitRange: '每小时限额必须为 ≥ 0 的数字', - batchWindowRange: '合并窗口必须在 0 到 86400 秒之间', - cronRequired: '启用定时任务时必须填写 Cron 表达式', - cronFormat: 'Cron 表达式格式可能不正确(至少应包含 5 段)', - digestMinCountRange: '错误摘要最小数量必须为 ≥ 0 的数字', - accountHealthThresholdRange: '账号健康错误率阈值必须在 0 到 100 之间' - } - }, - settings: { - title: '运维监控设置', - loadFailed: '加载设置失败', - saveSuccess: '运维监控设置保存成功', - saveFailed: '保存设置失败', - dataCollection: '数据采集', - evaluationInterval: '评估间隔(秒)', - evaluationIntervalHint: '检测任务的执行频率,建议保持默认', - alertConfig: '预警配置', - enableAlert: '开启预警', - alertRecipients: '预警接收邮箱', - emailPlaceholder: '输入邮箱地址', - recipientsHint: '若为空,系统将使用第一个管理员邮箱作为默认收件人', - minSeverity: '最低级别', - reportConfig: '评估报告配置', - enableReport: '开启评估报告', - reportRecipients: '评估报告接收邮箱', - dailySummary: '每日摘要', - weeklySummary: '每周摘要', - metricThresholds: '指标阈值配置', - metricThresholdsHint: '配置各项指标的告警阈值,超出阈值时将以红色显示', - slaMinPercent: 'SLA最低百分比', - slaMinPercentHint: 'SLA低于此值时显示为红色(默认:99.5%)', - ttftP99MaxMs: 'TTFT P99最大值(毫秒)', - ttftP99MaxMsHint: 'TTFT P99高于此值时显示为红色(默认:500ms)', - requestErrorRateMaxPercent: '请求错误率最大值(%)', - requestErrorRateMaxPercentHint: '请求错误率高于此值时显示为红色(默认:5%)', - upstreamErrorRateMaxPercent: '上游错误率最大值(%)', - upstreamErrorRateMaxPercentHint: '上游错误率高于此值时显示为红色(默认:5%)', - advancedSettings: '高级设置', - dataRetention: '数据保留策略', - enableCleanup: '启用数据清理', - cleanupSchedule: '清理计划(Cron)', - cleanupScheduleHint: '例如:0 2 * * * 表示每天凌晨2点', - errorLogRetentionDays: '错误日志保留天数', - minuteMetricsRetentionDays: '分钟指标保留天数', - hourlyMetricsRetentionDays: '小时指标保留天数', - retentionDaysHint: '建议保留 7-90 天,过长会占用存储空间;填 0 表示每次定时清理时清空所有历史', - aggregation: '预聚合任务', - enableAggregation: '启用预聚合任务', - aggregationHint: '预聚合可提升长时间窗口查询性能', - openaiQuotaAutoPause: 'OpenAI 账号配额自动暂停', - openaiQuotaAutoPauseHint: '当 OpenAI 账号 5h / 7d 用量达到阈值时,调度会自动跳过该账号;窗口滚动后自动恢复。账号级阈值优先于此全局默认值。', - openaiQuotaAutoPauseDefault5h: '默认 5h 用量阈值 (%)', - openaiQuotaAutoPauseDefault7d: '默认 7d 用量阈值 (%)', - openaiQuotaAutoPauseThresholdHint: '取值 0-100,留空或 0 表示不启用全局默认阈值。', - errorFiltering: '错误过滤', - ignoreCountTokensErrors: '忽略 count_tokens 错误', - ignoreCountTokensErrorsHint: '启用后,count_tokens 请求的错误将不会写入错误日志。', - ignoreContextCanceled: '忽略客户端断连错误', - ignoreContextCanceledHint: - '启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。', - ignoreNoAvailableAccounts: '忽略无可用账号错误', - ignoreNoAvailableAccountsHint: '启用后,"No available accounts" 错误将不会写入错误日志(不推荐,这通常是配置问题)。', - ignoreInvalidApiKeyErrors: '忽略无效 API Key 错误', - ignoreInvalidApiKeyErrorsHint: '启用后,无效或缺失 API Key 的错误(INVALID_API_KEY、API_KEY_REQUIRED)将不会写入错误日志。', - ignoreInsufficientBalanceErrors: '忽略余额不足错误', - ignoreInsufficientBalanceErrorsHint: '启用后,账号余额不足(Insufficient balance)的错误将不会写入错误日志。', - autoRefresh: '自动刷新', - enableAutoRefresh: '启用自动刷新', - enableAutoRefreshHint: '自动刷新仪表板数据,启用后会定期拉取最新数据。', - refreshInterval: '刷新间隔', - refreshInterval15s: '15 秒', - refreshInterval30s: '30 秒', - refreshInterval60s: '60 秒', - dashboardCards: '仪表盘卡片', - displayAlertEvents: '展示告警事件', - displayAlertEventsHint: '控制运维监控仪表盘中告警事件卡片是否显示,默认开启。', - displayOpenAITokenStats: '展示 OpenAI Token 请求统计', - displayOpenAITokenStatsHint: '控制运维监控仪表盘中 OpenAI Token 请求统计卡片是否显示,默认关闭。', - autoRefreshCountdown: '自动刷新:{seconds}s', - validation: { - title: '请先修正以下问题', - retentionDaysRange: '保留天数必须在 0-365 天之间(0 = 每次清理时清空所有)', - slaMinPercentRange: 'SLA最低百分比必须在0-100之间', - ttftP99MaxRange: 'TTFT P99最大值必须大于等于0', - requestErrorRateMaxRange: '请求错误率最大值必须在0-100之间', - upstreamErrorRateMaxRange: '上游错误率最大值必须在0-100之间', - openaiQuotaAutoPauseRange: 'OpenAI 配额自动暂停阈值必须在 0-100 之间' - } - }, - concurrency: { - title: '并发 / 排队', - byPlatform: '按平台', - byGroup: '按分组', - byAccount: '按账号', - byUser: '按用户', - showByUserTooltip: '切换用户视图,显示每个用户的并发使用情况', - switchToUser: '切换到用户视图', - switchToPlatform: '切换回平台视图', - totalRows: '共 {count} 项', - disabledHint: '已在设置中关闭实时监控。', - empty: '暂无数据', - queued: '队列 {count}', - rateLimited: '限流 {count}', - errorAccounts: '异常 {count}', - loadFailed: '加载并发数据失败' - }, - realtime: { - title: '实时信息', - connected: '实时已连接', - connecting: '实时连接中', - reconnecting: '实时重连中', - offline: '实时离线', - closed: '实时已关闭', - reconnectIn: '重连 {seconds}s' - }, - queryMode: { - auto: 'Auto(自动)', - raw: 'Raw(不聚合)', - preagg: 'Preagg(聚合)' - }, - accountAvailability: { - available: '可用', - unavailable: '不可用', - accountError: '异常' - }, - tooltips: { - totalRequests: '当前时间窗口内的总请求数和Token消耗量。', - throughputTrend: '当前窗口内的请求/QPS 与 token/TPS 趋势。', - switchRateTrend: '近5小时内账号切换次数 / 请求总数的趋势(平均切换次数)。', - latencyHistogram: '成功请求的请求时长分布(毫秒)。', - errorTrend: '错误趋势(SLA 口径排除业务限制;上游错误率排除 429/529)。', - errorDistribution: '按状态码统计的错误分布(SLA 口径,排除业务限制)。', - upstreamErrors: '上游服务返回的错误,包括API提供商的错误响应(排除429/529限流错误)。', - goroutines: - 'Go 运行时的协程数量(轻量级线程)。没有绝对"安全值",建议以历史基线为准。经验参考:<2000 常见;2000-8000 需关注;>8000 且伴随队列上升时,优先排查阻塞/泄漏。', - cpu: 'CPU 使用率,显示系统处理器的负载情况。', - memory: '内存使用率,包括已使用和总可用内存。', - db: '数据库连接池状态,包括活跃连接、空闲连接和等待连接数。', - redis: 'Redis 连接池状态,显示活跃和空闲的连接数。', - jobs: '后台任务执行状态,包括最近运行时间、成功时间和错误信息。', - qps: '每秒查询数(QPS)和每秒Token数(TPS),实时显示系统吞吐量。', - tokens: '当前时间窗口内处理的总Token数量。', - sla: '服务等级协议达成率,排除业务限制(如余额不足、配额超限)的成功请求占比。', - errors: '错误统计,包括总错误数、错误率和上游错误率。', - latency: '请求时长统计,包括 p50、p90、p95、p99 等百分位数。', - ttft: '首 Token 延迟(Time To First Token),衡量流式响应的首 Token 返回速度。', - health: '系统健康评分(0-100),综合考虑 SLA、错误率和资源使用情况。' - }, - charts: { - emptyRequest: '该时间窗口内暂无请求。', - emptyError: '该时间窗口内暂无错误。', - resetZoom: '重置', - resetZoomHint: '重置缩放(若启用)', - downloadChart: '下载', - downloadChartHint: '下载图表图片' - } - }, - - // Settings - settings: { - title: '系统设置', - description: '管理注册、邮箱验证、默认值和 SMTP 设置', - tabs: { - general: '通用设置', - agreement: '登录条款', - features: '功能开关', - security: '安全与认证', - users: '用户默认值', - gateway: '网关服务', - email: '邮件设置', - backup: '数据备份', - payment: '支付设置', - }, - features: { - channelMonitor: { - title: '渠道监控', - description: '定期对配置的渠道发起健康检查,向用户展示可用性与延迟。关闭后调度器停止扫描,用户端列表为空。', - configureLink: '前往 渠道管理 > 渠道监控 配置监控项', - enabled: '启用渠道监控', - enabledHint: '关闭后后台不再执行定时检测,已有数据保留。', - defaultInterval: '默认检测间隔(秒)', - defaultIntervalHint: '新建渠道监控时表单的默认值,可被单个渠道覆盖。范围 15 – 3600 秒。', - }, - availableChannels: { - title: '可用渠道', - description: '向已登录用户展示他们能访问的渠道、模型和定价聚合视图。默认关闭。', - configureLink: '前往 渠道管理 > 渠道定价 配置模型价格', - enabled: '启用可用渠道', - enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。', - }, - riskControl: { - title: '风控中心', - description: '启用内容审计菜单和全端点请求审核入口。默认关闭。', - configureLink: '前往 风控中心 配置内容审计', - enabled: '启用风控中心', - enabledHint: '关闭后管理员侧边栏入口隐藏,网关内容审计不会执行。', - cyberSessionBlock: 'cyber 会话自动屏蔽', - cyberSessionBlockHint: '开启后,被上游网络安全策略(cyber_policy)拦截的会话将在 TTL 内被本地屏蔽,不再发往上游。仅屏蔽该会话,不影响同 Key 其他会话。', - cyberSessionBlockTTL: '屏蔽时长(秒)', - }, - affiliate: { - title: '邀请返利', - description: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。', - enabled: '启用邀请返利', - enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。', - rebateRate: '全局返利比例', - rebateRateHint: '充值后返给邀请人的默认比例(0-100%,例如填写 10 表示返利 10%)。', - freezeHours: '返利冻结期(小时)', - freezeHoursDesc: '新产生的返利将在冻结期内无法提现。0 = 不冻结。', - durationDays: '返利有效期(天)', - durationDaysDesc: '被邀请用户注册后多少天内的充值产生返利。0 = 永久有效。', - perInviteeCap: '单人返利上限', - perInviteeCapDesc: '每个被邀请用户最多产生的返利总额。0 = 无上限。', - customUsers: { - title: '专属用户配置', - description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。', - addButton: '添加专属用户', - searchPlaceholder: '搜索邮箱或用户名', - batchButton: '批量设置比例(已选 {count})', - empty: '暂无专属配置用户', - customBadge: '自定义', - useGlobal: '沿用全局', - resetTitle: '重置该用户的专属配置', - resetMessage: '确认将 {email} 的专属配置全部重置为默认?\n• 专属返利比例将清除(沿用全局)\n• 邀请码将重新生成为系统随机码(已分发的旧邀请链接将失效)', - totalLabel: '共 {total} 条', - col: { - email: '邮箱', - username: '用户名', - code: '邀请码', - rate: '专属比例', - actions: '操作', - }, - }, - modal: { - addTitle: '添加专属用户', - editTitle: '编辑专属配置', - userLabel: '用户', - userPlaceholder: '搜索邮箱或用户名', - changeUser: '更换用户', - codeLabel: '专属邀请码(可选)', - codePlaceholder: '例如 VIP2026', - codeHint: '4-32 位,仅支持大写字母、数字、下划线、连字符;留空表示不修改;输入将自动转大写。', - rateLabel: '专属返利比例(可选)', - ratePlaceholder: '例如 30', - rateHint: '0-100%;留空(编辑模式下)表示清除专属比例并沿用全局。', - errorBadRate: '请输入 0-100 之间的比例', - errorEmpty: '至少填写一项:专属邀请码或专属返利比例', - }, - batchModal: { - title: '批量设置专属比例(已选 {count} 个用户)', - hint: '为所选用户统一设置专属返利比例。', - placeholder: '例如 30', - clearHint: '留空提交将清除所选用户的专属比例。', - }, - }, - }, - emailTabDisabledTitle: '邮箱验证未启用', - emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。', - registration: { - title: '注册设置', - description: '控制用户注册和验证', - enableRegistration: '开放注册', - enableRegistrationHint: '允许新用户注册', - emailVerification: '邮箱验证', - emailVerificationHint: '新用户注册时需要验证邮箱', - emailSuffixWhitelist: '邮箱域名白名单', - emailSuffixWhitelistHint: - "仅允许使用指定域名的邮箱注册账号(例如 {'@'}qq.com, {'@'}gmail.com, *.edu.cn)", - emailSuffixWhitelistPlaceholder: "{'@'}example.com, *.edu.cn", - emailSuffixWhitelistInputHint: '留空则不限制。使用 *.edu.cn 可匹配 edu.cn 及其子域名。', - promoCode: '优惠码', - promoCodeHint: '允许用户在注册时使用优惠码', - invitationCode: '邀请码注册', - invitationCodeHint: '开启后,用户注册时需要填写有效的邀请码', - passwordReset: '忘记密码', - passwordResetHint: '允许用户通过邮箱重置密码', - frontendUrl: '前端地址', - frontendUrlPlaceholder: 'https://example.com', - frontendUrlHint: '用于生成邮件中的密码重置链接,例如 https://example.com', - totp: '双因素认证 (2FA)', - totpHint: '允许用户使用 Google Authenticator 等应用进行二次验证', - totpKeyNotConfigured: - '请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。' - }, - turnstile: { - title: 'Cloudflare Turnstile', - description: '登录和注册的机器人防护', - enableTurnstile: '启用 Turnstile', - enableTurnstileHint: '需要 Cloudflare Turnstile 验证', - siteKey: '站点密钥', - secretKey: '私密密钥', - siteKeyHint: '从 Cloudflare Dashboard 获取', - cloudflareDashboard: 'Cloudflare Dashboard', - secretKeyHint: '服务端验证密钥(请保密)', - secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' - }, - apiKeyAcl: { - title: 'API Key IP 访问控制', - description: '控制 API Key 白名单和黑名单使用哪个客户端 IP 判断', - trustForwardedIp: '信任反代传递的客户端 IP', - trustForwardedIpHint: - '默认关闭。仅在源站只允许 Cloudflare 或 Nginx 反代访问时开启;开启后 API Key IP 白/黑名单会使用 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For,与使用记录中的请求 IP 保持一致。' - }, - linuxdo: { - title: 'LinuxDo Connect 登录', - description: '配置 LinuxDo Connect OAuth,用于 Sub2API 用户登录', - enable: '启用 LinuxDo 登录', - enableHint: '在登录/注册页面显示 LinuxDo 登录入口', - clientId: 'Client ID', - clientIdPlaceholder: '例如:hprJ5pC3...', - clientIdHint: '从 Connect.Linux.Do 后台获取', - clientSecret: 'Client Secret', - clientSecretPlaceholder: '********', - clientSecretHint: '用于后端交换 token(请保密)', - clientSecretConfiguredPlaceholder: '********', - clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。', - redirectUrl: '回调地址(Redirect URL)', - redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/linuxdo/callback', - redirectUrlHint: '需与 Connect.Linux.Do 中配置的回调地址一致(必须是 http(s) 完整 URL)', - quickSetCopy: '使用当前站点生成并复制', - redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板' - }, - dingtalk: { - title: '钉钉登录', - description: '配置钉钉 OAuth,用于 Sub2API 用户登录', - enable: '启用钉钉登录-企业内部应用', - enableHint: '在登录/注册页面显示钉钉登录入口', - clientId: 'Client ID(AppKey)', - clientIdPlaceholder: '例如:dingxxxxxxxxxxxxxxxx', - clientIdHint: '从钉钉开放平台应用详情页获取', - clientSecret: 'Client Secret(AppSecret)', - clientSecretPlaceholder: '********', - clientSecretHint: '用于后端交换 token(请保密)', - clientSecretConfiguredPlaceholder: '********', - clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。', - redirectUrl: '回调地址(Redirect URL)', - redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/dingtalk/callback', - redirectUrlHint: '需与钉钉开放平台中配置的回调地址一致(必须是 http(s) 完整 URL)', - corpPolicy: { - label: '企业限制策略', - hint: '控制哪些钉钉账号(企业)可以登录', - none: '不限制(所有钉钉账号均可登录)', - internalOnly: '仅本企业(Internal Only)' - }, - bypassRegistration: '开放钉钉注册', - bypassRegistrationHint: '即使「开放注册」关闭时也可以通过钉钉登录来注册', - syncDisplayName: '同步钉钉姓名', - syncDisplayNameHint: '登录时将钉钉姓名写入 username 字段(同时记录到 dingtalk_name 属性)', - syncCorpEmail: '同步企业邮箱', - syncCorpEmailHint: '登录时将钉钉企业邮箱写入 dingtalk_email 属性(不影响登录邮箱)', - syncCorpEmailPermissionHint: '需在钉钉开放平台 → 应用 → 权限管理中为本应用申请「邮箱等个人信息(fieldEmail)」权限,否则 OAPI 不会返回企业邮箱字段', - syncDept: '同步部门', - syncDeptHint: '登录时将钉钉首个部门完整路径写入 dingtalk_department 属性(每次登录实时拉取)', - syncDeptPermissionHint: '需在钉钉开放平台 → 应用 → 权限管理中为本应用申请「通讯录部门信息读权限(qyapi_get_department_list)」,否则无法递归出部门路径', - syncDisplayNameTarget: '属性键', - syncDisplayNameTargetHint: '默认 dingtalk_name / 钉钉姓名;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)', - syncCorpEmailTarget: '属性键', - syncCorpEmailTargetHint: '默认 dingtalk_email / 钉钉企业邮箱;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)', - syncDeptTarget: '属性键', - syncDeptTargetHint: '默认 dingtalk_department / 钉钉部门;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)', - syncAttrDisplayName: '显示名称' - }, - oidc: { - title: 'OIDC 登录', - description: '配置标准 OIDC Provider(例如 Keycloak)', - enable: '启用 OIDC 登录', - enableHint: '在登录/注册页面显示 OIDC 登录入口', - providerName: 'Provider 名称', - providerNamePlaceholder: '例如 Keycloak', - clientId: 'Client ID', - clientIdPlaceholder: 'OIDC client id', - clientSecret: 'Client Secret', - clientSecretPlaceholder: '********', - clientSecretHint: '用于后端交换 token(请保密)', - clientSecretConfiguredPlaceholder: '********', - clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。', - issuerUrl: 'Issuer URL', - issuerUrlPlaceholder: 'https://id.example.com/realms/main', - discoveryUrl: 'Discovery URL', - discoveryUrlPlaceholder: '可选,留空将基于 issuer 自动推导', - authorizeUrl: 'Authorize URL', - authorizeUrlPlaceholder: '可选,可通过 discovery 自动获取', - tokenUrl: 'Token URL', - tokenUrlPlaceholder: '可选,可通过 discovery 自动获取', - userinfoUrl: 'UserInfo URL', - userinfoUrlPlaceholder: '可选,可通过 discovery 自动获取', - jwksUrl: 'JWKS URL', - jwksUrlPlaceholder: '可选;启用严格 ID Token 校验时必填', - scopes: 'Scopes', - scopesPlaceholder: 'openid email profile', - scopesHint: '必须包含 openid', - redirectUrl: '后端回调地址(Redirect URL)', - redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback', - redirectUrlHint: '必须与 OIDC Provider 中配置的回调地址一致', - quickSetCopy: '使用当前站点生成并复制', - redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板', - frontendRedirectUrl: '前端回调路径', - frontendRedirectUrlPlaceholder: '/auth/oidc/callback', - frontendRedirectUrlHint: '后端回调完成后重定向到此前端路径', - tokenAuthMethod: 'Token 鉴权方式', - clockSkewSeconds: '时钟偏移(秒)', - allowedSigningAlgs: '允许的签名算法', - allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256', - usePkce: '启用 PKCE', - validateIdToken: '校验 ID Token', - requireEmailVerified: '要求邮箱已验证', - userinfoEmailPath: 'UserInfo 邮箱字段路径', - userinfoEmailPathPlaceholder: '例如 data.email', - userinfoIdPath: 'UserInfo ID 字段路径', - userinfoIdPathPlaceholder: '例如 data.id', - userinfoUsernamePath: 'UserInfo 用户名字段路径', - userinfoUsernamePathPlaceholder: '例如 data.username' - }, - defaults: { - title: '用户默认设置', - description: '新用户的默认值', - defaultBalance: '默认余额', - defaultBalanceHint: '新用户的初始余额', - affiliateRebateRate: '邀请返利比例', - affiliateRebateRateHint: '充值后返给邀请人的比例(0-100%,例如填写 10 表示返利 10%)', - defaultConcurrency: '默认并发数', - defaultConcurrencyHint: '新用户的最大并发请求数', - defaultUserRpmLimit: '默认用户 RPM 限制', - defaultUserRpmLimitHint: '新用户默认每分钟最大请求数,0 = 不限制;仅作用于新用户创建时初始化', - defaultSubscriptions: '默认订阅列表', - defaultSubscriptionsHint: '新用户创建或注册时自动分配这些订阅', - addDefaultSubscription: '添加默认订阅', - defaultSubscriptionsEmpty: '未配置默认订阅。新用户不会自动获得订阅套餐。', - defaultSubscriptionsDuplicate: '默认订阅存在重复分组:{groupId}。每个分组只能出现一次。', - subscriptionGroup: '订阅分组', - subscriptionValidityDays: '有效期(天)', - defaultPlatformQuotas: '默认平台限额(注册时分配)', - defaultPlatformQuotasHint: '新用户注册时自动写入平台限额记录;已有用户不受影响。留空 = 该平台该窗口不限制。', - platformQuotaNotice: '月限额为 30 天滚动窗口,非自然月', - }, - platformQuota: { - platform: '平台', - daily: '日限额 (USD)', - weekly: '周限额 (USD)', - monthly: '月限额 (USD, 30天滚动)', - placeholder: '不限', - }, - claudeCode: { - title: 'Claude Code 设置', - description: '控制 Claude Code 客户端访问要求', - minVersion: '最低版本号', - minVersionPlaceholder: '例如 2.1.63', - minVersionHint: '拒绝低于此版本的 Claude Code 客户端请求(semver 格式)。留空则不检查版本。', - maxVersion: '最高版本号', - maxVersionPlaceholder: '例如 2.5.0', - maxVersionHint: '拒绝高于此版本的 Claude Code 客户端请求(semver 格式)。留空则不限制最高版本。' - }, - scheduling: { - title: '网关调度设置', - description: '控制 API Key 的调度行为', - allowUngroupedKey: '允许未分组 Key 调度', - allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。' - }, - gatewayForwarding: { - title: '请求转发行为', - description: '控制请求转发到上游 OAuth 账号时的行为', - fingerprintUnification: '指纹统一化', - fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。', - metadataPassthrough: 'Metadata 透传', - metadataPassthroughHint: '透传客户端原始 metadata.user_id,不进行重写。可能提高上游缓存命中率。', - cchSigning: 'CCH 签名', - cchSigningHint: '对转发请求的 billing header 进行 CCH 哈希签名。关闭时保留原始占位符。', - claudeOAuthSystemPromptInjection: 'Claude OAuth System 注入', - claudeOAuthSystemPromptInjectionHint: '为非 Claude Code 客户端的 Claude OAuth 请求注入 Claude Code 形态的 system blocks。默认开启。', - claudeOAuthSystemPrompt: 'Claude OAuth 扩展提示词', - claudeOAuthSystemPromptPlaceholder: '留空时使用内置 Claude Code 扩展提示词。', - claudeOAuthSystemPromptHint: '兼容旧配置:仅控制第三个注入的 system block。', - claudeOAuthSystemPromptBlocks: 'Claude OAuth System Blocks', - claudeOAuthSystemPromptBlocksPlaceholder: '留空时使用内置 3 个 blocks。支持数组或 {"blocks": [...]}。', - claudeOAuthSystemPromptBlocksHint: '每个 block 会保存为带 enabled、type、text、可选 cache_control 的 JSON。{billing_header} 会按请求动态生成;Claude Code 身份提示词和扩展提示词可直接编辑,也可用预设恢复默认值。', - systemBlockTitle: 'System Block {index}', - systemBlockPreset: '预设', - systemBlockPresetBilling: 'Billing Header', - systemBlockPresetIdentity: 'Claude Code 身份提示词', - systemBlockPresetExpansion: 'Claude Code 扩展提示词', - systemBlockPresetCustom: '自定义', - systemBlockType: '类型', - systemBlockTypeText: '文本', - systemBlockText: '内容', - systemBlockCacheControl: 'Cache Control', - systemBlockHide: '隐藏 block 详情', - systemBlockShow: '展示 block 详情', - addSystemBlock: '添加 block', - resetSystemBlocks: '恢复默认', - cacheTTL5m: '5 分钟', - cacheTTL1h: '1 小时', - anthropicCacheTTL1hInjection: 'Anthropic 缓存 TTL 注入', - anthropicCacheTTL1hInjectionHint: '开启后,对 Anthropic OAuth/Setup Token 请求体中已有的 ephemeral 缓存块强制写入 1h;响应 usage 默认按 5m 回写计费,账号级 TTL 计费设置优先。', - rewriteMessageCacheControl: '改写消息缓存断点', - rewriteMessageCacheControlHint: '默认关闭,保留客户端在 messages 内容块中的 cache_control。开启后会清除客户端断点并注入代理断点,适合不自行管理缓存策略的客户端。', - clientDatelineNormalization: '客户端 dateline 归一化', - clientDatelineNormalizationHint: '默认开启。将 Anthropic OAuth/Setup Token 请求体中 "Today\'s date is …" 语句里的撇号与日期分隔符还原为 ASCII 撇号 + 短横线 (2026-07-01) 的规范形态,抹除某些客户端在检测到非官方 base URL 时注入的隐写指纹位。仅作用于 system prompt 与 块内,API Key 账号不受影响。', - antigravityUserAgentVersion: 'Antigravity UA 版本', - antigravityUserAgentVersionPlaceholder: '1.23.2', - antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。', - openaiCodexUserAgent: 'OpenAI Codex UA', - openaiCodexUserAgentPlaceholder: 'codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)', - openaiCodexUserAgentHint: '用于规避 OpenAI 上游 Cloudflare 对浏览器 UA 的访问质询。仅在检测到客户端 User-Agent 为浏览器(Mozilla/...)时生效,其他客户端原样透传。留空使用内置默认值。', - codexHardeningTitle: 'Codex 设置', - codexClientRestrictionTitle: 'Codex 客户端限制', - codexHardeningDesc: - '仅对已开启「仅允许 Codex 官方客户端」的 OpenAI OAuth 账号生效(全局)。在 User-Agent/Originator 之外,用版本区间、引擎指纹门与黑/白名单巩固判定。', - minCodexVersion: '最低 Codex 版本', - minCodexVersionPlaceholder: '例如 0.142.0', - maxCodexVersion: '最高 Codex 版本', - maxCodexVersionPlaceholder: '例如 0.200.0', - codexVersionHint: - '仅对官方客户端生效,校验其版本是否落在 [最低, 最高] 区间。留空表示该侧不限制。', - codexFingerprintSignals: 'Codex 引擎指纹信号', - codexFingerprintSignalsDesc: - '定义引擎指纹信号:勾「必须」的信号需全部命中(AND),每条 / 分隔的变体取或(OR);一条都不勾即不校验。默认只勾 x-codex- 前缀。类型:头精确 / 头前缀 / body 路径。', - codexFpTypeHeaderExact: '头精确', - codexFpTypeHeaderPrefix: '头前缀', - codexFpTypeBodyPath: 'body 路径', - codexFpMatchPlaceholder: '匹配,变体用 / 分隔(如 session-id / session_id 或 x-codex-)', - codexFpRequired: '必须', - codexFingerprintNoRequiredWarn: '未勾选任何「必须」信号——引擎指纹门当前不生效,等于放行所有通过身份/版本的候选。如需启用校验,请至少勾选一条信号。', - codexAllowAppServer: 'Codex app-server', - codexAllowAppServerDesc: - '放行内嵌 Codex 引擎、经 app-server 协议接入的第三方客户端(如 Claude Code 的 codex 插件)。默认关闭;开启后此类客户端通过引擎指纹门(下方信号列表)即放行,关闭则仅放行官方客户端与白名单。', - codexBlacklist: 'User-Agent/Originator 黑名单', - codexBlacklistDesc: - '命中任一字段即拒,优先于一切放行。originator 精确匹配,User-Agent 为包含匹配(多个用逗号分隔)。', - codexWhitelist: 'User-Agent/Originator 白名单', - codexWhitelistDesc: - '放行官方集之外的客户端:需 originator 精确,且每个 User-Agent 标记都命中。默认仍需过引擎指纹门,勾「跳过引擎指纹」可免。', - codexWhitelistSkipFingerprint: '跳过引擎指纹', - codexWhitelistSkipFingerprintTooltip: - '风险:勾选后该条仅凭 originator + User-Agent(均可伪造)放行,不再要求引擎指纹兜底。仅用于确属可信、但本身不发 codex 引擎指纹的第三方客户端。', - codexOriginatorPlaceholder: 'originator(精确,如 opencode)', - codexUaContainsPlaceholder: 'User-Agent 包含标记,逗号分隔(如 opencode/)', - codexAddRow: '添加一条', - codexRemoveRow: '删除', - }, - webSearchEmulation: { - title: 'Web Search 模拟', - description: '为不原生支持搜索的 Anthropic API Key 账号注入 web search 能力', - enabled: '启用 Web Search 模拟', - enabledHint: '全局开关。关闭后所有渠道和账号的 web search 模拟均不生效。', - providers: '搜索服务商', - addProvider: '添加服务商', - providerType: '服务商类型', - apiKey: 'API Key', - apiKeyPlaceholder: '输入 API Key', - apiKeyConfigured: '已配置', - showApiKey: '显示', - hideApiKey: '隐藏', - copyApiKey: '复制', - copied: '已复制', - quotaLimit: '配额上限', - quotaLimitHint: '留空表示无限制;填写时必须大于 0', - quotaLimitMustBePositive: '配额上限必须大于 0', - subscribedAt: '订阅时间', - subscribedAtHint: '配额从此日期起每月自动重置;留空则不自动重置', - quotaUsage: '用量', - resetUsage: '重置', - resetUsageConfirm: '确定要重置此服务商的用量计数吗?', - resetUsageSuccess: '用量已重置', - proxy: '代理', - removeProvider: '删除', - noProviders: '未配置搜索服务商', - test: '测试', - testDefaultQuery: '搜索今年世界大事件', - testing: '搜索中...', - testResultTitle: '搜索结果', - testResultProvider: '服务商', - testNoResults: '无搜索结果', - }, - site: { - title: '站点设置', - description: '自定义站点品牌', - backendMode: 'Backend 模式', - backendModeDescription: - '禁用用户注册、公开页面和自助服务功能。仅管理员可以登录和管理平台。', - siteName: '站点名称', - siteNameHint: '显示在邮件和页面标题中', - siteNamePlaceholder: 'Sub2API', - siteSubtitle: '站点副标题', - siteSubtitleHint: '显示在登录和注册页面', - siteSubtitlePlaceholder: '订阅转 API 转换平台', - apiBaseUrl: 'API 端点地址', - apiBaseUrlHint: '用于"使用密钥"、"导入到 CC Switch"和回调地址建议,留空则使用当前站点地址', - apiBaseUrlPlaceholder: 'https://api.example.com', - tablePreferencesTitle: '通用表格设置', - tablePreferencesDescription: '设置后台与用户侧表格组件的默认分页行为', - tableDefaultPageSize: '默认每页条数', - tableDefaultPageSizeHint: '必须为 5-1000 之间的整数', - tablePageSizeOptions: '可选每页条数列表', - tablePageSizeOptionsPlaceholder: '10, 20, 50, 100', - tablePageSizeOptionsHint: '使用英文逗号分隔,取值范围 5-1000,保存时会自动去重并排序', - tableDefaultPageSizeRangeError: '默认每页条数必须在 {min}-{max} 之间', - tablePageSizeOptionsFormatError: '可选每页条数格式无效,请输入 {min}-{max} 之间的整数并用英文逗号分隔', - customEndpoints: { - title: '自定义端点', - description: '添加额外的 API 端点地址,用户可在「API Keys」页面快速复制', - itemLabel: '端点 #{n}', - name: '名称', - namePlaceholder: '如:OpenAI Compatible', - endpointUrl: '端点地址', - endpointUrlPlaceholder: 'https://api2.example.com', - descriptionLabel: '介绍', - descriptionPlaceholder: '如:支持 OpenAI 格式请求', - add: '添加端点', - }, - contactInfo: '客服联系方式', - contactInfoPlaceholder: '例如:QQ: 123456789', - contactInfoHint: '填写客服联系方式,将展示在兑换页面、个人资料等位置', - docUrl: '文档链接', - docUrlHint: '文档网站的链接。留空则隐藏文档链接。', - docUrlPlaceholder: 'https://docs.example.com', - siteLogo: '站点Logo', - uploadImage: '上传图片', - remove: '移除', - logoHint: 'PNG、JPG 或 SVG 格式,最大 300KB。建议:80x80px 正方形图片。', - logoSizeError: '图片大小超过 300KB 限制({size}KB)', - logoTypeError: '请选择图片文件', - logoReadError: '读取图片文件失败', - homeContent: '首页内容', - homeContentPlaceholder: - '在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。', - homeContentHint: - '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。', - homeContentIframeWarning: - '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。', - hideCcsImportButton: '隐藏 CCS 导入按钮', - hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮' - }, - purchase: { - title: '充值/订阅页面', - description: '在侧边栏展示“充值/订阅”入口,并在页面内通过 iframe 打开指定链接', - enabled: '显示充值/订阅入口', - enabledHint: '仅在标准模式(非简单模式)下展示', - url: '充值/订阅页面 URL', - urlPlaceholder: 'https://example.com/purchase', - urlHint: '必须是完整的 http(s) 链接', - iframeWarning: - '⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSP(frame-ancestors)禁止被 iframe 嵌入,出现空白时可引导用户使用”新窗口打开”。', - integrationDoc: '支付集成文档', - integrationDocHint: '包含接口说明、幂等语义及示例代码' - }, - soraClient: { - title: 'Sora 客户端', - description: '控制是否在侧边栏展示 Sora 客户端入口', - enabled: '启用 Sora 客户端', - enabledHint: '开启后,侧边栏将显示 Sora 入口,用户可访问 Sora 功能' - }, - customMenu: { - title: '自定义菜单页面', - description: '添加自定义 iframe 页面到侧边栏导航。每个页面可以设置为普通用户或管理员可见。', - itemLabel: '菜单项 #{n}', - name: '菜单名称', - namePlaceholder: '如:帮助中心', - url: '页面 URL', - urlPlaceholder: 'https://example.com/page', - iconSvg: 'SVG 图标', - iconSvgPlaceholder: '...', - iconPreview: '图标预览', - uploadSvg: '上传 SVG', - removeSvg: '清除', - visibility: '可见角色', - visibilityUser: '普通用户', - visibilityAdmin: '管理员', - add: '添加菜单项', - remove: '删除', - moveUp: '上移', - moveDown: '下移', - }, - payment: { - title: '支付设置', - description: '配置支付系统选项', - configGuide: '支付配置指南', - enabled: '启用支付', - enabledHint: '启用或禁用支付系统', - enabledPaymentTypes: '启用的服务商', - enabledPaymentTypesHint: '禁用服务商将同时禁用对应的实例。', - findProvider: '正在寻找合适的易支付服务商?', - minAmount: '最低金额', - maxAmount: '最高金额', - dailyLimit: '每日限额', - balanceRechargeMultiplier: '余额充值倍率', - balanceRechargeMultiplierHint: '用户每支付 1 CNY 可获得多少 USD 余额', - balanceRechargePreview: '预览:1 CNY = {usd} USD', - subscriptionUsdToCnyRate: '订阅 CNY 换算汇率', - subscriptionUsdToCnyRateHint: - 'CNY 支付通道下,套餐每 1 USD 价格收取多少 CNY(如 7.15)。0 或留空 = 不换算,订阅按 price 数值直接收款。启用后所有套餐 price 必须按 USD 定价', - subscriptionUsdToCnyRateDisabled: '未启用(按 price 直付)', - rechargeFeeRate: '充值手续费率', - rechargeFeeRateHint: '用户充值时额外收取的手续费百分比,0 表示不收取手续费', - rechargeFeePreview: '预览:充值 100 元,手续费 {fee} 元', - orderTimeout: '订单超时时间', - orderTimeoutHint: '单位:分钟,至少 1 分钟', - maxPendingOrders: '最大待支付订单数', - cancelRateLimit: '限制取消频率', - cancelRateLimitHint: '启用后,用户在时间窗口内取消订单次数超限将无法创建新订单', - cancelRateLimitEvery: '每', - cancelRateLimitAllowMax: '最多', - cancelRateLimitTimes: '次', - cancelRateLimitWindow: '时间窗口', - cancelRateLimitUnit: '周期', - cancelRateLimitMax: '最大取消次数', - cancelRateLimitUnitMinute: '分钟', - cancelRateLimitUnitHour: '小时', - cancelRateLimitUnitDay: '天', - cancelRateLimitWindowMode: '窗口模式', - cancelRateLimitWindowModeRolling: '滚动', - cancelRateLimitWindowModeFixed: '固定', - alipayForceQRCode: '支付宝强制二维码支付', - alipayForceQRCodeHint: '启用后,移动端支付宝用户将统一使用二维码扫码支付,不再跳转至手机网站支付', - helpText: '帮助文本', - helpImageUrl: '帮助图片链接', - manageProviders: '管理服务商', - balancePaymentDisabled: '禁用余额充值', - noLimit: '留空表示不限制', - helpImage: '帮助图片', - helpImagePlaceholder: '上传或输入图片链接', - helpTextPlaceholder: '输入帮助说明文本...', - providerEasypay: '易支付', - providerAlipay: '支付宝官方', - providerWxpay: '微信官方', - providerStripe: 'Stripe', - providerAirwallex: 'Airwallex', - typeDisabled: '类型已禁用', - enableTypesFirst: '请先在上方启用至少一种服务商', - easypayRedirect: '跳转', - paymentMode: '支付模式', - modeRedirect: '跳转', - modeQRCode: '二维码', - modePopup: '弹窗', - validationNameRequired: '服务商名称不能为空', - validationTypesRequired: '请至少选择一种支持的支付方式', - validationFieldRequired: '{field} 不能为空', - validationEasyPayCustomMethodRequired: '每个易支付自定义方式都必须填写支付方式和上游 type', - validationEasyPayCustomMethodTypeInvalid: '易支付自定义支付方式只能包含小写字母、数字、下划线和短横线', - validationEasyPayCustomMethodUpstreamTypeInvalid: '易支付上游 type 只能包含小写字母、数字、下划线和短横线', - validationEasyPayCustomMethodReserved: '易支付自定义支付方式不能使用内置的 alipay 或 wxpay', - validationEasyPayCustomMethodPrefixReserved: '易支付自定义支付方式不能以 alipay 或 wxpay 开头', - validationEasyPayCustomMethodDuplicate: '易支付自定义支付方式不能重复', - field_apiBase: 'API 基础地址', - field_notifyUrl: '异步通知地址', - field_returnUrl: '同步跳转地址', - callbackBaseUrl: '回调基础地址', - field_privateKey: '私钥', - field_publicKey: '公钥', - field_mpAppId: '公众号 App ID', - field_mchId: '商户号', - field_apiV3Key: 'API v3 密钥', - field_publicKeyId: '公钥 ID', - field_certSerial: '证书序列号', - field_h5AppName: 'H5 应用名称', - field_h5AppUrl: 'H5 应用地址', - wxpayConfigHint: '微信支付通常只需要填写 App ID。公众号 App ID、H5 应用名称、H5 应用地址仅在公众号支付或 H5 场景有特殊要求时再填写。', - wxpayAdvancedOptions: '微信支付高级可选项', - field_secretKey: '密钥', - field_clientId: 'Client ID', - field_apiKey: 'API Key', - field_publishableKey: '公开密钥', - field_webhookSecret: 'Webhook 密钥', - field_countryCode: '国家/地区代码', - field_currency: '支付币种', - field_accountId: 'Airwallex 账户 ID', - field_airwallexApiBaseHint: '必须和 API Key 所属环境一致:沙箱/测试密钥使用 https://api-demo.airwallex.com/api/v1,生产密钥使用 https://api.airwallex.com/api/v1。环境混用会返回 credentials_invalid / Access Denied。', - field_paymentCurrencyHint: '默认 CNY。Stripe 和 Airwallex 可按账户支持从下拉项选择 HKD、USD 等币种;微信、支付宝、易支付仍按 CNY。', - field_accountIdHint: '不涉及多账户、组织级密钥或连接账户收款时可以不填;单账户 Scoped API Key 会默认使用所选账户。', - field_cid: '支付渠道 ID', - field_cidAlipay: '支付宝渠道 ID', - field_cidWxpay: '微信渠道 ID', - easypayCustomMethods: '易支付自定义支付方式', - easypayCustomMethodsHint: '添加当前易支付服务商额外支持的支付方式。支付方式会记录到 Sub2API 订单中,上游 type 会作为易支付 type 参数提交。', - addCustomMethod: '添加方式', - customMethodType: '支付方式', - customMethodUpstreamType: '上游 type', - customMethodDisplayName: '显示名称', - stripeWebhookHint: '请在 Stripe Dashboard 中将以下地址配置为 Webhook 端点:', - stripeWebhookApiVersionHint: 'Webhook 端点的 API 版本请与当前集成的 Stripe SDK 对齐,建议选择 {version};版本不一致可能导致回调事件解析失败。', - airwallexWebhookHint: '请在 Airwallex 后台将以下地址配置为 Webhook 端点;事件至少选择 Payment Intent -> Succeeded(payment_intent.succeeded),建议同时选择 Payment Intent -> Cancelled(payment_intent.cancelled);API version 选择账户默认或最新稳定版本。', - airwallexGuideSummary: '创建 Airwallex Scoped API 密钥时,建议只在账户级权限中为 Payment Acceptance 勾选读取和写入。', - airwallexGuideNote: '不需要勾选 Spend、Payouts、Transfers、Funds Splits、POS 终端等与在线收款无关的权限。Webhook 事件至少选择 payment_intent.succeeded,建议同时选择 payment_intent.cancelled;API version 选择账户默认或最新稳定版本。', - limitsTitle: '限额配置', - limitSingleMin: '单笔最低', - limitSingleMax: '单笔最高', - limitDaily: '每日限额', - limitsHint: '全部留空使用全局配置,部分填写时留空项表示不限制', - limitsUseGlobal: '使用全局配置', - limitsNoLimit: '不限制', - productNamePrefix: '商品名前缀', - productNameSuffix: '商品名后缀', - preview: '预览', - loadBalanceStrategy: '负载均衡策略', - strategyRoundRobin: '轮询', - strategyLeastAmount: '最少金额', - providerManagement: '服务商管理', - providerManagementDesc: '管理支付服务商实例', - createProvider: '添加服务商', - editProvider: '编辑服务商', - deleteProvider: '删除服务商', - deleteProviderConfirm: '确定要删除此服务商吗?', - providerName: '服务商名称', - providerKey: '服务商类型', - selectProviderKey: '选择服务商类型', - providerConfig: '凭证配置', - paymentGuideTrigger: '查看支付方式说明', - guideOpenLabel: '开通:', - guideCallLabel: '调用:', - guideFallbackLabel: '降级:', - alipayGuideSummary: '桌面优先扫码单,失败再走收银台;移动优先手机网站支付。', - alipayGuideFaceToFaceTitle: '当面付 / 扫码支付', - alipayGuideFaceToFaceOpen: '需开通当面付或扫码支付能力。', - alipayGuideFaceToFaceCall: '桌面端下单时优先调用 alipay.trade.precreate,前台直接渲染二维码。', - alipayGuideFaceToFaceFallback: '接口不可用或返回失败时,自动降级到电脑网站支付。', - alipayGuidePagePayTitle: '电脑网站支付', - alipayGuidePagePayOpen: '需开通电脑网站支付。', - alipayGuidePagePayCall: '桌面端当面付不可用时调用 alipay.trade.page.pay,并继续把返回链接渲染成二维码。', - alipayGuidePagePayFallback: '同时保留打开收银台入口,用户可手动重新拉起支付页。', - alipayGuideWapTitle: '手机网站支付', - alipayGuideWapOpen: '需开通手机网站支付。', - alipayGuideWapCall: '移动端优先调用 alipay.trade.wap.pay,跳转支付宝收银台。', - alipayGuideWapFallback: '未开通或返回异常时,前端自动改走扫码支付并提示未开通移动支付。', - wxpayGuideSummary: '桌面优先 Native 扫码,移动端按浏览器环境走 JSAPI 或 H5。', - wxpayGuideNote: '当前表单默认共用一个 App ID,适合同主体下统一配置网页、移动和公众号场景。', - wxpayGuideNativeTitle: 'Native / 扫码支付', - wxpayGuideNativeOpen: '需开通 Native 或扫码支付能力。', - wxpayGuideNativeCall: '桌面端默认调用 Native,下发二维码内容给前台渲染。', - wxpayGuideNativeFallback: '移动端无法走 JSAPI 或 H5 时,也会自动回退到这里。', - wxpayGuideJsapiTitle: 'JSAPI / 公众号支付', - wxpayGuideJsapiOpen: '需开通公众号支付,并保证当前浏览器在微信内且能拿到 OpenID。', - wxpayGuideJsapiCall: '微信内浏览器完成授权后调用 JSAPI,直接拉起微信支付。', - wxpayGuideJsapiFallback: '未配置、Bridge 不可用或拉起失败时,自动改走扫码支付。', - wxpayGuideH5Title: 'H5 支付', - wxpayGuideH5Open: '需开通 H5 支付。', - wxpayGuideH5Call: '移动端非微信浏览器且有客户端 IP 时调用 H5 支付,跳转微信收银台。', - wxpayGuideH5Fallback: '未开通 H5 或下单失败时,自动改走扫码支付。', - noProviders: '暂无服务商实例', - supportedTypes: '支持的支付方式', - supportedTypesHint: '逗号分隔,如 alipay,wxpay', - refundEnabled: '允许退款', - allowUserRefund: '允许用户退款', - enableConflict: '{method} 已有启用中的服务商实例:{provider}。请先停用现有实例后再启用或切换。', - }, - balanceNotify: { - title: '余额不足提醒', - description: '当用户余额低于阈值时发送邮件提醒', - enabled: '启用余额不足提醒', - threshold: '默认提醒阈值', - thresholdHint: '用户未自定义时使用此值', - thresholdPlaceholder: '输入金额', - rechargeUrl: '充值页面 URL', - rechargeUrlPlaceholder: 'https://example.com/payment', - rechargeUrlHint: '设置后邮件中将包含充值链接按钮', - }, - quotaNotify: { - title: '账号限额通知', - description: '当账号配额用量达到告警阈值时通知管理员', - enabled: '启用账号限额通知', - emails: '通知邮箱', - emailsHint: '留空则不发送通知', - addEmail: '添加邮箱', - emailPlaceholder: '输入邮箱地址', - }, - subscriptionExpiryNotify: { - title: '订阅到期提醒', - description: '控制是否向用户发送订阅即将到期的邮件提醒。', - enabled: '启用订阅到期提醒', - enabledHint: '开启后,系统会在订阅到期前 7 天、3 天、1 天各发送一次提醒。' - }, - smtp: { - title: 'SMTP 设置', - description: '配置用于发送验证码的邮件服务', - testConnection: '测试连接', - testing: '测试中...', - host: 'SMTP 主机', - hostPlaceholder: 'smtp.gmail.com', - port: 'SMTP 端口', - portPlaceholder: '587', - username: 'SMTP 用户名', - usernamePlaceholder: "your-email{'@'}gmail.com", - password: 'SMTP 密码', - passwordPlaceholder: '********', - passwordHint: '留空以保留现有密码', - passwordConfiguredPlaceholder: '********', - passwordConfiguredHint: '密码已配置,留空以保留当前值。', - fromEmail: '发件人邮箱', - fromEmailPlaceholder: "noreply{'@'}example.com", - fromName: '发件人名称', - fromNamePlaceholder: 'Sub2API', - useTls: '使用 TLS', - useTlsHint: '为 SMTP 连接启用 TLS 加密' - }, - testEmail: { - title: '发送测试邮件', - description: '发送测试邮件以验证 SMTP 配置', - recipientEmail: '收件人邮箱', - recipientEmailPlaceholder: "test{'@'}example.com", - sendTestEmail: '发送测试邮件', - sending: '发送中...', - enterRecipientHint: '请输入收件人邮箱地址' - }, - emailTemplates: { - title: '邮件模板', - description: '按事件和语言自定义通知邮件主题与 HTML 内容。', - event: '事件', - locale: '语言', - localeEn: '英文', - localeZh: '中文', - subject: '主题', - subjectPlaceholder: '输入邮件主题', - html: 'HTML 模板', - htmlPlaceholder: '编辑邮件 HTML 模板', - placeholders: '可用占位符', - placeholdersHelp: '点击占位符可复制。后端发送邮件时会替换这些值。', - livePreview: '实时预览', - previewSecurityHint: '预览 HTML 由后端预览接口生成,并在禁用脚本的沙盒 iframe 中展示。', - preview: '预览 / 刷新', - previewing: '预览中...', - save: '保存模板', - saving: '保存中...', - restoreOfficial: '恢复官方模板', - restoring: '恢复中...', - restoreConfirm: '确定恢复此事件和语言的官方模板吗?当前自定义版本将被替换。', - restoreSuccess: '已恢复官方模板', - saveSuccess: '邮件模板已保存', - placeholderCopied: '占位符已复制', - validationRequired: '主题和 HTML 模板不能为空', - empty: '暂无可用的邮件模板事件或语言。', - noPreview: '刷新预览后查看渲染后的邮件主题。', - customized: '已自定义' - }, - opsMonitoring: { - title: '运维监控', - description: '启用运维监控模块,用于排障与健康可视化', - disabled: '运维监控已关闭', - enabled: '启用运维监控', - enabledHint: '启用运维监控模块(仅管理员可见)', - realtimeEnabled: '启用实时监控', - realtimeEnabledHint: '启用实时请求速率和指标推送(WebSocket)', - queryMode: '默认查询模式', - queryModeHint: '运维监控默认查询模式(自动/原始/预聚合)', - queryModeAuto: '自动(推荐)', - queryModeRaw: '原始(最准确,但较慢)', - queryModePreagg: '预聚合(最快,需预聚合)', - metricsInterval: '采集频率(秒)', - metricsIntervalHint: '系统/请求指标采集频率(60-3600 秒)' - }, - adminApiKey: { - title: '管理员 API Key', - description: '用于外部系统集成的全局 API Key,拥有完整的管理员权限', - notConfigured: '尚未配置管理员 API Key', - configured: '管理员 API Key 已启用', - currentKey: '当前密钥', - regenerate: '重新生成', - regenerating: '生成中...', - delete: '删除', - deleting: '删除中...', - create: '创建密钥', - creating: '创建中...', - regenerateConfirm: '确定要重新生成吗?当前密钥将立即失效。', - deleteConfirm: '确定要删除管理员 API Key 吗?外部集成将停止工作。', - keyGenerated: '新的管理员 API Key 已生成', - keyDeleted: '管理员 API Key 已删除', - copyKey: '复制密钥', - keyCopied: '密钥已复制到剪贴板', - keyWarning: '此密钥仅显示一次,请立即复制保存。', - securityWarning: '警告:此密钥拥有完整的管理员权限,请妥善保管。', - usage: '使用方法:在请求头中添加 x-api-key: ' - }, - soraS3: { - title: 'Sora 存储配置', - description: '以多配置列表管理 Sora 媒体存储,支持 S3 和 Google Drive', - newProfile: '新建配置', - reloadProfiles: '刷新列表', - empty: '暂无存储配置,请先创建', - createTitle: '新建存储配置', - editTitle: '编辑存储配置', - selectProvider: '选择存储类型', - providerS3Desc: 'S3 兼容对象存储', - providerGDriveDesc: 'Google Drive 云盘', - profileID: '配置 ID', - profileName: '配置名称', - setActive: '创建后设为生效', - saveProfile: '保存配置', - activateProfile: '设为生效', - profileCreated: '存储配置创建成功', - profileSaved: '存储配置保存成功', - profileDeleted: '存储配置删除成功', - profileActivated: '生效配置已切换', - profileIDRequired: '请填写配置 ID', - profileNameRequired: '请填写配置名称', - profileSelectRequired: '请先选择配置', - endpointRequired: '启用时必须填写 S3 端点', - bucketRequired: '启用时必须填写存储桶', - accessKeyRequired: '启用时必须填写 Access Key ID', - deleteConfirm: '确定删除存储配置 {profileID} 吗?', - columns: { - profile: '配置', - profileId: 'Profile ID', - name: '名称', - provider: '存储类型', - active: '生效状态', - endpoint: '端点', - bucket: '存储桶', - storagePath: '存储路径', - capacityUsage: '容量 / 已用', - capacityUnlimited: '无限制', - videoCount: '视频数', - videoCompleted: '完成', - videoInProgress: '进行中', - quota: '默认配额', - updatedAt: '更新时间', - actions: '操作', - rootFolder: '根目录', - testInTable: '测试', - testingInTable: '测试中...', - testTimeout: '测试超时(15秒)' - }, - enabled: '启用存储', - enabledHint: '启用后,Sora 生成的媒体文件将自动上传到存储', - endpoint: 'S3 端点', - region: '区域', - bucket: '存储桶', - prefix: '对象前缀', - accessKeyId: 'Access Key ID', - secretAccessKey: 'Secret Access Key', - secretConfigured: '(已配置,留空保持不变)', - cdnUrl: 'CDN URL', - cdnUrlHint: '可选,配置后使用 CDN URL 访问文件', - forcePathStyle: '强制路径风格(Path Style)', - defaultQuota: '默认存储配额', - defaultQuotaHint: '未在用户或分组级别指定配额时的默认值,0 表示无限制', - testConnection: '测试连接', - testing: '测试中...', - testSuccess: '连接测试成功', - testFailed: '连接测试失败', - saved: '存储设置保存成功', - saveFailed: '保存存储设置失败', - gdrive: { - authType: '认证方式', - serviceAccount: '服务账号', - clientId: 'Client ID', - clientSecret: 'Client Secret', - clientSecretConfigured: '(已配置,留空保持不变)', - refreshToken: 'Refresh Token', - refreshTokenConfigured: '(已配置,留空保持不变)', - serviceAccountJson: '服务账号 JSON', - serviceAccountConfigured: '(已配置,留空保持不变)', - folderId: 'Folder ID(可选)', - authorize: '授权 Google Drive', - authorizeHint: '通过 OAuth2 获取 Refresh Token', - oauthFieldsRequired: '请先填写 Client ID 和 Client Secret', - oauthSuccess: 'Google Drive 授权成功', - oauthFailed: 'Google Drive 授权失败', - closeWindow: '此窗口将自动关闭', - processing: '正在处理授权...', - testStorage: '测试存储', - testSuccess: 'Google Drive 存储测试成功(上传、访问、删除均正常)', - testFailed: 'Google Drive 存储测试失败' - } - }, - overloadCooldown: { - title: '529 过载冷却', - description: '配置上游返回 529(过载)时的账号调度暂停策略', - enabled: '启用过载冷却', - enabledHint: '收到 529 错误时暂停该账号的调度,冷却后自动恢复', - cooldownMinutes: '冷却时长(分钟)', - cooldownMinutesHint: '账号暂停调度的持续时间(1-120 分钟)', - saved: '过载冷却设置保存成功', - saveFailed: '保存过载冷却设置失败' - }, - rateLimit429Cooldown: { - title: '429 默认回避', - description: '配置上游返回 429 且没有明确重置时间时的默认账号回避策略', - enabled: '启用 429 默认回避', - enabledHint: '收到无重置时间的 429 时暂停该账号调度,冷却后自动恢复', - cooldownSeconds: '回避时长(秒)', - cooldownSecondsHint: '默认回避持续时间(1-7200 秒);上游返回明确 reset 时仍优先使用上游时间', - saved: '429 默认回避设置保存成功', - saveFailed: '保存 429 默认回避设置失败' - }, - streamTimeout: { - title: '流超时处理', - description: '配置上游响应超时时的账户处理策略,避免问题账户持续被选中', - enabled: '启用流超时处理', - enabledHint: '当上游响应超时时,自动处理问题账户', - timeoutSeconds: '超时阈值(秒)', - timeoutSecondsHint: '流数据间隔超过此时间视为超时(30-300秒)', - action: '处理方式', - actionTempUnsched: '临时不可调度', - actionError: '标记为错误状态', - actionNone: '不处理', - actionHint: '超时后对账户执行的操作', - tempUnschedMinutes: '暂停时长(分钟)', - tempUnschedMinutesHint: '临时不可调度的持续时间(1-60分钟)', - thresholdCount: '触发阈值(次数)', - thresholdCountHint: '累计超时多少次后触发处理(1-10次)', - thresholdWindowMinutes: '阈值窗口(分钟)', - thresholdWindowMinutesHint: '超时计数的时间窗口(1-60分钟)', - saved: '流超时设置保存成功', - saveFailed: '保存流超时设置失败' - }, - rectifier: { - title: '请求整流器', - description: '当上游返回特定错误时,自动修正请求参数并重试,提高请求成功率', - enabled: '启用请求整流器', - enabledHint: '总开关,关闭后所有整流功能均不生效', - thinkingSignature: 'Thinking 签名整流', - thinkingSignatureHint: '当上游返回 thinking block 签名校验错误时,自动去除签名并重试', - thinkingBudget: 'Thinking Budget 整流', - thinkingBudgetHint: '当上游返回 budget_tokens 约束错误(≥1024)时,自动将 budget 设为 32000 并重试', - apikeySignature: 'API Key 签名整流', - apikeySignatureHint: - '当 API Key 账号的上游返回签名相关错误时,自动去除签名并重试(内置规则始终生效)', - apikeyPatterns: '自定义匹配关键词', - apikeyPatternsHint: - '额外的关键词,匹配响应体中的内容(不区分大小写)。内置规则始终生效,此处用于补充额外匹配。', - apikeyPatternPlaceholder: '例如:thinking_error 或 签名无效', - addPattern: '添加关键词', - saved: '整流器设置保存成功', - saveFailed: '保存整流器设置失败' - }, - betaPolicy: { - title: 'Beta 策略', - description: '配置转发 Anthropic API 请求时如何处理 Beta 特性。仅适用于 /v1/messages 接口。', - action: '处理方式', - actionPass: '透传(不处理)', - actionFilter: '过滤(移除)', - actionBlock: '拦截(拒绝请求)', - scope: '生效范围', - scopeAll: '全部账号', - scopeOAuth: '仅 OAuth 账号', - scopeAPIKey: '仅 API Key 账号', - scopeBedrock: '仅 Bedrock 账号', - errorMessage: '错误消息', - errorMessagePlaceholder: '拦截时返回的自定义错误消息', - errorMessageHint: '留空则使用默认错误消息', - saved: 'Beta 策略设置保存成功', - saveFailed: '保存 Beta 策略设置失败', - modelWhitelist: '模型白名单', - modelWhitelistHint: '留空则对所有模型生效。支持精确匹配和通配符前缀(如 claude-opus-*)', - modelPatternPlaceholder: '例如: claude-opus-* 或 claude-opus-4-6', - addModelPattern: '添加模型规则', - removePattern: '移除', - fallbackAction: '未匹配模型处理方式', - fallbackActionHint: '当请求模型不在白名单中时的处理方式', - fallbackErrorMessagePlaceholder: '未匹配模型被拦截时返回的自定义错误消息', - quickPresets: '快捷预设', - presetOpusOnly: '仅 Opus 允许 1M', - presetOpusOnlyDesc: 'Opus 透传,其他模型过滤', - commonPatterns: '常用模式' - }, - openaiFastPolicy: { - title: 'OpenAI Fast/Flex 策略', - description: '基于请求体 service_tier 字段拦截/过滤/透传 OpenAI fast(priority) 与 flex 请求;仅作用于 OpenAI 网关。', - empty: '尚未配置任何规则。点击下方按钮新增。', - ruleHeader: '规则 #{index}', - removeRule: '删除规则', - addRule: '新增规则', - saveHint: '保存时随系统设置一起提交(点击页面底部「保存」按钮)。', - serviceTier: 'service_tier 匹配', - tierAll: '全部 tier', - tierPriority: 'priority(fast)', - tierFlex: 'flex', - action: '处理方式', - actionPass: '透传(保留 service_tier)', - actionFilter: '过滤(移除 service_tier)', - actionForcePriority: '强制设置 priority(fast)', - actionBlock: '拦截(拒绝请求)', - scope: '生效范围', - scopeAll: '全部账号', - scopeOAuth: '仅 OAuth 账号', - scopeAPIKey: '仅 API Key 账号', - scopeBedrock: '仅 Bedrock 账号', - errorMessage: '错误消息', - errorMessagePlaceholder: '拦截时返回的自定义错误消息', - errorMessageHint: '留空则使用默认错误消息。', - modelWhitelist: '模型白名单', - modelWhitelistHint: '留空表示对所有模型生效;支持精确匹配与通配符(如 gpt-5.5*)。', - modelPatternPlaceholder: '例如: gpt-5.5 或 gpt-5.5*', - addModelPattern: '添加模型规则', - fallbackAction: '未匹配模型处理方式', - fallbackActionHint: '当请求模型不在白名单中时的处理方式。', - fallbackErrorMessagePlaceholder: '未匹配模型被拦截时返回的自定义错误消息' - }, - wechatConnect: { - title: '微信登录', - description: '用于微信开放平台或公众号/小程序的第三方登录配置。', - enabledLabel: '启用微信登录', - enabledHint: '开启后可使用微信第三方登录回调与授权配置。', - appIdLabel: 'AppID', - appIdPlaceholder: '微信开放平台 AppID', - appSecretLabel: 'AppSecret', - appSecretConfiguredPlaceholder: '密钥已配置,留空以保留当前值。', - appSecretPlaceholder: '微信开放平台 AppSecret', - appSecretConfiguredHint: '密钥已配置,留空以保留当前值。', - appSecretHint: '填写后会覆盖当前微信密钥。', - modeLabel: '模式', - openModeLabel: '非微信环境使用开放平台', - openModeHint: '浏览器不在微信内时,自动走开放平台扫码授权。', - mpModeLabel: '微信环境使用公众号', - mpModeHint: '浏览器在微信内时,自动走公众号授权。', - redirectUrlLabel: '回调地址', - redirectUrlPlaceholder: 'https://your-site.com/api/v1/auth/oauth/wechat/callback', - generateAndCopy: '使用当前站点生成并复制', - redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板', - frontendRedirectUrlLabel: '前端回调地址', - frontendRedirectUrlPlaceholder: '/auth/wechat/callback', - frontendRedirectUrlHint: '通常用于前端路由回调地址,需与后端配置保持一致。' - }, - authSourceDefaults: { - title: '认证来源默认值', - description: '按注册来源配置新用户默认余额、并发、订阅与授权策略。', - requireEmailLabel: '第三方注册强制补充邮箱', - requireEmailHint: '启用后,Linux DO、OIDC、微信注册缺少邮箱时必须先补充邮箱地址。', - enabledHint: '以下默认值会在该来源注册新用户时发放;首次绑定时授权仅作用于已有账号绑定该来源。', - sources: { - email: { - title: '邮箱注册', - description: '适用于邮箱密码注册的新用户默认配额。' - }, - linuxdo: { - title: 'Linux DO 登录', - description: '适用于 Linux DO 第三方注册的新用户默认配额。' - }, - oidc: { - title: 'OIDC 登录', - description: '适用于 OIDC 第三方注册的新用户默认配额。' - }, - wechat: { - title: '微信登录', - description: '适用于微信第三方注册的新用户默认配额。' - } - }, - grantOnFirstBindLabel: '首次绑定时授权', - grantOnFirstBindHint: '已有账号首次绑定该来源时发放默认权益。', - defaultSubscriptionsLabel: '默认订阅', - defaultSubscriptionsHint: '仅对当前认证来源生效,未配置时不追加来源专属订阅。', - noSourceSubscriptions: '当前来源未配置专属默认订阅。', - platformQuotasOverride: '平台限额覆盖', - platformQuotasOverrideHint: '留空的字段继承「系统默认平台限额」;填 0 表示禁止该窗口使用。', - }, - paymentVisibleMethods: { - methodLabel: '{title} 可见方式', - methodHint: '控制前台结算页是否展示该方式,以及展示时使用的来源键。', - sourceLabel: '支付来源', - sourceHint: '启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。', - sourceRequiredError: '{title} 已启用,请先选择支付来源。' - }, - openaiExperimentalScheduler: { - title: 'OpenAI 实验调度策略', - description: '默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。', - stickyWeightedTitle: '粘性加权', - stickyWeightedDescription: '开启后 previous_response_id 和 session_hash 粘性进入高级调度打分;关闭时仍按旧逻辑硬命中粘性账号。', - subscriptionPriorityTitle: '订阅优先', - subscriptionPriorityDescription: '开启后先在 ChatGPT 订阅账号池中按权值选取;订阅池拿不到席位时再回退到非订阅账号池。', - weightsTitle: '调度权值覆盖', - weightsDescription: '留空时使用配置/环境变量值;配置未设置时使用内置默认值。页面非空设置优先。', - defaultPlaceholder: '配置/默认:{value}', - topKLabel: 'TopK', - priorityWeight: '优先级', - loadWeight: '负载', - queueWeight: '排队', - errorRateWeight: '错误率', - ttftWeight: '首包延迟', - resetWeight: '重置窗口', - quotaHeadroomWeight: '额度余量', - previousResponseWeight: 'previous_response 粘性', - sessionStickyWeight: 'session_hash 粘性' - }, - usageRecords: { - title: '使用记录', - description: '与终端用户可见的用量及失败请求记录相关的设置。', - }, - user_error_view: { - label: '允许用户查看自己的错误请求', - description: '开启后,用户可在用量页查看自己失败请求的精简信息(不含内部/上游错误细节)。需运维监控开启才有数据。', - }, - saveSettings: '保存设置', - saving: '保存中...', - settingsSaved: '设置保存成功', - smtpConnectionSuccess: 'SMTP 连接成功', - testEmailSent: '测试邮件发送成功', - failedToLoad: '加载设置失败', - failedToSave: '保存设置失败', - failedToTestSmtp: 'SMTP 连接测试失败', - failedToSendTestEmail: '发送测试邮件失败' - }, - - // Error Passthrough Rules - errorPassthrough: { - title: '错误透传规则', - description: '配置上游错误如何返回给客户端', - createRule: '创建规则', - editRule: '编辑规则', - deleteRule: '删除规则', - noRules: '暂无规则', - createFirstRule: '创建第一条错误透传规则', - allPlatforms: '所有平台', - passthrough: '透传', - custom: '自定义', - code: '状态码', - body: '消息体', - skipMonitoring: '跳过监控', - - // Columns - columns: { - priority: '优先级', - name: '名称', - conditions: '匹配条件', - platforms: '平台', - behavior: '响应行为', - status: '状态', - actions: '操作' - }, - - // Match Mode - matchMode: { - any: '错误码 或 关键词', - all: '错误码 且 关键词', - anyHint: '状态码匹配任一错误码,或消息包含任一关键词', - allHint: '状态码匹配任一错误码,且消息包含任一关键词' - }, - - // Form - form: { - name: '规则名称', - namePlaceholder: '例如:上下文超限透传', - priority: '优先级', - priorityHint: '数值越小优先级越高,优先匹配', - description: '规则描述', - descriptionPlaceholder: '描述此规则的用途...', - matchConditions: '匹配条件', - errorCodes: '错误码', - errorCodesPlaceholder: '422, 400, 429', - errorCodesHint: '多个错误码用逗号分隔', - keywords: '关键词', - keywordsPlaceholder: '每行一个关键词\ncontext limit\nmodel not supported', - keywordsHint: '每行一个关键词,不区分大小写', - matchMode: '匹配模式', - platforms: '适用平台', - platformsHint: '不选择表示适用于所有平台', - responseBehavior: '响应行为', - passthroughCode: '透传上游状态码', - responseCode: '自定义状态码', - passthroughBody: '透传上游错误信息', - customMessage: '自定义错误信息', - customMessagePlaceholder: '返回给客户端的错误信息...', - skipMonitoring: '跳过运维监控记录', - skipMonitoringHint: '开启后,匹配此规则的错误不会被记录到运维监控中', - enabled: '启用此规则' - }, - - // Messages - nameRequired: '请输入规则名称', - conditionsRequired: '请至少配置一个错误码或关键词', - ruleCreated: '规则创建成功', - ruleUpdated: '规则更新成功', - ruleDeleted: '规则删除成功', - deleteConfirm: '确定要删除规则 "{name}" 吗?', - failedToLoad: '加载规则失败', - failedToSave: '保存规则失败', - failedToDelete: '删除规则失败', - failedToToggle: '切换状态失败' - }, - - // TLS 指纹模板 - tlsFingerprintProfiles: { - title: 'TLS 指纹模板', - description: '管理 TLS 指纹模板,用于模拟特定客户端的 TLS 握手特征', - createProfile: '创建模板', - editProfile: '编辑模板', - deleteProfile: '删除模板', - noProfiles: '暂无模板', - createFirstProfile: '创建你的第一个 TLS 指纹模板', - - columns: { - name: '名称', - description: '描述', - grease: 'GREASE', - alpn: 'ALPN', - actions: '操作' - }, - - form: { - pasteYaml: '粘贴 YAML 配置', - pasteYamlPlaceholder: '将 TLS 指纹采集器复制的 YAML 粘贴到这里...', - pasteYamlHint: '粘贴从 TLS 指纹采集器复制的 YAML 配置,自动填充所有字段。', - openCollector: '打开采集器', - parseYaml: '解析 YAML', - yamlParsed: 'YAML 解析成功,字段已自动填充', - yamlParseFailed: 'YAML 解析失败:未找到 name 字段', - name: '模板名称', - namePlaceholder: '例如 macOS Node.js v24', - description: '描述', - descriptionPlaceholder: '可选的模板描述', - enableGrease: '启用 GREASE', - enableGreaseHint: '在 TLS ClientHello 扩展中插入 GREASE 值', - cipherSuites: '密码套件', - cipherSuitesHint: '逗号分隔的十六进制值,例如 0x1301, 0x1302, 0xc02c', - curves: '椭圆曲线', - curvesHint: '逗号分隔的曲线 ID', - pointFormats: '点格式', - signatureAlgorithms: '签名算法', - alpnProtocols: 'ALPN 协议', - alpnProtocolsHint: '逗号分隔,例如 h2, http/1.1', - supportedVersions: '支持的 TLS 版本', - keyShareGroups: '密钥共享组', - pskModes: 'PSK 模式', - extensions: '扩展' - }, - - deleteConfirm: '删除模板', - deleteConfirmMessage: '确定要删除模板 "{name}" 吗?使用此模板的账号将回退到内置默认值。', - createSuccess: '模板创建成功', - updateSuccess: '模板更新成功', - deleteSuccess: '模板删除成功', - loadFailed: '加载模板失败', - saveFailed: '保存模板失败', - deleteFailed: '删除模板失败' - } - }, - - // Subscription Progress (Header component) - subscriptionProgress: { - title: '我的订阅', - viewDetails: '查看订阅详情', - activeCount: '{count} 个有效订阅', - daily: '每日', - weekly: '每周', - monthly: '每月', - daysRemaining: '剩余 {days} 天', - expired: '已过期', - expiresToday: '今天到期', - expiresTomorrow: '明天到期', - viewAll: '查看全部订阅', - noSubscriptions: '暂无有效订阅', - unlimited: '无限制' - }, - - // Version Badge - version: { - currentVersion: '当前版本', - latestVersion: '最新版本', - upToDate: '已是最新版本', - updateAvailable: '有新版本可用!', - releaseNotes: '更新日志', - noReleaseNotes: '暂无更新日志', - viewUpdate: '查看更新', - viewRelease: '查看发布', - viewChangelog: '查看更新日志', - refresh: '刷新', - sourceMode: '源码构建', - sourceModeHint: '源码构建请使用 git pull 更新', - updateNow: '立即更新', - updating: '正在更新...', - updateComplete: '更新完成', - updateFailed: '更新失败', - restartRequired: '请重启服务以应用更新', - restartNow: '立即重启', - restarting: '正在重启...', - retry: '重试' - }, - - // Recharge / Subscription Page - purchase: { - title: '充值/订阅', - description: '通过内嵌页面完成充值/订阅', - openInNewTab: '新窗口打开', - notEnabledTitle: '该功能未开启', - notEnabledDesc: '管理员暂未开启充值/订阅入口,请联系管理员。', - notConfiguredTitle: '充值/订阅链接未配置', - notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。' - }, - - // Custom Page (iframe embed) - customPage: { - title: '自定义页面', - openInNewTab: '新窗口打开', - notFoundTitle: '页面不存在', - notFoundDesc: '该自定义页面不存在或已被删除。', - notConfiguredTitle: '页面链接未配置', - notConfiguredDesc: '该自定义页面的 URL 未正确配置。', - tableOfContents: '目录', - copyCode: '复制', - copiedCode: '已复制', - copyCodeFailed: '失败' - }, - - // Announcements Page - announcements: { - title: '公告', - description: '查看系统公告', - unreadOnly: '仅显示未读', - markRead: '标记已读', - markAllRead: '全部已读', - viewAll: '查看全部公告', - markedAsRead: '已标记为已读', - allMarkedAsRead: '所有公告已标记为已读', - newCount: '有 {count} 条新公告', - readAt: '已读时间', - read: '已读', - unread: '未读', - startsAt: '开始时间', - endsAt: '结束时间', - empty: '暂无公告', - emptyUnread: '暂无未读公告', - total: '条公告', - emptyDescription: '暂时没有任何系统公告', - readStatus: '您已阅读此公告', - markReadHint: '点击"已读"标记此公告' - }, - - // User Subscriptions Page - userSubscriptions: { - title: '我的订阅', - description: '查看您的订阅计划和用量', - noActiveSubscriptions: '暂无有效订阅', - noActiveSubscriptionsDesc: '您没有任何有效订阅。请联系管理员获取订阅。', - failedToLoad: '加载订阅失败', - status: { - active: '有效', - expired: '已过期', - revoked: '已撤销' - }, - usage: '用量', - expires: '到期时间', - noExpiration: '无到期时间', - unlimited: '无限制', - unlimitedDesc: '该订阅无用量限制', - daily: '每日', - weekly: '每周', - monthly: '每月', - daysRemaining: '剩余 {days} 天', - expiresOn: '{date} 到期', - resetIn: '{time} 后重置', - quotaEndsIn: '额度将在 {time} 后结束', - windowNotActive: '等待首次使用', - usageOf: '已用 {used} / {limit}' - }, - - // Onboarding Tour - onboarding: { - restartTour: '重新查看新手引导', - dontShowAgain: '不再提示', - dontShowAgainTitle: '永久关闭新手引导', - confirmDontShow: '确定不再显示新手引导吗?\n\n您可以随时在右上角头像菜单中重新开启。', - confirmExit: '确定要退出新手引导吗?您可以随时在右上角菜单重新开始。', - interactiveHint: '按 Enter 或点击继续', - navigation: { - flipPage: '翻页', - exit: '退出' - }, - // Admin tour steps - admin: { - welcome: { - title: '👋 欢迎使用 Sub2API', - description: - '

Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。

🎯 核心功能:

  • 📦 分组管理 - 创建不同的服务套餐(VIP、免费试用等)
  • 🔗 账号池 - 连接多个上游 AI 服务商账号
  • 🔑 密钥分发 - 为用户生成独立的 API Key
  • 💰 计费管理 - 灵活的费率和配额控制

接下来,我们将用 3 分钟带您完成首次配置 →

', - nextBtn: '开始配置 🚀', - prevBtn: '跳过' - }, - groupManage: { - title: '📦 第一步:分组管理', - description: - '

什么是分组?

分组是 Sub2API 的核心概念,它就像一个"服务套餐":

  • 🎯 每个分组可以包含多个上游账号
  • 💰 每个分组有独立的计费倍率
  • 👥 可以设置为公开或专属分组

💡 示例:您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组

👉 点击左侧的"分组管理"开始

' - }, - createGroup: { - title: '➕ 创建新分组', - description: - '

现在让我们创建第一个分组。

📝 提示:建议先创建一个测试分组,熟悉流程后再创建正式分组

👉 点击"创建分组"按钮

' - }, - groupName: { - title: '✏️ 1. 分组名称', - description: - '

为您的分组起一个易于识别的名称。

💡 命名建议:
  • "测试分组" - 用于测试
  • "VIP专线" - 高质量服务
  • "免费试用" - 体验版

填写完成后点击"下一步"继续

', - nextBtn: '下一步' - }, - groupPlatform: { - title: '🤖 2. 选择平台', - description: - '

选择该分组支持的 AI 平台。

📌 平台说明:
  • Anthropic - Claude 系列模型
  • OpenAI - GPT 系列模型
  • Google - Gemini 系列模型

一个分组只能选择一个平台

', - nextBtn: '下一步' - }, - groupMultiplier: { - title: '💰 3. 费率倍数', - description: - '

设置该分组的计费倍率,控制用户的实际扣费。

⚙️ 计费规则:
  • 1.0 - 原价计费(成本价)
  • 1.5 - 用户消耗 $1,扣除 $1.5
  • 2.0 - 用户消耗 $1,扣除 $2
  • 0.8 - 补贴模式(亏本运营)

建议测试分组设置为 1.0

', - nextBtn: '下一步' - }, - groupExclusive: { - title: '🔒 4. 专属分组(可选)', - description: - '

控制分组的可见性和访问权限。

🔐 权限说明:
  • 关闭 - 公开分组,所有用户可见
  • 开启 - 专属分组,仅指定用户可见

💡 使用场景:VIP 用户专属、内部测试、特殊客户等

', - nextBtn: '下一步' - }, - groupSubmit: { - title: '✅ 保存分组', - description: - '

确认信息无误后,点击创建按钮保存分组。

⚠️ 注意:分组创建后,平台类型不可修改,其他信息可以随时编辑

📌 下一步:创建成功后,我们将添加上游账号到这个分组

👉 点击"创建"按钮

' - }, - accountManage: { - title: '🔗 第二步:添加账号', - description: - '

太棒了!分组已创建成功 🎉

现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。

🔑 账号的作用:
  • 连接到上游 AI 服务(Claude、GPT 等)
  • 一个分组可以包含多个账号(负载均衡)
  • 支持 OAuth 和 Session Key 两种方式

👉 点击左侧的"账号管理"

' - }, - createAccount: { - title: '➕ 添加新账号', - description: - '

点击按钮开始添加您的第一个上游账号。

💡 提示:建议使用 OAuth 方式,更安全且无需手动提取密钥

👉 点击"添加账号"按钮

' - }, - accountName: { - title: '✏️ 1. 账号名称', - description: - '

为账号设置一个便于识别的名称。

💡 命名建议:"Claude主账号"、"GPT备用1"、"测试账号" 等

', - nextBtn: '下一步' - }, - accountPlatform: { - title: '🤖 2. 选择平台', - description: - '

选择该账号对应的服务商平台。

⚠️ 重要:平台必须与刚才创建的分组平台一致

', - nextBtn: '下一步' - }, - accountType: { - title: '🔐 3. 授权方式', - description: - '

选择账号的授权方式。

✅ 推荐:OAuth 方式
  • 无需手动提取密钥
  • 更安全,支持自动刷新
  • 适用于 Claude Code、ChatGPT OAuth
📌 Session Key 方式
  • 需要手动从浏览器提取
  • 可能需要定期更新
  • 适用于不支持 OAuth 的平台
', - nextBtn: '下一步' - }, - accountPriority: { - title: '⚖️ 4. 优先级(可选)', - description: - '

设置账号的调用优先级。

📊 优先级规则:
  • 数字越小,优先级越高
  • 系统优先使用低数值账号
  • 相同优先级则随机选择

💡 使用场景:主账号设置低数值,备用账号设置高数值

', - nextBtn: '下一步' - }, - accountGroups: { - title: '🎯 5. 分配分组', - description: - '

关键步骤!将账号分配到刚才创建的分组。

⚠️ 重要提醒:
  • 必须勾选至少一个分组
  • 未分配分组的账号无法使用
  • 一个账号可以分配给多个分组

💡 提示:请勾选刚才创建的测试分组

', - nextBtn: '下一步' - }, - accountSubmit: { - title: '✅ 保存账号', - description: - '

确认信息无误后,点击保存按钮。

📌 OAuth 授权流程:
  • 点击保存后会跳转到服务商页面
  • 在服务商页面完成登录授权
  • 授权成功后自动返回

📌 下一步:账号添加成功后,我们将创建 API 密钥

👉 点击"保存"按钮

' - }, - keyManage: { - title: '🔑 第三步:生成密钥', - description: - '

恭喜!账号配置完成 🎉

最后一步,生成 API Key 来测试服务是否正常工作。

🔑 API Key 的作用:
  • 用于调用 AI 服务的凭证
  • 每个 Key 绑定一个分组
  • 可以设置配额和有效期
  • 支持独立的使用统计

👉 点击左侧的"API 密钥"

' - }, - createKey: { - title: '➕ 创建密钥', - description: - '

点击按钮创建您的第一个 API Key。

💡 提示:创建后请立即复制保存,密钥只显示一次

👉 点击"创建密钥"按钮

' - }, - keyName: { - title: '✏️ 1. 密钥名称', - description: - '

为密钥设置一个便于管理的名称。

💡 命名建议:"测试密钥"、"生产环境"、"移动端" 等

', - nextBtn: '下一步' - }, - keyGroup: { - title: '🎯 2. 选择分组', - description: - '

选择刚才配置好的分组。

📌 分组决定:
  • 该密钥可以使用哪些账号
  • 计费倍率是多少
  • 是否为专属密钥

💡 提示:选择刚才创建的测试分组

', - nextBtn: '下一步' - }, - keySubmit: { - title: '🎉 生成并复制', - description: - '

点击创建后,系统会生成完整的 API Key。

⚠️ 重要提醒:
  • 密钥只显示一次,请立即复制
  • 丢失后需要重新生成
  • 妥善保管,不要泄露给他人
🚀 下一步:
  • 复制生成的 sk-xxx 密钥
  • 在支持 OpenAI 接口的客户端中使用
  • 开始体验 AI 服务!

👉 点击"创建"按钮

' - } - }, - // User tour steps - user: { - welcome: { - title: '👋 欢迎使用 Sub2API', - description: - '

您好!欢迎来到 Sub2API AI 服务平台。

🎯 快速开始:

  • 🔑 创建 API 密钥
  • 📋 复制密钥到您的应用
  • 🚀 开始使用 AI 服务

只需 1 分钟,让我们开始吧 →

', - nextBtn: '开始 🚀', - prevBtn: '跳过' - }, - keyManage: { - title: '🔑 API 密钥管理', - description: - '

在这里管理您的所有 API 访问密钥。

📌 什么是 API 密钥?
API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。

👉 点击进入密钥页面

' - }, - createKey: { - title: '➕ 创建新密钥', - description: - '

点击按钮创建您的第一个 API 密钥。

💡 提示:创建后密钥只显示一次,请务必复制保存

👉 点击"创建密钥"

' - }, - keyName: { - title: '✏️ 密钥名称', - description: - '

为密钥起一个便于识别的名称。

💡 示例:"我的第一个密钥"、"测试用" 等

', - nextBtn: '下一步' - }, - keyGroup: { - title: '🎯 选择分组', - description: - '

选择管理员为您分配的服务分组。

📌 分组说明:
不同分组可能有不同的服务质量和计费标准,请根据需要选择。

', - nextBtn: '下一步' - }, - keySubmit: { - title: '🎉 完成创建', - description: - '

点击确认创建您的 API 密钥。

⚠️ 重要:
  • 创建后请立即复制密钥(sk-xxx)
  • 密钥只显示一次,丢失需重新生成

🚀 如何使用:
将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!

👉 点击"创建"按钮

' - } - } - }, - - // Payment System - payment: { - title: '充值/订阅', - amountLabel: '充值金额', - paymentAmount: '支付金额', - creditedBalance: '到账余额', - quickAmounts: '快捷金额', - customAmount: '自定义金额', - enterAmount: '输入金额', - paymentMethod: '支付方式', - fee: '手续费', - actualPay: '实付金额', - createOrder: '确认支付', - methods: { - easypay: '易支付', - alipay: '支付宝', - wxpay: '微信支付', - stripe: 'Stripe', - airwallex: 'Airwallex', - card: '银行卡', - link: 'Link', - alipay_direct: '支付宝(直连)', - wxpay_direct: '微信支付(直连)', - }, - status: { - pending: '待支付', - paid: '已支付', - recharging: '充值中', - completed: '已完成', - expired: '已过期', - cancelled: '已取消', - failed: '失败', - refund_requested: '退款申请中', - refunding: '退款中', - refund_pending: '退款处理中', - refunded: '已退款', - partially_refunded: '部分退款', - refund_failed: '退款失败', - }, - qr: { - scanToPay: '请扫码支付', - scanAlipay: '支付宝扫码支付', - scanWxpay: '微信扫码支付', - scanAlipayHint: '请使用手机打开支付宝,扫描二维码完成支付', - scanWxpayHint: '请使用手机打开微信,扫描二维码完成支付', - payInNewWindow: '请在新窗口中完成支付', - payInNewWindowHint: '支付页面已在新窗口打开,请在新窗口中完成支付后返回此页面', - openPayWindow: '重新打开支付页面', - expiresIn: '剩余支付时间', - expired: '订单已过期', - expiredDesc: '订单已超时,请重新创建订单', - cancelled: '订单已取消', - cancelledDesc: '您已取消本次支付', - waitingPayment: '等待支付...', - cancelOrder: '取消订单', - }, - orders: { - title: '我的订单', - empty: '暂无订单', - orderId: '订单 ID', - orderNo: '订单编号', - amount: '金额', - payAmount: '实付', - creditedAmount: '到账金额', - fee: '手续费', - baseAmount: '充值金额', - includedInPayAmount: '已含在实付金额中', - status: '状态', - paymentMethod: '支付方式', - createdAt: '创建时间', - cancel: '取消订单', - userId: '用户 ID', - orderType: '订单类型', - actions: '操作', - requestRefund: '申请退款', - }, - result: { - success: '支付成功', - subscriptionSuccess: '订阅成功', - processing: '支付处理中', - processingHint: '支付结果仍在确认中,页面会自动刷新。', - failed: '支付失败', - backToRecharge: '返回充值', - viewOrders: '查看订单', - }, - currentBalance: '当前余额', - groupFallback: '分组 #{id}', - rechargeAccount: '充值账户', - activeSubscription: '当前订阅', - noActiveSubscription: '暂无有效订阅', - tabTopUp: '充值', - tabSubscribe: '订阅', - noPlans: '暂无可用订阅套餐', - notAvailable: '充值功能暂未开放', - confirmSubscription: '确认订阅', - confirmCancel: '确定要取消此订单吗?', - amountTooLow: '最低金额为 {min}', - amountTooHigh: '最高金额为 {max}', - amountNoMethod: '该金额没有可用的支付方式', - rechargeRatePreview: '当前倍率:1 CNY = {usd} USD', - refundReason: '退款原因', - refundReasonPlaceholder: '请描述您的退款原因', - stripeLoadFailed: '支付组件加载失败,请刷新页面重试', - stripeMissingParams: '缺少订单ID或支付密钥', - stripeNotConfigured: 'Stripe 未配置', - airwallexLoadFailed: 'Airwallex 支付组件加载失败,请刷新页面重试', - airwallexMissingParams: '缺少 Airwallex 支付参数', - errors: { - tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单', - cancelRateLimited: '取消订单过于频繁,请稍后再试', - wechatH5NotAuthorized: '当前商户未开通微信 H5 支付,请在微信中打开当前页面继续支付。', - wechatPaymentMpNotConfigured: '当前站点未完成公众号/JSAPI 支付配置,暂时无法在微信内直接拉起支付。', - wechatJsapiUnavailable: '当前环境未能拉起微信支付,请确认正在微信内打开本页后重试。', - wechatJsapiFailed: '微信支付未完成,请重新拉起支付或改用扫码支付。', - wechatUnavailable: '当前微信支付暂不可用,请稍后重试。', - wechatOpenInWeChatHint: '请复制当前页面链接到微信内打开,或直接改用电脑端微信扫码支付。', - wechatScanOnDesktopHint: '电脑端请直接使用微信扫一扫完成支付;移动端请在微信内打开当前页面。', - wechatSwitchBrowserHint: '请改用电脑端微信扫码,或在外部浏览器重新打开本页后再试。', - mobilePaymentFallbackToQr: '当前商户未开通移动支付,已自动切换为扫码支付。', - alipayDesktopUnavailable: '当前支付宝桌面支付未成功生成二维码。', - alipayDesktopQrHint: '电脑端支付宝应展示扫码单,请刷新后重试,或确认浏览器未拦截当前支付页。', - alipayMobileUnavailable: '当前页面未成功跳转到支付宝。', - alipayMobileOpenHint: '请允许当前页面打开支付宝 App,或改用系统浏览器重新发起支付。', - // Structured error codes (reason strings from backend ApplicationError) - PAYMENT_DISABLED: '支付系统已关闭', - USER_INACTIVE: '账号已被禁用', - BALANCE_PAYMENT_DISABLED: '余额充值功能已关闭', - INVALID_AMOUNT: '金额无效', - INVALID_INPUT: '参数有误', - PLAN_NOT_AVAILABLE: '套餐不存在或已下架', - GROUP_NOT_FOUND: '订阅分组不可用', - GROUP_TYPE_MISMATCH: '分组类型不是订阅类型', - TOO_MANY_PENDING: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单', - DAILY_LIMIT_EXCEEDED: '今日充值已达上限,剩余额度 {remaining}', - PAYMENT_GATEWAY_ERROR: '支付方式不可用', - NO_AVAILABLE_INSTANCE: '暂无可用的支付通道', - PAYMENT_PROVIDER_MISCONFIGURED: '支付通道配置错误,请联系管理员', - WXPAY_CONFIG_MISSING_KEY: '微信支付配置缺少必填项:{key}', - WXPAY_CONFIG_INVALID_KEY_LENGTH: '微信支付 {key} 长度错误,应为 {expected} 字节(实际 {actual})', - WXPAY_CONFIG_INVALID_KEY: '微信支付 {key} 格式错误,请确认复制了完整的 PEM 内容', - PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作', - PAYMENT_PROVIDER_CONFLICT: '该支付方式已有其他启用中的服务商实例,请先停用后再继续。', - CANCEL_RATE_LIMITED: '取消订单过于频繁,请稍后再试', - NOT_FOUND: '订单不存在', - FORBIDDEN: '无权限操作此订单', - CONFLICT: '订单状态已变更,请刷新', - INVALID_ORDER_TYPE: '仅余额订单可申请退款', - INVALID_STATUS: '当前订单状态不允许此操作', - BALANCE_NOT_ENOUGH: '退款金额超过余额', - REFUND_AMOUNT_EXCEEDED: '退款金额超过充值金额', - REFUND_FAILED: '退款失败', - }, - airwallexPay: 'Airwallex 支付', - stripePay: '立即支付', - stripeSuccessProcessing: '支付成功,正在处理订单...', - stripePopup: { - redirecting: '正在跳转到支付页面...', - loadingQr: '正在获取微信支付二维码...', - timeout: '等待支付凭证超时,请重试', - qrFailed: '未能获取微信支付二维码', - }, - subscribeNow: '立即开通', - renewNow: '续费', - selectPlan: '选择套餐', - planFeatures: '功能特性', - planCard: { - rate: '倍率', - peakRate: '高峰倍率', - dailyLimit: '日限额', - weeklyLimit: '周限额', - monthlyLimit: '月限额', - quota: '配额', - unlimited: '无限制', - models: '模型', - }, - days: '天', - months: '个月', - years: '年', - oneMonth: '1 个月', - oneYear: '1 年', - perMonth: '月', - perYear: '年', - admin: { - tabs: { - overview: '概览', - orders: '订单管理', - channels: '支付渠道', - plans: '订阅套餐', - }, - todayRevenue: '今日收入', - totalRevenue: '总收入', - todayOrders: '今日订单', - orderCount: '订单数', - avgAmount: '平均金额', - revenue: '收入', - dailyRevenue: '每日收入', - paymentDistribution: '支付方式分布', - colUser: '用户', - topUsers: '消费排行', - noData: '暂无数据', - days: '天', - weeks: '周', - months: '月', - searchOrders: '搜索订单...', - allStatuses: '全部状态', - allPaymentTypes: '全部支付方式', - allOrderTypes: '全部订单类型', - orderDetail: '订单详情', - orderType: '订单类型', - orders: '订单', - balanceOrder: '余额充值', - subscriptionOrder: '订阅', - paidAt: '支付时间', - completedAt: '完成时间', - expiresAt: '过期时间', - feeRate: '手续费率', - refund: '退款', - refundOrder: '退款订单', - refundAmount: '退款金额', - maxRefundable: '最大可退金额', - refundReason: '退款原因', - refundReasonPlaceholder: '请输入退款原因', - confirmRefund: '确认退款', - refundSuccess: '退款成功', - refundPending: '退款处理中,待网关确认', - queryRefundStatus: '查询退款状态', - refundInfo: '退款信息', - refundEnabled: '允许退款', - alreadyRefunded: '已退款', - deductBalance: '扣除余额', - deductBalanceHint: '从用户余额中扣回充值金额', - userBalance: '用户余额', - orderAmount: '订单金额', - insufficientBalance: '余额不足,将扣至 $0', - noDeduction: '将不扣除用户余额', - forceRefund: '强制退款(忽略余额检查)', - orderCancelled: '订单已取消', - retry: '重试', - retrySuccess: '重试成功', - approveRefund: '批准退款', - retryRefund: '重试退款', - refundRequestInfo: '退款申请信息', - refundRequestedAt: '申请时间', - refundRequestedBy: '申请人', - refundRequestReason: '申请原因', - auditLogs: '操作日志', - operator: '操作人', - channelName: '渠道名称', - channelDescription: '渠道描述', - createChannel: '创建渠道', - editChannel: '编辑渠道', - deleteChannel: '删除渠道', - deleteChannelConfirm: '确定要删除此渠道吗?', - planName: '套餐名称', - planDescription: '套餐描述', - createPlan: '创建套餐', - editPlan: '编辑套餐', - deletePlan: '删除套餐', - deletePlanConfirm: '确定要删除此套餐吗?', - originalPrice: '原价', - price: '价格', - subscriptionCnyPayPreview: 'CNY 通道实扣预览:{amount}', - subscriptionCnyPayPreviewWithFee: '(含 {feeRate}% 手续费:{total})', - validityDays: '有效期(天)', - validityUnit: '有效期单位', - sortOrder: '排序', - forSale: '上架状态', - onSale: '上架', - offSale: '下架', - group: '分组', - groupId: '分组 ID', - features: '功能特性', - featuresHint: '每行一个特性', - featuresPlaceholder: '输入套餐特性...', - providerManagement: '服务商管理', - providerManagementDesc: '管理支付服务商实例', - createProvider: '创建服务商', - editProvider: '编辑服务商', - deleteProvider: '删除服务商', - deleteProviderConfirm: '确定要删除此服务商吗?', - providerName: '服务商名称', - providerKey: '服务商标识', - selectProviderKey: '选择服务商标识', - providerConfig: '服务商配置', - noProviders: '暂无服务商', - noProvidersHint: '创建一个服务商实例以开始接受支付', - supportedTypes: '支持的支付方式', - supportedTypesHint: '选择此服务商支持的支付方式', - rateMultiplier: '费率倍数', - dashboardTitle: '支付概览', - dashboardDesc: '充值订单统计与分析', - daySuffix: '天', - paymentConfigTitle: '支付配置', - paymentConfigDesc: '管理支付服务商与相关设置', - plansPageTitle: '订阅套餐管理', - plansPageDesc: '管理订阅套餐配置', - tabPlanConfig: '套餐配置', - tabUserSubs: '用户订阅', - selectGroup: '请选择分组', - groupRequired: '请选择订阅分组', - priceRequired: '价格必须大于 0', - validityDaysRequired: '有效期天数必须大于 0', - groupMissing: '缺失', - groupInfo: '分组信息', - platform: '平台', - rateMultiplierLabel: '倍率', - dailyLimit: '日限额', - weeklyLimit: '周限额', - monthlyLimit: '月限额', - unlimited: '无限制', - searchUserSubs: '搜索用户订阅...', - daily: '日', - weekly: '周', - monthly: '月', - subsStatus: { - active: '生效中', - expired: '已过期', - revoked: '已撤销', - }, - }, - }, - -} diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts new file mode 100644 index 0000000000..c664386d4d --- /dev/null +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -0,0 +1,1257 @@ +export default { + accounts: { + title: '账号管理', + description: '管理 AI 平台账号和 Cookie', + createAccount: '添加账号', + autoRefresh: '自动刷新', + enableAutoRefresh: '启用自动刷新', + refreshInterval5s: '5 秒', + refreshInterval10s: '10 秒', + refreshInterval15s: '15 秒', + refreshInterval30s: '30 秒', + autoRefreshCountdown: '自动刷新:{seconds}s', + listPendingSyncHint: '列表存在待同步变更,点击同步可补齐最新数据。', + listPendingSyncAction: '立即同步', + syncFromCrs: '从 CRS 同步', + dataExport: '导出', + dataExportSelected: '导出选中', + dataExportIncludeProxies: '导出代理(导出账号关联的代理)', + dataImport: '导入', + moreActions: '更多操作', + dataActions: '数据操作', + toolActions: '工具', + viewColumns: '列显示', + selectedCount: '已选 {count}', + dataExportConfirmMessage: '导出的数据包含账号与代理的敏感信息,请妥善保存。', + dataExportConfirm: '确认导出', + dataExported: '数据导出成功', + dataExportedSkippedShadows: '数据已导出。已跳过 {count} 个 spark 影子账号:其调度配置不在备份内,还原后需在重建的影子上重新调优。', + dataExportFailed: '数据导出失败', + dataImportTitle: '导入数据', + dataImportHint: '上传导出的 JSON 文件以批量导入账号与代理。', + dataImportWarning: '导入将创建新账号与代理,分组需手工绑定;请确认已有数据不会冲突。', + dataImportFile: '数据文件', + dataImportButton: '开始导入', + dataImporting: '导入中...', + dataImportSelectFile: '请选择数据文件', + dataImportParseFailed: '数据解析失败', + dataImportParseFailedFile: '文件 {name} 解析失败', + dataImportInvalidFile: '文件 {name} 不是受支持的导出数据文件', + dataImportIgnoredFiles: '已忽略 {count} 个非 JSON 文件', + dataImportFailed: '数据导入失败', + dataImportResult: '导入结果', + dataImportResultSummary: '代理创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed};账号创建 {account_created},失败 {account_failed}', + dataImportErrors: '失败详情', + dataImportSuccess: '导入完成:账号 {account_created},失败 {account_failed}', + dataImportCompletedWithErrors: '导入完成但有错误:账号失败 {account_failed},代理失败 {proxy_failed}', + syncFromCrsTitle: '从 CRS 同步账号', + syncFromCrsDesc: + '将 claude-relay-service(CRS)中的账号同步到当前系统(不会在浏览器侧直接请求 CRS)。', + crsVersionRequirement: '⚠️ 注意:CRS 版本必须 ≥ v1.1.240 才支持此功能', + crsBaseUrl: 'CRS 服务地址', + crsBaseUrlPlaceholder: '例如:http://127.0.0.1:3000', + crsUsername: '用户名', + crsPassword: '密码', + syncProxies: '同时同步代理(按 host/port/账号匹配或自动创建)', + syncNow: '开始同步', + syncing: '同步中...', + syncMissingFields: '请填写服务地址、用户名和密码', + syncResult: '同步结果', + syncResultSummary: '创建 {created},更新 {updated},跳过 {skipped},失败 {failed}', + syncErrors: '错误/跳过详情', + syncCompleted: '同步完成:创建 {created},更新 {updated},跳过 {skipped}', + syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated},跳过 {skipped})', + syncFailed: '同步失败', + crsPreview: '预览', + crsPreviewing: '预览中...', + crsPreviewFailed: '预览失败', + crsExistingAccounts: '将自动更新的已有账号', + crsNewAccounts: '新账号(可选择)', + crsSelectAll: '全选', + crsSelectNone: '全不选', + crsNoNewAccounts: '所有 CRS 账号均已同步。', + crsWillUpdate: '将更新 {count} 个已有账号。', + crsSelectedCount: '已选择 {count} 个新账号', + crsUpdateBehaviorNote: + '已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。', + crsBack: '返回', + editAccount: '编辑账号', + deleteAccount: '删除账号', + deleteConfirmMessage: "确定要删除账号 '{name}' 吗?", + refreshCookie: '刷新 Cookie', + testAccount: '测试账号', + searchAccounts: '搜索账号...', + notes: '备注', + notesPlaceholder: '请输入备注', + notesHint: '备注可选', + // Filter options + allPlatforms: '全部平台', + allTypes: '全部类型', + allStatus: '全部状态', + allGroups: '全部分组', + ungroupedGroup: '未分配分组', + oauthType: 'OAuth', + // Schedulable toggle + schedulable: '参与调度', + schedulableHint: '开启后账号参与API请求调度', + schedulableEnabled: '调度已开启', + schedulableDisabled: '调度已关闭', + failedToToggleSchedulable: '切换调度状态失败', + groupCountTotal: '共 {count} 个分组', + columns: { + name: '名称', + id: '账号ID', + platformType: '平台/类型', + platform: '平台', + type: '类型', + capacity: '容量', + notes: '备注', + priority: '优先级', + billingRateMultiplier: '账号倍率', + weight: '权重', + schedulerScore: '调度权值', + status: '状态', + schedulable: '调度', + todayStats: '今日统计', + groups: '分组', + usageWindows: '用量窗口', + proxy: '代理', + lastUsed: '最近使用', + createdAt: '创建时间', + expiresAt: '过期时间', + actions: '操作' + }, + schedulerScore: { + baseShort: '普通', + stickyShort: '粘性', + ungrouped: '未分组', + hint: '显示格式为“分组名 / 基础分 / 粘性加分”。基础分按当前筛选条件限定的候选账号计算,包含优先级、负载、排队、错误率、首包延迟、重置窗口、额度余量等因子;粘性加分只在开启粘性加权时用于 previous_response_id 或 session_hash。分数越大越优先。' + }, + usageWindowsHint: '“5h / 7d”是上游账号(如 OpenAI ChatGPT、Claude)官方的滚动用量窗口限制,由上游对账号设定,并非 sub2api 配置,也与你映射的模型无关。窗口滚动到期后用量会自动重置,无法在 sub2api 端解除该限制。', + allPrivacyModes: '全部Privacy状态', + privacyUnset: '未设置', + privacyTrainingOff: '已关闭训练数据共享', + privacyCfBlocked: '被 Cloudflare 拦截,训练可能仍开启', + privacyFailed: '关闭训练数据共享失败', + privacyAntigravitySet: '已关闭遥测和营销邮件', + privacyAntigravityFailed: '隐私设置失败', + setPrivacy: '设置隐私', + subscriptionAbnormal: '异常', + subscriptionExpires: '到期', + // 容量状态提示 + capacity: { + windowCost: { + blocked: '5h窗口费用超限,账号暂停调度', + stickyOnly: '5h窗口费用达阈值,仅允许粘性会话', + normal: '5h窗口费用正常' + }, + sessions: { + full: '活跃会话已满,新会话需等待(空闲超时:{idle}分钟)', + normal: '活跃会话正常(空闲超时:{idle}分钟)' + }, + rpm: { + full: '已达 RPM 上限', + warning: 'RPM 接近上限', + normal: 'RPM 正常', + tieredNormal: 'RPM 限制 (三区模型) - 正常', + tieredWarning: 'RPM 限制 (三区模型) - 接近阈值', + tieredStickyOnly: 'RPM 限制 (三区模型) - 仅粘性会话 | 缓冲区: {buffer}', + tieredBlocked: 'RPM 限制 (三区模型) - 已阻塞 | 缓冲区: {buffer}', + stickyExemptNormal: 'RPM 限制 (粘性豁免) - 正常', + stickyExemptWarning: 'RPM 限制 (粘性豁免) - 接近阈值', + stickyExemptOver: 'RPM 限制 (粘性豁免) - 超限,仅粘性会话' + }, + quota: { + exceeded: '配额已用完,账号暂停调度', + normal: '配额正常' + }, + }, + clearRateLimit: '清除速率限制', + resetQuota: '重置配额', + quotaLimit: '配额限制', + quotaLimitPlaceholder: '0 表示不限制', + quotaLimitHint: '设置日/周/总使用额度(美元),任一维度达到限额后账号暂停调度。Anthropic API Key 账号还可配置客户端亲和。修改限额不会重置已用额度。', + quotaLimitToggle: '启用配额限制', + quotaLimitToggleHint: '开启后,当账号用量达到设定额度时自动暂停调度', + quotaDailyLimit: '日限额', + quotaDailyLimitHint: '从首次使用起每 24 小时自动重置。', + quotaWeeklyLimit: '周限额', + quotaWeeklyLimitHint: '从首次使用起每 7 天自动重置。', + quotaTotalLimit: '总限额', + quotaTotalLimitHint: '累计消费上限,不会自动重置 — 使用「重置配额」手动清零。', + quotaResetMode: '重置方式', + quotaResetModeRolling: '滚动窗口', + quotaResetModeFixed: '固定时间', + quotaResetHour: '重置时间', + quotaWeeklyResetDay: '重置日', + quotaResetTimezone: '重置时区', + quotaDailyLimitHintFixed: '每天 {hour}:00({timezone})重置。', + quotaWeeklyLimitHintFixed: '每{day} {hour}:00({timezone})重置。', + dayOfWeek: { + monday: '周一', + tuesday: '周二', + wednesday: '周三', + thursday: '周四', + friday: '周五', + saturday: '周六', + sunday: '周日', + }, + quotaLimitAmount: '总限额', + quotaLimitAmountHint: '累计消费上限,不会自动重置。', + quotaNotify: { + alert: '提醒阈值', + enabled: '启用告警', + threshold: '告警金额', + thresholdPlaceholder: '输入百分比', + }, + testConnection: '测试连接', + reAuthorize: '重新授权', + refreshToken: '刷新令牌', + noAccountsYet: '暂无账号', + createFirstAccount: '添加 AI 平台账号以开始使用 API 网关。', + tokenRefreshed: 'Token 刷新成功', + accountDeleted: '账号删除成功', + rateLimitCleared: '速率限制已清除', + setupToken: 'Setup Token', + apiKey: 'API Key', + deleteConfirm: "确定要删除账号 '{name}' 吗?此操作无法撤销。", + failedToClearRateLimit: '清除速率限制失败', + platforms: { + claude: 'Claude', + openai: 'OpenAI', + anthropic: 'Anthropic', + gemini: 'Gemini', + antigravity: 'Antigravity', + grok: 'Grok', + }, + types: { + oauth: 'OAuth', + chatgptOauth: 'ChatGPT OAuth', + responsesApi: 'Responses API', + googleOauth: 'Google OAuth', + codeAssist: 'Code Assist', + antigravityOauth: 'Antigravity OAuth', + grokOauth: 'Grok OAuth', + antigravityApikey: '通过 Base URL + API Key 连接', + upstream: '对接上游', + upstreamDesc: '通过 Base URL + API Key 连接上游', + api_key: 'API Key', + cookie: 'Cookie' + }, + antigravityProjectIdLabel: 'GCP Project ID(可选)', + antigravityProjectIdPlaceholder: 'your-gcp-project-id', + antigravityProjectIdHint: + 'standard-tier 且未自动返回 project_id 的 Antigravity 账号需要填写用户自带 GCP project。', + status: { + active: '正常', + inactive: '停用', + error: '错误', + cooldown: '冷却中', + paused: '暂停', + limited: '限流', + rateLimited: '限流中', + overloaded: '过载中', + tempUnschedulable: '临时不可调度', + quotaExceeded: '配额超限', + unschedulable: '不可调度', + rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复', + rateLimitedAutoResume: '{time} 自动恢复', + modelRateLimitedUntil: '{model} 限流至 {time}', + modelCreditOveragesUntil: '{model} 正在使用 AI Credits,至 {time}', + creditsExhausted: '积分已用尽', + creditsExhaustedUntil: 'AI Credits 已用尽,预计 {time} 恢复', + overloadedUntil: '负载过重,重置时间:{time}', + viewTempUnschedDetails: '查看临时不可调度详情' + }, + tempUnschedulable: { + title: '临时不可调度', + statusTitle: '临时不可调度状态', + hint: '当错误码与关键词同时匹配时,账号会在指定时间内被临时禁用。', + notice: '规则按顺序匹配,需同时满足错误码与关键词。', + addRule: '添加规则', + ruleOrder: '规则序号', + ruleIndex: '规则 #{index}', + errorCode: '错误码', + errorCodePlaceholder: '例如 429', + durationMinutes: '持续时间(分钟)', + durationPlaceholder: '例如 30', + keywords: '关键词', + keywordsPlaceholder: '例如 overloaded, too many requests', + keywordsHint: '多个关键词用逗号分隔,匹配时必须命中其中之一。', + description: '描述', + descriptionPlaceholder: '可选,便于记忆规则用途', + rulesInvalid: '请至少填写一条包含错误码、关键词和时长的规则。', + viewDetails: '查看临时不可调度详情', + accountName: '账号', + triggeredAt: '触发时间', + until: '解除时间', + remaining: '剩余时间', + matchedKeyword: '匹配关键词', + errorMessage: '错误详情', + reset: '恢复状态', + resetSuccess: '账号状态已恢复', + resetFailed: '恢复账号状态失败', + failedToLoad: '加载临时不可调度状态失败', + notActive: '当前账号未处于临时不可调度状态。', + expired: '已到期', + remainingMinutes: '约 {minutes} 分钟', + remainingHours: '约 {hours} 小时', + remainingHoursMinutes: '约 {hours} 小时 {minutes} 分钟', + presets: { + overloadLabel: '529 过载', + overloadDesc: '服务过载 - 暂停 60 分钟', + rateLimitLabel: '429 限流', + rateLimitDesc: '触发限流 - 暂停 10 分钟', + unavailableLabel: '503 维护', + unavailableDesc: '服务不可用 - 暂停 30 分钟' + } + }, + usageWindow: { + statsTitle: '5小时窗口用量统计', + statsTitleDaily: '每日用量统计', + geminiProDaily: 'Pro', + geminiFlashDaily: 'Flash', + gemini3Pro: 'G3P', + gemini3Flash: 'G3F', + gemini3Image: 'G31FI', + claude: 'Claude', + grokRequests: '请求', + grokTokens: 'Token', + grokUnknown: 'Grok 配额需等待首次上游响应返回 xAI rate-limit 头后显示。', + grokRetryAfter: '{time} 后重试', + grokProbe: '探测', + grokProbeTooltip: '发送最小 xAI Responses 探测请求并读取配额响应头', + grokResetUnsupported: '不支持重置', + grokResetUnsupportedTooltip: 'xAI 未向 Grok OAuth 账号开放重置额度接口', + grokNoHeaders: '未观察到配额响应头', + grokLastStatus: '状态 {status}', + grokLastProbe: '探测 {time}', + grokLastHeadersSeen: '响应头 {time}', + passiveSampled: '被动采样', + activeQuery: '查询' + }, + openaiQuotaReset: { + count: '次数', + reset: '重置', + countTooltipLoad: '点击查询剩余重置次数', + countTooltipRefresh: '点击刷新剩余重置次数', + resetTooltipReady: '消耗 1 次重置次数以立即恢复当前窗口', + resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数', + resetTooltipNoCredits: '没有可用的重置次数', + resetTooltipShadow: 'Spark 影子账号不能重置次数;请在母账号上重置', + expiresAt: '到期 {time}', + expiresAtFull: '重置次数到期时间: {time}', + expandExpirations: '展开其余 {count} 张重置次数到期时间', + collapseExpirations: '收起重置次数到期时间', + expirationDetails: '重置次数到期明细', + noCreditsAvailable: '没有可用的重置次数', + resetSuccess: '已重置 {windows} 个窗口', + confirmTitle: '确认重置周限', + confirmMessage: '将消耗 1 次重置次数立即恢复当前窗口,剩余 {count} 次。此操作不可撤销,确定继续吗?' + }, + tier: { + free: 'Free', + pro: 'Pro', + ultra: 'Ultra', + aiPremium: 'AI Premium', + standard: '标准版', + basic: '基础版', + personal: '个人版', + unlimited: '无限制' + }, + ineligibleWarning: + '该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。', + forbidden: '已封禁', + forbiddenValidation: '需要验证', + forbiddenViolation: '违规封禁', + openVerification: '打开验证链接', + copyLink: '复制链接', + linkCopied: '链接已复制', + needsReauth: '需要重新授权', + rateLimited: '限流中', + usageError: '获取失败', + form: { + nameLabel: '账号名称', + namePlaceholder: '请输入账号名称', + platformLabel: '平台', + selectPlatform: '选择平台', + typeLabel: '类型', + selectType: '选择类型', + credentialsLabel: '凭证', + credentialsPlaceholder: '请输入 Cookie 或 API Key', + priorityLabel: '优先级', + priorityHint: '数值越小优先级越高', + weightLabel: '权重', + weightHint: '用于负载均衡的权重值', + statusLabel: '状态' + }, + filters: { + platform: '平台', + allPlatforms: '全部平台', + type: '类型', + allTypes: '全部类型', + status: '状态', + allStatuses: '全部状态' + }, + saving: '保存中...', + refreshing: '刷新中...', + testing: '测试中...', + noAccounts: '暂无账号', + noAccountsDescription: '添加 AI 平台账号以开始使用 API 网关。', + accountCreatedSuccess: '账号添加成功', + accountUpdatedSuccess: '账号更新成功', + accountDeletedSuccess: '账号删除成功', + bulkSchedulableEnabled: '成功启用 {count} 个账号的调度', + bulkSchedulableDisabled: '成功停止 {count} 个账号的调度', + bulkSchedulablePartial: '部分调度更新成功:成功 {success} 个,失败 {failed} 个', + bulkSchedulableResultUnknown: '批量调度结果不完整,请稍后重试或刷新列表', + bulkActions: { + selected: '已选择 {count} 个账号', + selectCurrentPage: '本页全选', + clear: '清除选择', + edit: '批量编辑账号', + delete: '批量删除', + enableScheduling: '批量启用调度', + disableScheduling: '批量停止调度', + resetStatus: '批量重置状态', + refreshToken: '批量刷新令牌', + resetStatusSuccess: '已成功重置 {count} 个账号状态', + refreshTokenSuccess: '已成功刷新 {count} 个账号令牌', + partialSuccess: '操作部分完成:{success} 成功,{failed} 失败' + }, + bulkEdit: { + title: '批量编辑账号', + selectionInfo: '已选择 {count} 个账号。只更新您勾选或填写的字段,未勾选的字段保持不变。', + baseUrlPlaceholder: 'https://api.anthropic.com 或 https://api.openai.com', + baseUrlNotice: '仅适用于 API Key 账号,留空则不修改', + submit: '批量更新', + updating: '更新中...', + success: '成功更新 {count} 个账号', + partialSuccess: '部分更新成功:成功 {success} 个,失败 {failed} 个', + failed: '批量更新失败', + noSelection: '请选择要编辑的账号', + noFieldsSelected: '请至少选择一个要更新的字段', + mixedPlatformWarning: '所选账号跨越多个平台({platforms})。显示的模型映射预设为合并结果——请确保映射对每个平台都适用。' + }, + bulkDeleteTitle: '批量删除账号', + bulkDeleteConfirm: '确定要删除选中的 {count} 个账号吗?此操作无法撤销。', + bulkDeleteSuccess: '成功删除 {count} 个账号', + bulkDeletePartial: '部分删除成功:成功 {success} 个,失败 {failed} 个', + bulkDeleteFailed: '批量删除失败', + recoverState: '恢复状态', + recoverStateHint: '用于恢复错误、限流和临时不可调度等可恢复状态。', + recoverStateSuccess: '账号状态已恢复', + recoverStateFailed: '恢复账号状态失败', + fallbackActive: '已回退', + fallbackActiveTip: '原代理 {origin} 已到期,当前使用备用代理', + revertProxy: '切回原代理', + revertProxySuccess: '已成功切回原代理', + revertProxyFailed: '切回原代理失败', + createSparkShadow: '创建 Spark 影子账号', + createSparkShadowConfirm: '为「{name}」创建链接型 Spark 影子账号?影子共享母账号凭据、仅服务 spark 模型。', + createSparkShadowSuccess: 'Spark 影子账号已创建', + createSparkShadowFailed: '创建 Spark 影子账号失败', + resetStatus: '重置状态', + statusReset: '账号状态已重置', + failedToResetStatus: '重置账号状态失败', + cookieRefreshedSuccess: 'Cookie 刷新成功', + testSuccess: '账号测试通过', + testFailed: '账号测试失败', + failedToLoad: '加载账号列表失败', + failedToSave: '保存账号失败', + failedToDelete: '删除账号失败', + failedToRefresh: '刷新 Cookie 失败', + // Create/Edit Account Modal + platform: '平台', + accountName: '账号名称', + enterAccountName: '请输入账号名称', + accountType: '账号类型', + claudeCode: 'Claude Code', + claudeConsole: 'Claude Console', + bedrockLabel: 'AWS Bedrock', + bedrockDesc: 'SigV4 / API Key', + vertexLabel: 'Vertex', + vertexDesc: 'Service Account', + vertexAnthropicHint: '使用 Google Cloud Service Account JSON 通过 Vertex AI 调用 Anthropic Claude。建议配置模型映射,将客户端 Claude 模型名映射到 Vertex 模型 ID。', + vertexGeminiHint: '使用 Google Cloud Service Account JSON 访问 Vertex AI Gemini。建议将 Vertex 账号放入独立分组,避免和 AI Studio/Gemini OAuth 同模型混调。', + vertexSaJsonLabel: 'Service Account JSON', + vertexSaJsonLoaded: '已读取 Service Account JSON', + vertexSaJsonDrop: '拖入 Service Account JSON', + vertexSaJsonKeyHidden: '密钥内容不会在表单中显示。', + vertexSaJsonDropHint: '把 .json 文件拖到这里,或点击按钮选择文件。', + vertexSaJsonSelectBtn: '选择 JSON', + vertexSaJsonUploadHint: '上传或拖入 JSON 后会自动读取 project_id,密钥内容仅用于创建账号提交。', + vertexSaJsonEditHint: 'Service Account JSON 不在编辑页显示;需要更换 JSON 时请删除账号后重新创建。', + vertexProjectIdPlaceholder: '从 JSON 自动读取', + vertexLocationHint: '不同 Vertex 模型可用 location 可能不同,这里选择账号默认 endpoint location。', + vertexLocationRequired: '请填写 Vertex location', + vertexSaJsonMissingFields: 'Service Account JSON 缺少 project_id、client_email 或 private_key', + vertexSaJsonMissingProjectId: 'Service Account JSON 缺少 project_id', + vertexSaJsonMissingClientEmail: 'Service Account JSON 缺少 client_email', + vertexSaJsonInvalid: 'Service Account JSON 格式无效', + vertexSaJsonRequired: '请上传 Service Account JSON', + oauthSetupToken: 'OAuth / Setup Token', + addMethod: '添加方式', + setupTokenLongLived: 'Setup Token(长期有效)', + baseUrl: 'Base URL', + baseUrlHint: '留空使用官方 Anthropic API', + apiKeyRequired: 'API Key *', + apiKeyPlaceholder: 'sk-ant-api03-...', + apiKeyHint: '您的 Claude Console API Key', + // OpenAI specific hints + openai: { + baseUrlHint: '留空使用官方 OpenAI API', + apiKeyHint: '您的 OpenAI API Key', + oauthPassthrough: '自动透传(仅替换认证)', + oauthPassthroughDesc: + '开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。', + responsesWebsocketsV2: 'Responses WebSocket v2', + responsesWebsocketsV2Desc: + '默认关闭。开启后可启用 responses_websockets_v2 协议能力(受网关全局开关与账号类型开关约束)。', + wsMode: 'WS mode', + wsModeDesc: '仅对当前 OpenAI 账号类型生效。', + wsModeOff: '关闭(off)', + wsModeCtxPool: '上下文池(ctx_pool)', + wsModePassthrough: '透传(passthrough)', + wsModeHttpBridge: 'HTTP 桥接(http_bridge)', + wsModeShared: '共享(shared)', + wsModeDedicated: '独享(dedicated)', + wsModeConcurrencyHint: '启用 WS mode 后,该账号并发数将作为该账号 WS 连接池上限。', + wsModePassthroughHint: 'passthrough 模式不使用 WS 连接池。', + oauthResponsesWebsocketsV2: 'OAuth WebSocket Mode', + oauthResponsesWebsocketsV2Desc: + '仅对 OpenAI OAuth 生效。开启后该账号才允许使用 OpenAI WebSocket Mode 协议。', + apiKeyResponsesWebsocketsV2: 'API Key WebSocket Mode', + apiKeyResponsesWebsocketsV2Desc: + '仅对 OpenAI API Key 生效。开启后该账号才允许使用 OpenAI WebSocket Mode 协议。', + responsesWebsocketsV2PassthroughHint: '当前已开启自动透传:仅影响 HTTP 透传链路,不影响 WS mode。', + responsesMode: 'Responses API 支持', + responsesModeDesc: + '仅对 OpenAI API Key 的文本转发链路生效。自动跟随探测结果,强制模式会覆盖自动探测。', + responsesModeAuto: '自动', + responsesModeForceResponses: '强制 Responses', + responsesModeForceChatCompletions: '强制 Chat Completions', + responsesModeTextDisabledHint: '未启用 Responses / Chat Completions 端点时,此设置不适用。', + endpointCapabilities: '端点能力', + endpointCapabilitiesDesc: + '用于调度筛选。文本端点会跟随上方 Responses API 支持显示为 Responses、Chat Completions 或自动模式;Embeddings 独立控制 /v1/embeddings。', + capabilityResponses: 'Responses', + capabilityTextAuto: 'Responses / Chat Completions(自动)', + capabilityResponsesAuto: 'Responses(自动探测)', + capabilityChatCompletions: 'Chat Completions', + capabilityChatCompletionsAuto: 'Chat Completions(自动探测)', + capabilityEmbeddings: 'Embeddings', + responsesStatusAutoSupported: '自动探测:Responses', + responsesStatusAutoUnsupported: '自动探测:Chat Completions', + responsesStatusAutoUnknown: '自动探测:未探测', + responsesStatusForcedResponses: '已强制 Responses', + responsesStatusForcedChatCompletions: '已强制 Chat Completions', + codexCLIOnly: '仅允许 Codex 官方客户端', + codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。', + codexCLIOnlyAppServer: '允许 Codex app-server 客户端', + codexCLIOnlyAppServerDesc: '仅在上方开关开启时生效。开启后本账号额外放行内嵌 Codex 引擎、经 app-server 协议接入的第三方客户端(如 Claude Code 的 codex 插件),仍需通过全局引擎指纹门;与全局 app-server 开关取 OR(任一开即放行)。', + codexImageTool: 'Codex 图片工具', + codexImageToolDesc: + '统一控制 Codex /responses 文本请求的 image_generation 图片工具:是否自动注入,以及客户端自带该工具时是否放行。账号级策略优先于渠道和全局配置,不影响独立图片生成接口。', + codexImageToolInherit: '跟随渠道', + codexImageToolInheritDesc: '不写入账号覆盖,是否注入由渠道或全局策略决定;客户端自带的图片工具照常放行。', + codexImageToolEnabled: '强制注入', + codexImageToolEnabledDesc: '始终为 Codex /responses 请求注入图片工具。', + codexImageToolDisabled: '关闭注入', + codexImageToolDisabledDesc: '不自动注入;客户端自带的图片工具仍会放行。', + codexImageToolBlock: '完全阻断', + codexImageToolBlockDesc: '不注入,并移除客户端自带的图片工具及指向它的 tool_choice。', + codexImageToolBadgeInherit: '渠道策略', + codexImageToolBadgeEnabled: '强制注入', + codexImageToolBadgeDisabled: '关闭注入', + codexImageToolBadgeBlock: '完全阻断', + compactMode: 'Compact 模式', + compactModeDesc: + '控制本账号在 /responses/compact 调度中的参与方式。Auto 跟随探测结果,Force On 强制允许,Force Off 强制排除。', + compactModeAuto: '自动', + compactModeForceOn: '强制开启', + compactModeForceOff: '强制关闭', + compactModelMapping: 'Compact 专属模型映射', + compactModelMappingDesc: + '仅在 /responses/compact 请求中生效。当上游 compact 端点需要特殊 compact 模型时使用。', + compactSupported: '支持 Compact', + compactUnsupported: '不支持 Compact', + compactAuto: 'Compact Auto', + compactUnknown: 'Compact Auto', + compactLastChecked: '最近探测', + testMode: '测试模式', + testModeDefault: '常规请求', + testModeCompact: 'Compact 探测', + modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。', + }, + grok: { + baseUrlHint: 'Grok OAuth 账号会转发到官方 xAI API Base URL。', + apiKeyHint: 'Grok 订阅支持使用 OAuth refresh token;API Key 账号不在本次范围内。' + }, + anthropic: { + apiKeyPassthrough: '自动透传(仅替换认证)', + apiKeyPassthroughDesc: + '仅对 Anthropic API Key 生效。开启后,messages/count_tokens 请求将透传上游并仅替换认证,保留计费/并发/审计及必要安全过滤;关闭即可回滚到现有兼容链路。', + apiKeyAuthScheme: '上游认证方式', + apiKeyAuthSchemeDesc: '选择转发到 Anthropic-compatible 上游时使用的 API Key 认证头。Ollama Cloud 使用 Authorization: Bearer。', + apiKeyAuthSchemeXApiKey: 'x-api-key', + apiKeyAuthSchemeBearer: 'Authorization: Bearer', + webSearchEmulation: 'Web Search 模拟', + webSearchEmulationDesc: + '为该 API Key 账号启用 web search 模拟。客户端发送纯 web_search 请求时,由网关调用第三方搜索 API 并构造响应返回。默认跟随渠道配置。', + webSearchDefault: '默认', + webSearchEnabled: '开启', + webSearchDisabled: '关闭', + }, + modelRestriction: '模型限制(可选)', + modelWhitelist: '模型白名单', + modelMapping: '模型映射', + selectAllowedModels: '选择允许的模型。留空则支持所有模型。', + mapRequestModels: '将请求模型映射到实际模型。左边是请求的模型,右边是发送到 API 的实际模型。', + selectedModels: '已选择 {count} 个模型', + supportsAllModels: '(支持所有模型)', + requestModel: '请求模型', + actualModel: '实际模型', + addMapping: '添加映射', + mappingExists: '模型 {model} 的映射已存在', + wildcardOnlyAtEnd: '通配符 * 只能放在末尾', + targetNoWildcard: '目标模型不能包含通配符 *', + searchModels: '搜索模型...', + noMatchingModels: '没有匹配的模型', + fillRelatedModels: '同步最新支持模型', + syncUpstreamModels: '同步上游支持的模型', + syncUpstreamModelsLoading: '同步上游中...', + syncUpstreamModelsSuccess: '已从上游同步 {count} 个新模型(上游共 {total} 个)', + syncUpstreamModelsNoChanges: '上游 {count} 个模型均已在白名单中', + syncUpstreamModelsEmpty: '上游没有返回可同步的模型', + syncUpstreamModelsFailed: '同步上游模型失败', + syncUpstreamModelsError: '同步上游模型失败:{message}', + clearAllModels: '清除所有模型', + customModelName: '自定义模型名称', + enterCustomModelName: '输入自定义模型名称', + addModel: '填入', + modelExists: '该模型已存在', + modelCount: '{count} 个模型', + poolMode: '池模式', + poolModeHint: '上游为账号池时启用,错误不标记本地账号状态', + poolModeInfo: + '启用后,上游 429/403/401 错误将自动重试而不标记账号限流或错误,适用于上游指向另一个 sub2api 实例的场景。', + poolModeRetryCount: '同账号重试次数', + poolModeRetryCountHint: '仅在池模式下生效。0 表示不原地重试;默认 {default},最大 {max}。', + poolModeRetryStatusCodes: '同账号重试状态码', + poolModeRetryStatusCodesHint: '仅在池模式下生效。以英文逗号分隔的 HTTP 状态码(100-599),命中时触发同账号重试。留空使用默认值({default})。', + customErrorCodes: '自定义错误码', + customErrorCodesHint: '仅对选中的错误码停止调度', + customErrorCodesWarning: '仅选中的错误码会停止调度,其他错误将返回 500。', + customErrorCodes429Warning: + '429 已有内置的限流处理机制。添加到自定义错误码后,将直接停止调度而非临时限流。确定要添加吗?', + customErrorCodes529Warning: + '529 已有内置的过载处理机制。添加到自定义错误码后,将直接停止调度而非临时标记过载。确定要添加吗?', + selectedErrorCodes: '已选择', + noneSelectedUsesDefault: '未选择(使用默认策略)', + enterErrorCode: '输入错误码 (100-599)', + invalidErrorCode: '请输入有效的 HTTP 错误码 (100-599)', + errorCodeExists: '该错误码已被选中', + interceptWarmupRequests: '拦截预热请求', + interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token', + headerOverride: { + title: '请求头覆写', + hint: '转发时用配置值覆盖同名请求头(不区分大小写)', + info: '仅对本账号的出站请求生效:配置的请求头会在转发前覆盖客户端/网关生成的同名头。认证头(authorization、x-api-key)与连接控制头不允许覆写。', + namePlaceholder: '请求头名称(如 user-agent)', + valuePlaceholder: '覆写值(留空表示不覆写)', + addRow: '添加请求头', + fillTemplate: '填入模板', + emptyValueHint: '值留空的行不会参与覆盖,仅作为待填写的占位。', + bulkDisableHint: '保存后将关闭所选账号的请求头覆写并清空已有配置。', + bulkReplaceHint: '保存后将用下方配置整体替换所选账号已有的请求头覆写配置。', + bulkEmptyRows: '请至少添加一行请求头再保存;如需清空已有配置,请关闭上方开关。', + invalidName: '请求头名称格式不正确(仅允许字母、数字和 !#$%&\'*+-.^_`|~ 字符)', + blockedName: '该请求头不允许覆写(认证头与连接控制头由系统管理)', + duplicateName: '存在重复的请求头名称(匹配不区分大小写)', + invalidValue: '请求头值不合法(不允许控制字符,长度不超过 8192)', + tooManyEntries: '请求头覆写条目过多(最多 64 条)' + }, + autoPauseOnExpired: '过期自动暂停调度', + autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度', + autoPause5hThreshold: '5h 用量阈值(%)', + autoPause7dThreshold: '7d 用量阈值(%)', + autoPauseThresholdHint: '留空或填 0 表示使用全局默认阈值(在运维设置中配置);填具体值则覆盖全局默认。达到阈值后仅在调度时跳过账号,不修改 schedulable。', + autoPause5hDisabled: '禁用 5h 自动暂停', + autoPause7dDisabled: '禁用 7d 自动暂停', + autoPauseDisabledHint: '开启后该账号永不进入自动暂停(即使全局默认阈值已配置)。', + // Quota control (Anthropic OAuth/SetupToken only) + quotaControl: { + title: '配额控制', + hint: '配置费用窗口、会话限制、客户端亲和等调度控制。', + windowCost: { + label: '5h窗口费用控制', + hint: '限制账号在5小时窗口内的费用使用', + limit: '费用阈值', + limitPlaceholder: '50', + limitHint: '达到阈值后不参与新请求调度', + stickyReserve: '粘性预留额度', + stickyReservePlaceholder: '10', + stickyReserveHint: '为粘性会话预留的额外额度' + }, + sessionLimit: { + label: '会话数量控制', + hint: '限制同时活跃的会话数量', + maxSessions: '最大会话数', + maxSessionsPlaceholder: '3', + maxSessionsHint: '同时活跃的最大会话数量', + idleTimeout: '空闲超时', + idleTimeoutPlaceholder: '5', + idleTimeoutHint: '会话空闲超时后自动释放' + }, + rpmLimit: { + label: 'RPM 限制', + hint: '限制每分钟请求数量,保护上游账号', + baseRpm: '基础 RPM', + baseRpmPlaceholder: '15', + baseRpmHint: '每分钟最大请求数,0 或留空表示不限制', + strategy: 'RPM 策略', + strategyTiered: '三区模型', + strategyStickyExempt: '粘性豁免', + strategyTieredHint: '绿区→黄区→仅粘性→阻塞,逐步限流', + strategyStickyExemptHint: '超限后仅允许粘性会话', + strategyHint: '三区模型: 超限后逐步限制; 粘性豁免: 已有会话不受限', + stickyBuffer: '粘性缓冲区', + stickyBufferPlaceholder: '默认: base RPM 的 20%', + stickyBufferHint: '超过 base RPM 后,粘性会话额外允许的请求数。为空则使用默认值(base RPM 的 20%,最小为 1)', + userMsgQueue: '用户消息限速', + userMsgQueueHint: '对用户消息施加发送限制,避免触发上游 RPM 限制', + umqModeOff: '关闭', + umqModeThrottle: '软性限速', + umqModeSerialize: '串行队列', + }, + tlsFingerprint: { + label: 'TLS 指纹模拟', + hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹', + defaultProfile: '内置默认', + randomProfile: '随机' + }, + sessionIdMasking: { + label: '会话 ID 伪装', + hint: '启用后将在 15 分钟内固定 metadata.user_id 中的 session ID,使上游认为请求来自同一会话' + }, + cacheTTLOverride: { + label: '缓存 TTL 强制替换', + hint: '将所有缓存创建 token 强制按指定的 TTL 类型(5分钟或1小时)计费', + target: '目标 TTL', + targetHint: '选择计费使用的 TTL 类型' + }, + customBaseUrl: { + label: '自定义转发地址', + hint: '启用后将请求转发到自定义中继服务,代理地址将作为 URL 参数传递给中继服务', + urlHint: '中继服务地址(如 https://relay.example.com)', + }, + clientAffinity: { + label: '客户端亲和调度', + hint: '启用后,新会话会优先调度到该客户端之前使用过的账号,避免频繁切换账号' + } + }, + affinityNoClients: '无亲和客户端', + affinityClients: '{count} 个亲和客户端:', + affinitySection: '客户端亲和', + affinitySectionHint: '控制客户端在账号间的分布。通过配置区域阈值来平衡负载。', + affinityToggle: '启用客户端亲和', + affinityToggleHint: '新会话优先调度到该客户端之前使用过的账号', + affinityBase: '基础限额(绿区)', + affinityBasePlaceholder: '留空表示不限制', + affinityBaseHint: '绿区最大客户端数量(完整优先级调度)', + affinityBaseOffHint: '未开启绿区限制,所有客户端均享受完整优先级调度', + affinityBuffer: '缓冲区(黄区)', + affinityBufferPlaceholder: '例如 3', + affinityBufferHint: '黄区允许的额外客户端数量(降级优先级调度)', + affinityBufferInfinite: '不限制', + expired: '已过期', + proxy: '代理', + noProxy: '无代理', + concurrency: '并发数', + loadFactor: '负载因子', + loadFactorHint: '提高负载因子可以提高对账号的调度频率', + priority: '优先级', + priorityHint: '优先级越小的账号优先使用', + billingRateMultiplier: '账号计费倍率', + billingRateMultiplierHint: '0 表示不计费,仅影响账号计费', + expiresAt: '过期时间', + expiresAtHint: '留空表示不过期', + higherPriorityFirst: '数值越小优先级越高', + mixedScheduling: '在 /v1/messages 中使用', + mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度', + mixedSchedulingTooltip: + '!!注意!! Antigravity Claude 和 Anthropic Claude 无法在同个上下文中使用,如果你同时有 Anthropic 账号和 Antigravity 账号,开启此选项会导致经常 400 报错。开启后,请用分组功能做好 Antigravity 账号和 Anthropic 账号的隔离。一定要弄明白再开启!!', + aiCreditsBalance: 'AI Credits', + allowOverages: '允许超量请求 (AI Credits)', + allowOveragesTooltip: + '仅在免费配额被明确判定为耗尽后才会使用 AI Credits。普通并发 429 限流不会切换到超量请求。', + creating: '创建中...', + updating: '更新中...', + accountCreated: '账号创建成功', + accountUpdated: '账号更新成功', + failedToCreate: '创建账号失败', + failedToUpdate: '更新账号失败', + pleaseSelectStatus: '请选择有效的账号状态', + mixedChannelWarningTitle: '混合渠道警告', + mixedChannelWarning: '警告:分组 "{groupName}" 中同时包含 {currentPlatform} 和 {otherPlatform} 账号。混合使用不同渠道可能导致 thinking block 签名验证问题,会自动回退到非 thinking 模式。确定要继续吗?', + pleaseEnterAccountName: '请输入账号名称', + pleaseEnterApiKey: '请输入 API Key', + bedrockAccessKeyId: 'AWS Access Key ID', + bedrockSecretAccessKey: 'AWS Secret Access Key', + bedrockSessionToken: 'AWS Session Token', + bedrockRegion: 'AWS Region', + bedrockRegionHint: '例如 us-east-1, us-west-2, eu-west-1', + bedrockForceGlobal: '强制使用 Global 跨区域推理', + bedrockForceGlobalHint: '启用后模型 ID 使用 global. 前缀(如 global.anthropic.claude-...),请求可路由到全球任意支持的区域,获得更高可用性', + bedrockAccessKeyIdRequired: '请输入 AWS Access Key ID', + bedrockSecretAccessKeyRequired: '请输入 AWS Secret Access Key', + bedrockRegionRequired: '请选择 AWS Region', + bedrockSessionTokenHint: '可选,用于临时凭证', + bedrockSecretKeyLeaveEmpty: '留空以保持当前密钥', + bedrockAuthMode: '认证方式', + bedrockAuthModeSigv4: 'SigV4 签名', + bedrockAuthModeApikey: 'Bedrock API Key', + bedrockApiKeyLabel: 'Bedrock API Key', + bedrockApiKeyDesc: 'Bearer Token 认证', + bedrockApiKeyInput: 'API Key', + bedrockApiKeyRequired: '请输入 Bedrock API Key', + bedrockApiKeyLeaveEmpty: '留空以保持当前密钥', + apiKeyIsRequired: 'API Key 是必需的', + leaveEmptyToKeep: '留空以保持当前密钥', + // Upstream type + upstream: { + baseUrl: '上游 Base URL', + baseUrlHint: '上游 Antigravity 服务的地址,例如:https://cloudcode-pa.googleapis.com', + apiKey: '上游 API Key', + apiKeyHint: '上游服务的 API Key', + pleaseEnterBaseUrl: '请输入上游 Base URL', + pleaseEnterApiKey: '请输入上游 API Key' + }, + // OAuth flow + oauth: { + title: 'Claude 账号授权', + authMethod: '授权方式', + manualAuth: '手动授权', + cookieAutoAuth: 'Cookie 自动授权', + cookieAutoAuthDesc: '使用 claude.ai sessionKey 自动完成 OAuth 授权,无需手动打开浏览器。', + sessionKey: 'sessionKey', + keysCount: '{count} 个密钥', + batchCreateAccounts: '将批量创建 {count} 个账号', + sessionKeyPlaceholder: + '每行一个 sessionKey,例如:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...', + sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...', + howToGetSessionKey: '如何获取 sessionKey', + step1: '在浏览器中登录 claude.ai', + step2: '按 F12 打开开发者工具', + step3: '切换到 Application 标签', + step4: '找到 Cookies → https://claude.ai', + step5: '找到 sessionKey 所在行', + step6: '复制 Value 列的值', + sessionKeyFormat: 'sessionKey 通常以 sk-ant-sid01- 开头', + startAutoAuth: '开始自动授权', + authorizing: '授权中...', + followSteps: '按照以下步骤授权您的 Claude 账号:', + step1GenerateUrl: '点击下方按钮生成授权 URL', + generateAuthUrl: '生成授权 URL', + generating: '生成中...', + regenerate: '重新生成', + step2OpenUrl: '在浏览器中打开 URL 并完成授权', + openUrlDesc: '在新标签页中打开授权 URL,登录您的 Claude 账号并授权。', + proxyWarning: '注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。', + step3EnterCode: '输入授权码', + authCodeDesc: '授权完成后,页面会显示一个授权码。复制并粘贴到下方:', + authCode: '授权码', + authCodePlaceholder: '粘贴 Claude 页面的授权码...', + authCodeHint: '粘贴从 Claude 页面复制的授权码', + completeAuth: '完成授权', + verifying: '验证中...', + pleaseEnterSessionKey: '请输入至少一个有效的 sessionKey', + authFailed: '授权失败', + cookieAuthFailed: 'Cookie 授权失败', + keyAuthFailed: '密钥 {index}: {error}', + successCreated: '成功创建 {count} 个账号', + batchSuccess: '成功创建 {count} 个账号', + batchPartialSuccess: '部分成功:{success} 个成功,{failed} 个失败', + batchFailed: '批量创建失败', + // OpenAI specific + openai: { + title: 'OpenAI 账户授权', + followSteps: '请按照以下步骤完成 OpenAI 账户的授权:', + step1GenerateUrl: '点击下方按钮生成授权链接', + generateAuthUrl: '生成授权链接', + step2OpenUrl: '在浏览器中打开链接并完成授权', + openUrlDesc: '请在新标签页中打开授权链接,登录您的 OpenAI 账户并授权。', + importantNotice: + '重要提示:授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 http://localhost... 开头时,表示授权已完成。', + step3EnterCode: '输入授权链接或 Code', + authCodeDesc: + '授权完成后,当页面地址变为 http://localhost:xxx/auth/callback?code=... 时:', + authCode: '授权链接或 Code', + authCodePlaceholder: + '方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值', + authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别', + failedToGenerateUrl: '生成 OpenAI 授权链接失败', + failedToExchangeCode: 'OpenAI 授权码兑换失败', + failedToValidateRT: '验证 Refresh Token 失败', + errors: { + OPENAI_OAUTH_PROXY_REQUIRED: + '未设置代理,当前服务器无法直连 OpenAI,导致 OpenAI OAuth 请求失败。请先选择可访问 OpenAI 的代理后重试;如果授权码已失效,请重新生成授权链接。' + }, + // Refresh Token auth + refreshTokenAuth: '手动输入 RT', + refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。', + refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个', + codexSessionAuth: 'Codex JSON / AT 批量输入', + codexSessionDesc: '粘贴 Codex JSON 或 accessToken,按第一步配置创建账号。', + codexSessionInputLabel: 'Codex JSON 或 accessToken', + codexSessionPlaceholder: '支持多行,每行一个 token 或 JSON', + codexSessionHint: 'sessionToken 不会作为 refresh_token 保存;未包含 refresh_token 时会按 accessToken 过期时间设置账号过期,无法解析且第一步未设置过期时间时会拒绝导入。', + codexSessionImportAndCreate: '导入并创建账号', + codexSessionEmpty: '请输入 Codex JSON 或 accessToken', + codexSessionImportFailed: 'Codex 账号导入失败', + codexSessionImportSuccess: '导入完成:新增 {created},更新 {updated},跳过 {skipped}', + codexSessionImportPartial: '部分成功:新增 {created},更新 {updated},跳过 {skipped},失败 {failed}', + codexPatAuth: 'Codex Personal Access Token', + codexPatDesc: '输入 Codex at- Personal Access Token,系统会先调用 OpenAI whoami 校验后再创建账号。', + codexPatInputLabel: 'Codex PAT', + codexPatPlaceholder: 'at-...', + codexPatHint: '这是独立认证模式,不保存 refresh_token,也不会写入 OAuth access_token 过期时间。', + codexPatImportAndCreate: '校验并创建 Codex PAT 账号', + codexPatEmpty: '请输入 Codex Personal Access Token', + codexPatImportFailed: 'Codex PAT 账号创建失败', + sessionTokenAuth: '手动输入 ST', + sessionTokenDesc: '输入您已有的 Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。', + sessionTokenPlaceholder: '粘贴您的 Session Token...\n支持多个,每行一个', + sessionTokenRawLabel: '原始字符串', + sessionTokenRawPlaceholder: '粘贴 /api/auth/session 原始数据或 Session Token...', + sessionTokenRawHint: '支持粘贴完整 JSON,系统会自动解析 ST 和 AT。', + openSessionUrl: '打开获取链接', + copySessionUrl: '复制链接', + sessionUrlHint: '该链接通常可获取 AT。若返回中无 sessionToken,请从浏览器 Cookie 复制 __Secure-next-auth.session-token 作为 ST。', + parsedSessionTokensLabel: '解析出的 ST', + parsedSessionTokensEmpty: '未解析到 ST,请检查输入内容', + parsedAccessTokensLabel: '解析出的 AT', + validating: '验证中...', + validateAndCreate: '验证并创建账号', + pleaseEnterRefreshToken: '请输入 Refresh Token', + pleaseEnterSessionToken: '请输入 Session Token' + }, + grok: { + title: 'Grok 账号授权', + followSteps: '请按照以下步骤授权您的 xAI/Grok 账号:', + step1GenerateUrl: '生成 xAI 授权链接', + generateAuthUrl: '生成授权链接', + step2OpenUrl: '在浏览器中打开链接并完成授权', + openUrlDesc: '在新标签页中打开授权链接,登录 xAI 并授权 API 访问。', + importantNotice: '当浏览器跳转到本地 callback URL 后,请复制完整 URL 或 code 参数回填到这里。', + step3EnterCode: '输入授权链接或 Code', + authCodeDesc: '授权完成后,粘贴 callback URL、查询字符串或授权码:', + authCode: '授权链接或 Code', + authCodePlaceholder: '粘贴完整 callback URL、?code=... 查询字符串或 code 值', + authCodeHint: '支持完整 callback URL、查询字符串或裸 code。', + refreshTokenAuth: '手动输入 RT', + refreshTokenDesc: '输入已有的 xAI refresh token,支持批量输入(每行一个)。', + refreshTokenPlaceholder: '粘贴您的 xAI refresh token...\n支持多个,每行一个', + validating: '验证中...', + validateAndCreate: '验证并创建账号', + pleaseEnterRefreshToken: '请输入 Refresh Token', + failedToGenerateUrl: '生成 Grok 授权链接失败', + missingExchangeParams: '缺少授权码、state 或 OAuth 会话', + failedToExchangeCode: 'Grok 授权码兑换失败', + failedToValidateRT: '验证 Grok refresh token 失败', + oauthOnlyHint: '首版 Grok 支持仅包含 OAuth 订阅的 Responses API 文本/推理转发。' + }, + // Gemini specific + gemini: { + title: 'Gemini 账户授权', + followSteps: '请按照以下步骤完成 Gemini 账户的授权:', + step1GenerateUrl: '生成授权链接', + generateAuthUrl: '生成授权链接', + projectIdLabel: 'Project ID(可选)', + projectIdPlaceholder: '例如:my-gcp-project 或 cloud-ai-companion-xxxxx', + projectIdHint: + '留空则在兑换授权码后自动探测;若自动探测失败,可填写后重新生成授权链接再授权。', + howToGetProjectId: '如何获取', + step2OpenUrl: '在浏览器中打开链接并完成授权', + openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。', + step3EnterCode: '输入回调链接或 Code', + authCodeDesc: + '授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code,粘贴到下方即可。', + authCode: '回调链接或 Code', + authCodePlaceholder: '方式1(推荐):粘贴回调链接\n方式2:仅粘贴 code 参数的值', + authCodeHint: '系统会自动从链接中解析 code/state。', + redirectUri: 'Redirect URI', + redirectUriHint: '需要在 Google OAuth Client 中配置,且必须与此处完全一致。', + confirmRedirectUri: '我已在 Google OAuth Client 中配置了该 Redirect URI(必须完全一致)', + invalidRedirectUri: 'Redirect URI 必须是合法的 http(s) URL', + redirectUriNotConfirmed: '请确认 Redirect URI 已在 Google OAuth Client 中正确配置', + missingRedirectUri: '缺少 Redirect URI', + failedToGenerateUrl: '生成 Gemini 授权链接失败', + missingExchangeParams: '缺少 code / session_id / state', + failedToExchangeCode: 'Gemini 授权码兑换失败', + missingProjectId: + 'GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。', + modelPassthrough: 'Gemini 直接转发模型', + modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。', + stateWarningTitle: '提示', + stateWarningDesc: '建议粘贴完整回调链接(包含 code 和 state)。', + oauthTypeLabel: 'OAuth 类型', + needsProjectId: '内置授权(Code Assist)', + needsProjectIdDesc: '需要 GCP 项目与 Project ID', + noProjectIdNeeded: '自定义授权(AI Studio)', + noProjectIdNeededDesc: '需管理员配置 OAuth Client', + aiStudioNotConfiguredShort: '未配置', + aiStudioNotConfiguredTip: + 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback(Consent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever)', + aiStudioNotConfigured: + 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback' + }, + // Antigravity specific + antigravity: { + title: 'Antigravity 账户授权', + followSteps: '请按照以下步骤完成 Antigravity 账户的授权:', + step1GenerateUrl: '生成授权链接', + generateAuthUrl: '生成授权链接', + step2OpenUrl: '在浏览器中打开链接并完成授权', + openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。', + importantNotice: + '重要提示:授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 http://localhost... 开头时,表示授权已完成。', + step3EnterCode: '输入授权链接或 Code', + authCodeDesc: + '授权完成后,当页面地址变为 http://localhost:xxx/auth/callback?code=... 时:', + authCode: '授权链接或 Code', + authCodePlaceholder: + '方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值', + authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别', + failedToGenerateUrl: '生成 Antigravity 授权链接失败', + missingExchangeParams: '缺少 code / session_id / state', + failedToExchangeCode: 'Antigravity 授权码兑换失败', + // Refresh Token auth + refreshTokenAuth: '手动输入 RT', + refreshTokenDesc: '输入您已有的 Antigravity Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。', + refreshTokenPlaceholder: '粘贴您的 Antigravity Refresh Token...\n支持多个,每行一个', + validating: '验证中...', + validateAndCreate: '验证并创建账号', + pleaseEnterRefreshToken: '请输入 Refresh Token', + failedToValidateRT: '验证 Refresh Token 失败' + } + }, + // Gemini specific (platform-wide) + gemini: { + helpButton: '使用帮助', + helpDialog: { + title: 'Gemini 使用指南', + apiKeySection: 'API Key 相关链接' + }, + modelPassthrough: 'Gemini 直接转发模型', + modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。', + baseUrlHint: '留空使用官方 Gemini API', + apiKeyHint: '您的 Gemini API Key(以 AIza 开头)', + tier: { + label: '账号等级', + hint: '提示:系统会优先尝试自动识别账号等级;若自动识别不可用或失败,则使用你选择的等级作为回退(本地模拟配额)。', + aiStudioHint: + 'AI Studio 的配额是按模型分别限流(Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。', + googleOne: { + free: 'Google One Free', + pro: 'Google One Pro', + ultra: 'Google One Ultra' + }, + gcp: { + standard: 'GCP Standard', + enterprise: 'GCP Enterprise' + }, + aiStudio: { + free: 'Google AI Free', + paid: 'Google AI Pay-as-you-go' + } + }, + accountType: { + oauthTitle: 'OAuth 授权(Gemini)', + oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。', + apiKeyTitle: 'API 密钥(AI Studio)', + apiKeyDesc: '最快接入方式,使用 AIza API Key。', + apiKeyNote: '适合轻量测试。免费层限流严格,数据可能用于训练。', + apiKeyLink: '获取 API Key', + quotaLink: '配额说明' + }, + oauthType: { + builtInTitle: '内置授权(Gemini CLI / Code Assist)', + builtInDesc: '使用 Google 内置客户端 ID,无需管理员配置。', + builtInRequirement: '需要 GCP 项目并填写 Project ID。', + googleOneDesc: '个人账号,享受 Google One 订阅配额', + codeAssistDesc: '企业级,需要 GCP 项目', + codeAssistRequirement: '需要激活 GCP 项目并绑定信用卡', + showAdvanced: '显示高级选项(自建 OAuth Client)', + hideAdvanced: '隐藏高级选项(自建 OAuth Client)', + gcpProjectLink: '创建项目', + customTitle: '自定义授权(AI Studio OAuth)', + customDesc: '使用管理员预设的 OAuth 客户端,适合组织管理。', + customRequirement: '需管理员配置 Client ID 并加入测试用户白名单。', + badges: { + recommended: '推荐', + highConcurrency: '高并发', + individuals: '推荐个人用户', + noGcp: '无需 GCP', + enterprise: '企业用户', + noAdmin: '无需管理员配置', + orgManaged: '组织管理', + adminRequired: '需要管理员' + } + }, + setupGuide: { + title: 'Gemini 使用准备', + checklistTitle: '准备工作', + checklistItems: { + usIp: '使用美国 IP,并确保账号归属地为美国。', + age: '账号需满 18 岁。' + }, + activationTitle: '服务激活', + activationItems: { + geminiWeb: '激活 Gemini Web,避免 User not initialized。', + gcpProject: '激活 GCP 项目,获取 Code Assist 所需 Project ID。' + }, + links: { + countryCheck: '检查归属地', + countryChange: '修改归属地', + geminiWebActivation: '激活 Gemini Web', + gcpProject: '打开 GCP 控制台' + } + }, + quotaPolicy: { + title: 'Gemini 配额与限流政策(参考)', + note: '注意:Gemini 官方未提供用量查询接口。此处显示的“每日配额”是由系统根据账号等级模拟计算的估算值,仅供调度参考,请以 Google 官方实际报错为准。', + columns: { + channel: '授权通道', + account: '账号状态', + limits: '限流政策', + docs: '官方文档' + }, + docs: { + codeAssist: 'Code Assist 配额', + aiStudio: 'AI Studio 定价', + vertex: 'Vertex AI 配额' + }, + simulatedNote: '本地模拟配额,仅供参考', + rows: { + googleOne: { + channel: 'Google One OAuth(个人版 / Code Assist for Individuals)', + limitsFree: '共享池:1000 RPD / 60 RPM(不分模型)', + limitsPro: '共享池:1500 RPD / 120 RPM(不分模型)', + limitsUltra: '共享池:2000 RPD / 120 RPM(不分模型)' + }, + gcp: { + channel: 'GCP Code Assist OAuth(企业版)', + limitsStandard: '共享池:1500 RPD / 120 RPM(不分模型)', + limitsEnterprise: '共享池:2000 RPD / 120 RPM(不分模型)' + }, + cli: { + channel: 'Gemini CLI(官方 Google 登录 / Code Assist)', + free: '免费 Google 账号', + premium: 'Google One AI Premium', + limitsFree: 'RPD ~1000;RPM ~60(软限制)', + limitsPremium: 'RPD ~1500+;RPM ~60+(优先队列)' + }, + gcloud: { + channel: 'GCP Code Assist(gcloud 登录)', + account: '未购买 Code Assist 订阅', + limits: 'RPD ~1000;RPM ~60(预览期)' + }, + aiStudio: { + channel: 'AI Studio API Key / OAuth', + free: '未绑卡(免费层)', + paid: '已绑卡(按量付费)', + limitsFree: 'RPD 50;RPM 2(Pro)/ 15(Flash)', + limitsPaid: 'RPD 不限;RPM 1000(Pro)/ 2000(Flash)(按模型配额)' + }, + customOAuth: { + channel: 'Custom OAuth Client(GCP)', + free: '项目未绑卡', + paid: '项目已绑卡', + limitsFree: 'RPD 50;RPM 2(项目配额)', + limitsPaid: 'RPD 不限;RPM 1000+(项目配额)' + } + } + }, + rateLimit: { + ok: '未限流', + unlimited: '无限流', + limited: '限流 {time}', + now: '现在' + } + }, + // Re-Auth Modal + reAuthorizeAccount: '重新授权账号', + claudeCodeAccount: 'Claude Code 账号', + openaiAccount: 'OpenAI 账号', + geminiAccount: 'Gemini 账号', + antigravityAccount: 'Antigravity 账号', + grokAccount: 'Grok 账号', + inputMethod: '输入方式', + reAuthorizedSuccess: '账号重新授权成功', + // Test Modal + testAccountConnection: '测试账号连接', + account: '账号', + readyToTest: '准备测试。点击"开始测试"按钮开始...', + connectingToApi: '连接 API 中...', + testCompleted: '测试完成!', + connectedToApi: '已连接到 API', + usingModel: '使用模型:{model}', + sendingTestMessage: '发送测试消息:"hi"', + sendingImageRequest: '发送生图测试请求...', + response: '响应:', + startTest: '开始测试', + retry: '重试', + copyOutput: '复制输出', + outputCopied: '输出已复制', + startingTestForAccount: '开始测试账号:{name}', + testAccountTypeLabel: '账号类型:{type}', + selectTestModel: '选择测试模型', + testModel: '测试模型', + testPrompt: '提示词:"hi"', + imagePromptLabel: '生图提示词', + imagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。', + imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.', + imageTestHint: '选择图片模型后,这里会直接发起生图测试,并在下方展示返回图片。', + imageTestMode: '模式:生图测试', + imagePreview: '生成结果:', + imageReceived: '已收到第 {count} 张测试图片', + // Stats Modal + viewStats: '查看统计', + usageStatistics: '使用统计', + last30DaysUsage: '近30天使用统计(日均基于实际使用天数)', + stats: { + totalCost: '30天总费用', + accumulatedCost: '累计成本', + standardCost: '标准计费', + totalRequests: '30天总请求', + totalCalls: '累计调用次数', + avgDailyCost: '日均费用', + basedOnActualDays: '基于 {days} 天实际使用', + avgDailyRequests: '日均请求', + avgDailyUsage: '平均每日调用', + todayOverview: '今日概览', + cost: '费用', + requests: '请求', + tokens: 'Token', + highestCostDay: '最高费用日', + highestRequestDay: '最高请求日', + date: '日期', + accumulatedTokens: '累计 Token', + totalTokens: '30天总计', + dailyAvgTokens: '日均 Token', + performance: '性能', + avgResponseTime: '平均响应', + daysActive: '活跃天数', + recentActivity: '最近统计', + todayRequests: '今日请求', + todayTokens: '今日 Token', + todayCost: '今日费用', + usageTrend: '30天费用与请求趋势', + noData: '该账号暂无使用数据' + } + }, + + // Scheduled Tests +} diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts new file mode 100644 index 0000000000..036f3f77e0 --- /dev/null +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -0,0 +1,712 @@ +export default { + availableChannels: { + title: '可用渠道', + description: '按渠道聚合查看关联分组与支持模型(已展开通配符)', + searchPlaceholder: '搜索渠道或模型...', + columns: { + name: '渠道名', + status: '状态', + billingSource: '计费模型来源', + groups: '关联分组', + supportedModels: '支持模型' + }, + empty: '暂无数据', + noGroups: '未关联分组', + noModels: '未配置模型映射', + noPricing: '未配置定价', + statusActive: '启用', + statusDisabled: '停用', + billingSource: { + requested: '请求模型', + upstream: '上游模型', + channel_mapped: '映射后模型' + }, + pricing: { + billingMode: '计费模式', + billingModeToken: '按 Token', + billingModePerRequest: '按次', + billingModeImage: '按图片', + inputPrice: '输入', + outputPrice: '输出', + cacheWritePrice: '缓存写入', + cacheReadPrice: '缓存读取', + imageOutputPrice: '图片输出', + perRequestPrice: '每次请求', + intervals: '阶梯定价', + unitPerMillion: '/ 1M token', + unitPerRequest: '/ 次' + } + }, + + // Channel Management + channels: { + title: '渠道管理', + description: '管理渠道和自定义模型定价', + searchChannels: '搜索渠道...', + createChannel: '创建渠道', + editChannel: '编辑渠道', + deleteChannel: '删除渠道', + statusActive: '启用', + statusDisabled: '停用', + allStatus: '全部状态', + groupsUnit: '个分组', + pricingUnit: '条定价', + noChannelsYet: '暂无渠道', + createFirstChannel: '创建第一个渠道来管理模型定价', + loadError: '加载渠道列表失败', + createSuccess: '渠道创建成功', + updateSuccess: '渠道更新成功', + deleteSuccess: '渠道删除成功', + createError: '创建渠道失败', + updateError: '更新渠道失败', + deleteError: '删除渠道失败', + nameRequired: '请输入渠道名称', + duplicateModels: '模型「{0}」在多个定价条目中重复', + modelConflict: "模型模式 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。模型名称按大小写不敏感匹配,已有条目已覆盖其所有大小写变体,无需重复添加。", + mappingConflict: "模型映射源 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。源模式按大小写不敏感匹配,已有条目已覆盖其所有大小写变体。", + intervalValidation: { + negativeMin: '区间 #{index}:最小 token 数({value})不能为负数', + maxPositive: '区间 #{index}:最大 token 数({value})必须大于 0', + maxGreaterThanMin: '区间 #{index}:最大 token 数({max})必须大于最小 token 数({min})', + negativePrice: '区间 #{index}:{field}不能为负数', + unboundedLast: '区间 #{index}:无上限区间(最大 token 数为空)必须放在最后', + overlap: '区间 #{previousIndex} 和 #{currentIndex} 重叠:前一个上界({previousMax})大于当前下界({currentMin})', + price: { + inputPrice: '输入价格', + outputPrice: '输出价格', + cacheWritePrice: '缓存写入价格', + cacheReadPrice: '缓存读取价格', + perRequestPrice: '单次价格' + } + }, + deleteConfirm: '确定要删除渠道「{name}」吗?此操作不可撤销。', + columns: { + name: '名称', + description: '描述', + status: '状态', + groups: '分组', + pricing: '定价', + createdAt: '创建时间', + actions: '操作' + }, + billingMode: { + token: 'Token', + perRequest: '按次', + image: '图片(按次)' + }, + form: { + name: '名称', + namePlaceholder: '输入渠道名称', + description: '描述', + descriptionPlaceholder: '可选描述', + status: '状态', + groups: '关联分组', + noGroupsAvailable: '暂无可用分组', + inOtherChannel: '已属于「{name}」', + modelPricing: '模型定价', + models: '模型列表', + modelsPlaceholder: '输入完整模型名后按回车添加', + modelInputHint: '按回车添加,支持粘贴批量导入', + billingMode: '计费模式', + defaultPrices: '默认价格(未命中区间时使用)', + inputPrice: '输入', + outputPrice: '输出', + cacheWritePrice: '缓存写入', + cacheReadPrice: '缓存读取', + cacheWritePriceShort: '缓存写', + cacheReadPriceShort: '缓存读', + imageTokenPrice: '图片输出', + imageOutputPrice: '图片输出价格', + pricePlaceholder: '默认', + intervals: '上下文区间定价(可选)', + minTokens: '最小', + maxTokens: '最大', + inclusive: '(含)', + addInterval: '添加区间', + requestTiers: '按次计费层级', + imageTiers: '图片计费层级(按次)', + addTier: '添加层级', + noTiersYet: '暂无层级,点击添加配置按次计费价格', + noPricingRules: '暂无定价规则,点击"添加"创建', + perRequestPrice: '单次价格', + perRequestPriceRequired: '按次/图片计费模式必须设置默认价格或至少一个计费层级', + tierLabel: '层级', + resolution: '分辨率', + modelMapping: '模型映射', + modelMappingHint: '将请求中的模型名映射为实际模型名。在账号级别映射之前执行。', + noMappingRules: '暂无映射规则,点击"添加"创建', + mappingSource: '源模型', + mappingTarget: '目标模型', + billingModelSource: '计费基准', + billingModelSourceChannelMapped: '以渠道映射后的模型计费', + billingModelSourceRequested: '以请求模型计费', + billingModelSourceUpstream: '以最终模型计费', + billingModelSourceHint: '控制使用哪个模型名称进行定价查找', + selectedCount: '已选 {count} 个', + searchGroups: '搜索分组...', + noGroupsMatch: '没有匹配的分组', + restrictModels: '限制模型', + restrictModelsHint: '开启后,仅允许模型定价列表中的模型。不在列表中的模型请求将被拒绝。', + defaultPerRequestPrice: '默认单次价格(未命中层级时使用)', + defaultImagePrice: '默认图片价格(未命中层级时使用)', + platformConfig: '平台配置', + webSearchEmulation: 'Web Search 模拟', + webSearchEmulationHint: '⚠️ 开启后该渠道下所有 Anthropic 分组的账号将自动拦截 web_search 请求,请谨慎操作', + webSearchEmulationGlobalDisabled: '请先在系统设置 → 网关 → Web Search 模拟中启用全局开关', + codexImageGenerationBridge: 'Codex 图片生成桥接', + codexImageGenerationBridgeHint: '开启后,OpenAI 分组的 Codex /responses 文本请求可能会被自动注入 image_generation 工具。仅在路由账号支持图片生成时开启。', + bedrockCCCompat: 'Bedrock CC 兼容', + bedrockCCCompatHint: '⚠️ 开启后,该渠道下 Bedrock 账号的请求将进行 Claude Code 兼容处理(thinking 类型转换、tool_use ID 清理)', + basicSettings: '基础设置', + addPlatform: '添加平台', + noPlatforms: '点击"添加平台"开始配置渠道', + mappingCount: '条映射', + pricingEntry: '定价配置', + noModels: '未添加模型', + applyPricingToAccountStats: '应用模型定价到账号统计', + applyPricingToAccountStatsDesc: '启用后,未被自定义规则匹配的请求将使用模型定价文件中的标准价格计算账号统计费用', + accountStatsPricingRules: '自定义账号统计定价规则', + addRule: '添加规则', + noRulesConfigured: '未配置自定义规则,将使用上方的模型定价。', + ruleName: '规则名称(可选)', + ruleGroups: '分组', + ruleAccounts: '账号', + searchAccountPlaceholder: '搜索账号...', + ruleAccountsHint: '留空表示匹配所有账号', + ruleModelPricing: '模型定价', + noGroupsInChannel: '上方平台标签页中未选择分组', + unnamed: '未命名', + syncLatestModels: '同步最新模型', + syncingModels: '同步中...', + syncModelsSuccess: '已同步 {count} 个新模型', + syncModelsAlreadyUpToDate: '模型列表已是最新', + syncModelsError: '同步模型失败' + } + }, + + riskControl: { + title: '风控中心', + description: '配置内容审计策略并查看审核记录', + loadFailed: '加载风控中心失败', + saveFailed: '保存内容审计配置失败', + logsFailed: '加载审核记录失败', + saved: '内容审计配置已保存', + refresh: '刷新', + config: '内容审计配置', + configHint: '调用 OpenAI Moderations 进行请求内容评分,命中阈值后按模式处理。', + openSettings: '内容审计设置', + settingsTitle: '内容审计设置', + refreshStatus: '刷新状态', + records: '审核记录', + recordsHint: '展示命中、拦截、异常和已采样记录。', + saveConfig: '保存内容审计配置', + statusFailed: '加载运行状态失败', + enabled: '开启内容审计', + enabledHint: '关闭后即使风控中心菜单启用,也不会审核网关请求。', + mode: '全局模式', + modePreBlock: '前置拦截', + modePreBlockDesc: '每次请求先同步审核最新用户输入,命中后立即拒绝请求。', + modeObserve: '仅观察', + modeObserveDesc: '请求直接放行,最新用户输入进入异步审核队列;命中后只记录、通知和按规则累计。', + modeOff: '关闭', + modeOffDesc: '不执行内容审计,也不会写入审核记录。', + baseUrl: 'OpenAI Base URL', + model: '模型名', + apiKey: 'OpenAI API Key', + apiKeys: 'OpenAI API Keys', + apiKeyCount: '{count} 个 Key', + apiKeyPlaceholder: '请输入 API Key', + apiKeysPlaceholder: '新增 API Key,每行一个;保存后会追加到已保存 Key', + apiKeysPlaceholderReplace: '覆盖保存 API Key,每行一个;保存后会替换全部已保存 Key', + apiKeysPlaceholderKeep: '新增 API Key,每行一个;保存后会追加到已保存 Key', + apiKeysHint: '当前已保存 {count} 个 Key;输入区只用于新增,保存时会增量追加并自动去重。', + apiKeysWriteMode: '写入方式', + apiKeysModeAppend: '增量添加', + apiKeysModeReplace: '覆盖保存', + apiKeysModeAppendHint: '默认模式:保存时追加输入区 Key,并保留已保存 Key。', + apiKeysModeReplaceHint: '覆盖模式:保存时用输入区 Key 替换全部已保存 Key。', + apiKeysReplaceWarning: '覆盖模式', + apiKeysReplaceNoInput: '覆盖保存至少需要输入 1 个 API Key', + apiKeyPlaceholderKeep: '留空保持不变', + apiKeyWillClear: '保存后清除已配置 Key', + apiKeyConfigured: '已配置', + apiKeyTemporary: '待保存', + apiKeyPendingDelete: '待删除', + apiKeyPendingDeleteCount: '待删除 {count} 个 Key', + deleteApiKey: '删除这个 Key', + undoDeleteApiKey: '撤销删除', + inputApiKeyCount: '输入区 {count} 个 Key', + storedApiKeyCount: '已保存 {count} 个 Key', + testInputApiKeys: '测试输入区 Key', + testStoredApiKeys: '测试已保存 Key', + testContentWithStoredApiKey: '用已保存 Key 试跑内容', + testingApiKeys: '测试中', + apiKeyTestNoInput: '请先输入需要测试的 OpenAI API Key', + apiKeyTestDone: 'Key 测试完成,共 {count} 个', + apiKeyTestFailed: '测试 OpenAI API Key 失败', + apiKeyHealth: 'Key 可用状态', + apiKeyFreezeRule: '400 不冻结;401/403 冻结 10 分钟;429/529 冻结 1 分钟;其他 HTTP 错误冻结 10 秒。', + apiKeyRows: '{count} 个 Key', + apiKeyRowsCollapsed: '已隐藏 {count} 个 Key', + apiKeyRowsExpanded: '正在显示全部 {count} 个 Key', + expandApiKeyRows: '展开', + collapseApiKeyRows: '收起', + apiKeyHealthEmpty: '暂无 Key 状态', + apiKeyHealthEmptyHint: '保存 Key 或测试输入区 Key 后会显示可用性。', + apiKeyStatusOk: '可用', + apiKeyStatusError: '异常', + apiKeyStatusFrozen: '冻结', + apiKeyStatusUnknown: '未测试', + apiKeyFailureCount: '失败 {count} 次', + apiKeyLatency: '{ms} ms', + apiKeyHTTPStatus: 'HTTP {status}', + apiKeyFrozenUntil: '冻结至 {time}', + apiKeyLastChecked: '检查于 {time}', + apiKeyNotTested: '尚未测试', + auditTestInput: '审计试跑输入', + auditTestInputHint: '可填写提示词并上传或粘贴图片;图片以 base64 发送,不会保存文件。', + auditTestPromptPlaceholder: '输入要测试的用户提示词;留空时仅测试 Key 可用性。', + auditTestImages: '测试图片', + auditTestImagesHint: '支持上传、拖拽或粘贴图片,最多 1 张,每张不超过 8MB。', + addAuditTestImage: '添加图片', + clearAuditTest: '清空试跑', + auditTestImageLimit: '最多只能添加 {count} 张测试图片', + auditTestImageTooLarge: '单张测试图片不能超过 8MB', + auditTestImageReadFailed: '读取测试图片失败', + auditTestResult: '审计试跑结果', + auditTestHighest: '最高分类 {category},分数 {score}', + auditTestComposite: '综合评分', + auditTestFlagged: '命中阈值', + auditTestPassed: '未命中', + notConfigured: '未配置', + clearApiKey: '清除已保存 Key', + keepApiKey: '保留已保存 Key', + timeoutMs: 'HTTP 超时 (ms)', + retryCount: '失败重试次数', + sampleRate: '采样率', + recordNonHits: '记录未命中输入', + recordNonHitsHint: '开启后会记录抽样但未命中的请求摘要,摘要会先脱敏再入库。', + preHashCheck: '启用前置哈希比对', + preHashCheckHint: '异步审核命中过的输入哈希会被前置拦截;该拦截不发送邮件,也不累计封禁次数。', + flaggedHashCount: '当前哈希集合数量:{count} 个', + flaggedHashHint: '哈希永久保存在 Redis 集合中;可粘贴完整 64 位哈希删除误拦截项,或一键清空全部风险哈希。', + flaggedHashPlaceholder: '粘贴完整 64 位输入哈希', + deleteFlaggedHash: '删除指定哈希', + clearFlaggedHashes: '一键清空', + clearFlaggedHashesConfirm: '确定要清空全部风险输入哈希吗?此操作不会删除审核记录,但会取消所有历史哈希拦截。', + flaggedHashDeleted: '风险哈希已删除', + flaggedHashNotFound: '该风险哈希不存在', + flaggedHashDeleteFailed: '删除风险哈希失败', + flaggedHashesCleared: '已清空 {count} 个风险哈希', + flaggedHashesClearFailed: '清空风险哈希失败', + workerCount: 'Worker 数', + queueSize: '异步队列大小', + blockStatus: '拦截 HTTP 状态码', + blockMessage: '自定义拦截提示', + defaultBlockMessage: '内容审计命中风险规则,请调整输入后重试', + emailOnHit: '命中后发送邮件', + emailOnHitHint: '开启后每次达到阈值都会向用户发送风控提醒邮件;自动封禁通知始终发送。', + autoBan: '自动封禁用户', + autoBanHint: '命中次数达到阈值后将禁用用户账号、刷新认证缓存并发送封禁通知邮件。', + cyberPolicyExcludeBan: 'cyber_policy 不计入封号次数', + cyberPolicyExcludeBanHint: '开启后,cyber_policy 拦截不再计入自动封号的违规次数:当次不判定封号,历史累计亦排除。风控日志与通知邮件照常。', + violationNotCounted: '未计入封号', + banThreshold: '封禁触发次数', + violationWindowHours: '累计窗口(小时)', + hitRetentionDays: '命中记录保留(天)', + nonHitRetentionDays: '未命中记录保留(天,最多 3 天)', + violationCount: '{count} 次', + emailSent: '已发邮件', + emailNotSent: '未发邮件', + autoBanned: '已封禁', + unbanUser: '解封', + unbanSuccess: '用户已解封', + unbanFailed: '解封用户失败', + inputDetailTitle: '输入摘要详情', + inputDetailContent: '完整内容', + matchedKeyword: '命中关键词', + queueDelay: '排队 {ms} ms', + allGroups: '全部分组', + allGroupsHint: '当前审计全部分组', + selectedGroupsHint: '当前审计指定分组', + groupScope: '审计分组', + groupScopeHint: '开启右侧开关表示全部分组,关闭后选择指定分组。', + selectedGroups: '指定分组', + searchGroups: '搜索分组名称或平台', + noGroups: '暂无可用分组', + modelFilter: '模型范围', + modelFilterHint: '按客户端请求的模型名决定是否执行内容审计,模型映射后仍以请求模型判断。', + modelFilterAll: '所有模型', + modelFilterAllDesc: '所有模型请求都会进入内容审计。', + modelFilterInclude: '仅指定模型', + modelFilterIncludeDesc: '只有列表中的模型会执行内容审计。', + modelFilterExclude: '排除指定模型', + modelFilterExcludeDesc: '列表中的模型跳过内容审计,其余模型执行审计。', + modelFilterModels: '模型列表', + modelFilterModelCount: '已配置 {count} 个模型', + modelFilterModelsRequired: '当前模型范围至少需要配置 1 个模型', + modelFilterAllSummary: '全部模型生效', + modelFilterIncludeSummary: '仅 {count} 个模型生效', + modelFilterExcludeSummary: '排除 {count} 个模型', + emptyLogs: '暂无审核记录', + preBlockSyncStatus: '前置拦截同步状态', + preBlockSyncHint: '同步审核链路的实时计数,不包含异步写记录任务。', + preBlockActive: '同步处理中', + preBlockActiveHint: '当前正在审核', + preBlockChecked: '已检查', + preBlockCheckedHint: '进入前置拦截链路', + preBlockAllowed: '已放行', + preBlockAllowedHint: '未触发拦截', + preBlockBlocked: '已拦截', + preBlockBlockedHint: '命中后拒绝请求', + preBlockErrors: '审核异常', + preBlockErrorsHint: '失败或无可用 Key', + preBlockAvgLatency: '平均耗时', + preBlockAvgLatencyHint: '同步链路平均值', + preBlockAPIKeyLoad: '审核 Key 负载', + preBlockAPIKeyLoadHint: '同步前置拦截直接轮询可用审核 Key。', + preBlockAPIKeyLoadSummary: '同步并发 {active} / 可用 Key {available},累计 {total} 次,worker:{workerActive} / {workerTotal}', + preBlockAPIKeyTotals: '累计 {total},成功 {success},异常 {errors}', + preBlockAPIKeyLoadEmpty: '暂无审核 Key 负载数据', + preBlockKeyActiveShort: '并发', + preBlockKeyTotalShort: '累计', + preBlockKeyAvgShort: '平均', + preBlockKeyLastShort: '最近', + workerStatus: 'Worker 运行状态', + workerStatusHint: '异步审计任务和前置拦截记录任务的队列与 Worker 池状态,不包含同步前置拦截审核请求。', + workerPool: 'Worker 池', + workerPoolMeta: '{active} 个处理中,{idle} 个空闲可用,共 {total} 个', + queueUsage: '队列占用', + activeWorkers: '处理中', + idleWorkers: '空闲可用', + workerActive: '正在处理异步审计或记录任务', + workerIdle: '已启动,当前空闲可用', + workerDisabled: '风控或内容审计未启用', + processed: '已处理', + droppedErrors: '丢弃/异常', + autoRefresh: '每 15 秒自动刷新', + lastCleanup: '上次清理:{time}', + cleanupStats: '上次清理删除命中 {hit} 条,未命中 {nonHit} 条', + riskSwitchOff: '系统开关关闭', + riskThresholds: '风险阈值', + riskThresholdsHint: '按 OpenAI Moderations 分类调整命中阈值,分数达到或超过阈值即视为命中。', + riskThresholdDefault: '默认 {value}', + riskThresholdReset: '恢复默认阈值', + riskThresholdPercent: '阈值百分比', + tabs: { + basic: '基础', + scope: '审计范围', + runtime: '运行队列', + response: '命中通知', + riskThresholds: '风险阈值', + keywords: '关键词拦截', + retention: '日志保留', + }, + blockedKeywords: '拦截关键词', + blockedKeywordsPlaceholder: '每行输入一个关键词,例如:\n敏感词1\n敏感词2', + blockedKeywordsDescription: '匹配忽略大小写;命中后会按下方策略决定是否调用上游审计接口。', + blockedKeywordsPreBlockHint: '关键词拦截仅在「前置拦截」模式下生效。', + blockedKeywordsModeWarning: '当前为「{mode}」模式,关键词拦截不会生效;请切换到「前置拦截」模式后再保存关键词。', + blockedKeywordCount: '已配置 {count} 个关键词', + blockedKeywordsLimit: '最多保存 {max} 个关键词,单个长度不超过 200 个字符;重复项会自动去重。', + keywordBlockingMode: '审计策略', + keywordModeKeywordAndApi: '关键词 + API', + keywordModeKeywordAndApiDesc: '命中关键词直接拦截;未命中时再调用上游审计接口。', + keywordModeKeywordOnly: '仅关键词', + keywordModeKeywordOnlyDesc: '只用关键词判断,未命中即放行,不调用上游审计接口,可显著降低 API 用量。', + keywordModeKeywordOnlyNotice: '当前为「仅关键词」策略:未命中关键词的请求将直接放行,不调用上游审计接口。', + keywordModeApiOnly: '仅 API', + keywordModeApiOnlyDesc: '只调用上游审计接口判断,本页的关键词列表将不会生效。', + keywordModeApiOnlyNotice: '当前为「仅 API」策略:关键词列表不会生效,请求会全部交给上游审计接口判断。', + overview: { + status: '运行状态', + enabled: '已启用', + disabled: '未启用', + apiKey: 'API Key', + groupScope: '审计范围', + logs: '审核记录', + currentFilter: '当前筛选结果', + }, + filters: { + search: '按用户/Key/摘要搜索', + from: '开始时间', + to: '结束时间', + allGroups: '全部分组', + allEndpoints: '全部端点', + }, + table: { + time: '时间', + group: '分组', + user: '用户', + apiKey: 'API Key', + endpoint: '端点', + result: '结果', + highest: '最高分', + actionMeta: '处置', + latency: '上游耗时', + input: '输入摘要', + }, + result: { + all: '全部结果', + hit: '命中', + blocked: '已拦截', + pass: '未命中', + error: '异常', + }, + action: { + block: '拦截', + keywordBlock: '关键词拦截', + cyberPolicy: '网络安全策略', + error: '异常', + }, + }, + + // Channel Monitor + channelMonitor: { + title: '渠道监控', + description: '监测各渠道的可用性、延迟和状态', + searchPlaceholder: '搜索监控名称...', + allProviders: '全部供应商', + allStatus: '全部状态', + enabledFilter: '启用状态', + onlyEnabled: '仅启用', + onlyDisabled: '仅禁用', + createButton: '新增监控', + createTitle: '新增渠道监控', + editTitle: '编辑渠道监控', + runNow: '立即检测', + runSuccess: '检测完成', + runFailed: '检测失败', + apiKeyDecryptFailed: 'API Key 解密失败,请重新编辑该监控并填入新的 Key', + createSuccess: '监控创建成功', + updateSuccess: '监控更新成功', + deleteSuccess: '监控删除成功', + loadError: '加载监控列表失败', + deleteConfirm: '确定要删除监控「{name}」吗?此操作不可撤销。', + nameRequired: '请输入监控名称', + primaryModelRequired: '请输入主模型', + columns: { + name: '名称', + provider: '供应商', + primaryModel: '主模型', + availability7d: '7 天可用率', + latency: '延迟 (ms)', + enabled: '启用', + actions: '操作' + }, + form: { + name: '名称', + namePlaceholder: '输入监控名称', + provider: '平台', + apiMode: 'OpenAI 协议', + apiModeChatCompletions: 'OpenAI Compatible', + apiModeChatCompletionsHint: '使用 /v1/chat/completions,发送 messages;适合大多数兼容站。', + apiModeResponses: 'Responses API', + apiModeResponsesHint: '使用 /v1/responses,默认带 instructions + input;适合本站自检/Codex。', + endpoint: '上游地址', + endpointPlaceholder: 'https://api.example.com', + useCurrentDomain: '使用当前服务', + apiKey: 'API Key', + apiKeyPlaceholder: '请输入 API Key', + apiKeyEditPlaceholder: '留空表示不修改', + useMyKey: '使用我的 Key', + selectKeyTitle: '选择我的 API Key', + selectKeyHint: '仅显示当前账号下处于「启用」状态且未过期的 Key。', + noActiveKey: '没有可用的启用状态 Key', + primaryModel: '主模型', + primaryModelPlaceholder: 'gpt-4o-mini', + extraModels: '附加模型', + extraModelsPlaceholder: '回车添加附加模型', + groupName: '分组名称', + groupNamePlaceholder: '可选,用于在用户视图中聚合显示', + intervalSeconds: '检测间隔 (秒)', + intervalSecondsHint: '范围:15 - 3600 秒', + jitterSeconds: '随机抖动 (± 秒)', + jitterSecondsHint: '每次检测在间隔基础上正负随机偏移该秒数,0 表示固定间隔;需满足 间隔 - 抖动 ≥ 15 秒', + enabled: '启用监控', + kindRequired: '请选择供应商' + }, + runResultTitle: '检测结果', + noMonitorsYet: '暂无监控', + createFirstMonitor: '创建第一个监控来跟踪渠道可用性', + advanced: { + section: '高级(可选)', + sectionHint: '自定义请求头和请求体,用于突破上游的客户端识别限制(如仅允许 Claude Code 客户端)。', + headers: '自定义请求头', + headersPlaceholder: 'User-Agent: claude-cli/1.0.83 (external, cli)\nx-app: cli\nanthropic-beta: claude-code-20250219', + headerNamePlaceholder: 'Header 名', + headerValuePlaceholder: 'Value', + headerAddRow: '添加 Header', + headerNameInvalid: 'Header 名不能包含空格或冒号:{name}', + headersHint: '与默认请求头合并,用户值优先。hop-by-hop 类 header(Host/Content-Length/...)会被忽略。', + headersParseError: '无法解析这一行:{line}', + bodyMode: '请求体处理', + bodyModeOff: '默认', + bodyModeMerge: '合并', + bodyModeReplace: '覆盖', + bodyModeHintOff: '使用 adapter 默认请求体(带 challenge 数学题校验)。', + bodyModeHintMerge: '与默认请求体浅合并,用户字段优先;但 model / messages / contents 会被保护不允许覆盖(动这些字段请用「覆盖」模式)。', + bodyModeHintReplace: '完全用下方 JSON 作为请求体。注意:此模式下跳过 challenge 校验,改为 HTTP 2xx + 响应文本非空即视为可用。', + bodyJson: 'Body JSON', + bodyJsonFormat: '格式化', + bodyJsonHint: '失焦时自动解析校验。留空等价于没有覆盖。', + bodyJsonError: 'JSON 解析失败', + bodyJsonObjectError: '请求体必须是一个 JSON 对象(不能是数组或基本类型)' + }, + templateField: { + label: '请求模板', + none: '不使用模板', + placeholder: '选择一个模板(按当前平台过滤)', + applyHint: '选中模板后,会把模板的请求头和请求体拷贝到此监控(快照)。后续模板变动不自动同步。' + }, + template: { + manageButton: '模板管理', + managerTitle: '请求模板管理', + createButton: '新建模板', + emptyState: '当前平台下还没有请求模板', + missingName: '请输入模板名称', + createSuccess: '模板创建成功', + updateSuccess: '模板更新成功', + deleteSuccess: '模板删除成功', + applyButton: '应用到关联监控', + applyTooltip: '把当前模板配置覆盖到所有关联的监控上', + applyTitle: '应用模板', + applyConfirm: '确认应用', + applyConfirmMessage: '将把模板「{name}」的当前配置覆盖到 {n} 个关联监控。监控本地已编辑的自定义修改会被丢弃,是否继续?', + applySuccess: '已应用到 {n} 个监控', + applyPickerTitle: '应用模板「{name}」', + applyPickerHint: '勾选要覆盖请求头/请求体的监控(默认全选)。监控本地已编辑的自定义修改会被丢弃。', + applyPickerEmpty: '当前模板没有关联监控', + applyPickerConfirm: '应用到 {n} 个监控', + selectNone: '全不选', + selectedCount: '已选 {n} / {total}', + deleteConfirm: '确定要删除模板「{name}」吗?{n} 个关联监控会解除关联但保留自己的快照继续工作。', + associatedCount: '{n} 个关联监控', + headersSummary: '{n} 个自定义请求头', + form: { + name: '模板名称', + namePlaceholder: '例:Claude Code 伪装', + description: '说明', + descriptionPlaceholder: '可选:说明这个模板的用途和来源(抓包日期等)' + } + } + }, + + // Subscriptions Management + subscriptions: { + title: '订阅管理', + description: '管理用户订阅和配额限制', + assignSubscription: '分配订阅', + adjustSubscription: '调整订阅', + revokeSubscription: '撤销订阅', + restoreSubscription: '恢复订阅', + allStatus: '全部状态', + allGroups: '全部分组', + allPlatforms: '全部平台', + daily: '每日', + weekly: '每周', + monthly: '每月', + noLimits: '未配置限额', + unlimited: '无限制', + resetNow: '即将重置', + windowNotActive: '窗口未激活', + resetInMinutes: '{minutes} 分钟后重置', + resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置', + resetInDaysHours: '{days} 天 {hours} 小时后重置', + quotaEndsInMinutes: '额度将在 {minutes} 分钟后结束', + quotaEndsInHoursMinutes: '额度将在 {hours} 小时 {minutes} 分钟后结束', + quotaEndsInDaysHours: '额度将在 {days} 天 {hours} 小时后结束', + daysRemaining: '天剩余', + remainingDays: '剩余天数', + noExpiration: '无过期时间', + status: { + active: '生效中', + expired: '已过期', + revoked: '已撤销', + suspended: '已暂停' + }, + columns: { + user: '用户', + group: '分组', + usage: '用量', + expires: '到期时间', + status: '状态', + actions: '操作' + }, + form: { + user: '用户', + group: '订阅分组', + validityDays: '有效期(天)', + adjustDays: '调整天数' + }, + selectUser: '选择用户', + selectGroup: '选择订阅分组', + groupHint: '仅显示订阅计费类型的分组', + validityHint: '订阅的有效天数', + adjustingFor: '为以下用户调整订阅', + currentExpiration: '当前到期时间', + adjustDaysPlaceholder: '正数延长,负数缩短', + adjustHint: '输入正数延长订阅,负数缩短订阅(缩短后剩余天数需大于0)', + assign: '分配', + assigning: '分配中...', + adjust: '调整', + adjusting: '调整中...', + revoke: '撤销', + restore: '恢复', + resetQuota: '重置配额', + resetQuotaTitle: '重置用量配额', + resetQuotaConfirm: "确定要重置 '{user}' 的每日、每周和每月用量配额吗?用量将归零并从今天开始重新计算。", + quotaResetSuccess: '配额重置成功', + failedToResetQuota: '重置配额失败', + noSubscriptionsYet: '暂无订阅', + assignFirstSubscription: '分配一个订阅以开始使用。', + subscriptionAssigned: '订阅分配成功', + subscriptionAdjusted: '订阅调整成功', + subscriptionRevoked: '订阅撤销成功', + subscriptionRestored: '订阅已恢复', + failedToLoad: '加载订阅列表失败', + failedToAssign: '分配订阅失败', + failedToAdjust: '调整订阅失败', + failedToRevoke: '撤销订阅失败', + failedToRestore: '恢复订阅失败', + adjustWouldExpire: '调整后剩余天数必须大于0', + adjustOutOfRange: '调整天数必须在 -36500 到 36500 之间', + pleaseSelectUser: '请选择用户', + pleaseSelectGroup: '请选择分组', + validityDaysRequired: '请输入有效的天数(至少1天)', + revokeConfirm: "确定要撤销 '{user}' 的订阅吗?可稍后在已撤销列表中恢复。", + restoreConfirm: "确定要恢复 '{user}' 的订阅吗?如果原订阅已过期,恢复后将显示为已过期。", + guide: { + title: '订阅管理教程', + subtitle: '订阅模式允许你按时间周期为用户分配使用额度,支持日/周/月配额限制。按照以下步骤即可完成配置。', + showGuide: '使用指南', + step1: { + title: '创建订阅分组', + line1: '前往「分组管理」页面,点击「创建分组」', + line2: '将计费类型设为「订阅」,配置日/周/月额度限制', + line3: '保存分组,确保状态为「正常」', + link: '前往分组管理' + }, + step2: { + title: '分配订阅给用户', + line1: '点击本页右上角「分配订阅」按钮', + line2: '在弹窗中搜索用户邮箱并选择目标用户', + line3: '选择订阅分组、设置有效期天数,点击「分配」' + }, + step3: { + title: '管理已有订阅' + }, + actions: { + adjust: '调整', + adjustDesc: '延长或缩短订阅有效期', + resetQuota: '重置配额', + resetQuotaDesc: '将日/周/月用量归零,重新开始计算', + revoke: '撤销', + revokeDesc: '立即终止该用户的订阅,可在已撤销列表中恢复' + }, + tip: '提示:订阅分组下拉列表中只会显示计费类型为「订阅」且状态为「正常」的分组。如果没有可选项,请先到分组管理中创建。' + } + }, + + // Accounts Management +} diff --git a/frontend/src/i18n/locales/zh/admin/index.ts b/frontend/src/i18n/locales/zh/admin/index.ts new file mode 100644 index 0000000000..e4e9bba438 --- /dev/null +++ b/frontend/src/i18n/locales/zh/admin/index.ts @@ -0,0 +1,15 @@ +import overview from './overview' +import channels from './channels' +import accounts from './accounts' +import resources from './resources' +import ops from './ops' +import settings from './settings' + +export default { + ...overview, + ...channels, + ...accounts, + ...resources, + ...ops, + ...settings, +} diff --git a/frontend/src/i18n/locales/zh/admin/ops.ts b/frontend/src/i18n/locales/zh/admin/ops.ts new file mode 100644 index 0000000000..97b974fd8f --- /dev/null +++ b/frontend/src/i18n/locales/zh/admin/ops.ts @@ -0,0 +1,810 @@ +export default { + ops: { + title: '运维监控', + description: '运维监控与排障', + // Dashboard + systemHealth: '系统健康', + overview: '概览', + noSystemMetrics: '尚未收集系统指标。', + collectedAt: '采集时间:', + window: '窗口', + memory: '内存', + db: '数据库', + goroutines: '协程', + jobs: '后台任务', + jobsHelp: '点击“明细”查看任务心跳与报错信息', + active: '活跃', + idle: '空闲', + waiting: '等待', + conns: '连接', + queue: '队列', + accountSwitches: '账号切换', + ok: '正常', + lastRun: '最近运行', + lastSuccess: '最近成功', + lastError: '最近错误', + result: '结果', + noData: '暂无数据', + loadingText: '加载中...', + ready: '就绪', + autoRefreshRemaining: '剩余 {seconds}s', + systemLogs: { + title: '系统日志', + description: '优先显示最新日志,可按条件筛选、搜索和清理。', + queue: '队列', + written: '已写入', + dropped: '已丢弃', + failed: '写入失败', + runtimeConfig: '运行时日志配置(立即生效)', + all: '全部', + level: '级别', + stacktraceThreshold: '堆栈阈值', + samplingInitial: '采样初始条数', + samplingThereafter: '后续采样间隔', + retentionDays: '保留天数', + caller: '调用方', + sampling: '采样', + saveAndApply: '保存并应用', + resetDefaults: '重置默认值', + latestWriteError: '最近写入错误:', + timeRange: '时间范围', + startTime: '开始时间(可选)', + endTime: '结束时间(可选)', + component: '组件', + componentPlaceholder: '例如 http.access', + keyId: 'KEY ID', + platform: '平台', + model: '模型', + keyword: '关键词', + keywordPlaceholder: 'message/request_id', + search: '搜索', + cleanCurrentFilters: '清理当前筛选结果', + refreshHealth: '刷新健康状态', + empty: '暂无系统日志', + time: '时间', + logDetails: '日志详情', + loadFailed: '加载系统日志失败', + runtimeConfigActive: '运行时日志配置已生效', + runtimeConfigSaveFailed: '保存日志配置失败', + resetRuntimeConfigConfirm: '确定要重置为启动配置(env/yaml)并立即应用吗?', + runtimeConfigReset: '已重置为启动日志配置', + runtimeConfigResetFailed: '重置日志配置失败', + cleanupConfirm: '确定要清理匹配当前筛选条件的系统日志吗?此操作不可撤销。', + cleanupSuccess: '清理完成,已删除 {count} 条日志。', + cleanupFailed: '清理系统日志失败' + }, + requestsTotal: '请求(总计)', + slaScope: 'SLA 范围:', + tokens: 'Token数', + tps: 'TPS', + current: '当前', + peak: '峰值', + average: '平均', + totalRequests: '总请求', + avgQps: '平均 QPS', + avgTps: '平均 TPS', + avgLatency: '平均请求时长', + avgTtft: '平均首 Token 延迟', + exceptions: '异常数', + requestErrors: '请求错误', + errorCount: '错误数', + upstreamErrors: '上游错误', + errorCountExcl429529: '错误数(排除429/529)', + sla: 'SLA(排除业务限制)', + businessLimited: '业务限制:', + errors: '错误', + errorRate: '错误率:', + upstreamRate: '上游错误率:', + latencyDuration: '请求时长', + ttftLabel: '首 Token 延迟(毫秒)', + p50: 'p50', + p90: 'p90', + p95: 'p95', + p99: 'p99', + avg: 'avg', + max: 'max', + requests: '请求数', + requestsTitle: '请求', + upstream: '上游', + client: '客户端', + system: '系统', + other: '其他', + errorsSla: '错误(SLA范围)', + upstreamExcl429529: '上游(排除429/529)', + failedToLoadData: '加载运维数据失败', + failedToLoadOverview: '加载概览数据失败', + failedToLoadThroughputTrend: '加载吞吐趋势失败', + failedToLoadSwitchTrend: '加载平均账号切换趋势失败', + failedToLoadLatencyHistogram: '加载请求时长分布失败', + failedToLoadErrorTrend: '加载错误趋势失败', + failedToLoadErrorDistribution: '加载错误分布失败', + failedToLoadErrorDetail: '加载错误详情失败', + retryFailed: '重试失败', + tpsK: 'TPS(千)', + top: '最高:', + throughputTrend: '吞吐趋势', + switchRateTrend: '平均账号切换趋势', + latencyHistogram: '请求时长分布', + errorTrend: '错误趋势', + errorDistribution: '错误分布', + switchRate: '平均账号切换', + // Health Score & Diagnosis + health: '健康', + healthCondition: '健康状况', + healthHelp: '基于 SLA、错误率和资源使用情况的系统整体健康评分', + healthyStatus: '健康', + riskyStatus: '风险', + idleStatus: '待机', + timeRange: { + '5m': '近5分钟', + '30m': '近30分钟', + '1h': '近1小时', + '1d': '近1天', + '15d': '近15天', + '6h': '近6小时', + '24h': '近24小时', + '7d': '近7天', + '30d': '近30天', + custom: '自定义' + }, + openaiTokenStats: { + title: 'OpenAI Token 请求统计', + viewModeTopN: 'TopN', + viewModePagination: '分页', + prevPage: '上一页', + nextPage: '下一页', + pageInfo: '第 {page}/{total} 页', + totalModels: '模型总数:{total}', + failedToLoad: '加载 OpenAI Token 统计失败', + empty: '当前筛选条件下暂无 OpenAI Token 请求统计数据', + table: { + model: '模型', + requestCount: '请求数', + avgTokensPerSec: '平均 Tokens/秒', + avgFirstTokenMs: '平均首 Token 延迟(ms)', + totalOutputTokens: '输出 Token 总数', + avgDurationMs: '平均时长(ms)', + requestsWithFirstToken: '首 Token 样本数' + } + }, + customTimeRange: { + startTime: '开始时间', + endTime: '结束时间' + }, + fullscreen: { + enter: '进入全屏' + }, + diagnosis: { + title: '智能诊断', + footer: '基于当前指标的自动诊断建议', + idle: '系统当前处于待机状态', + idleImpact: '无活跃流量', + // Resource diagnostics + dbDown: '数据库连接失败', + dbDownImpact: '所有数据库操作将失败', + dbDownAction: '检查数据库服务状态、网络连接和连接配置', + redisDown: 'Redis连接失败', + redisDownImpact: '缓存功能降级,性能可能下降', + redisDownAction: '检查Redis服务状态和网络连接', + cpuCritical: 'CPU使用率严重过高 ({usage}%)', + cpuCriticalImpact: '系统响应变慢,可能影响所有请求', + cpuCriticalAction: '检查CPU密集型任务,考虑扩容或优化代码', + cpuHigh: 'CPU使用率偏高 ({usage}%)', + cpuHighImpact: '系统负载较高,需要关注', + cpuHighAction: '监控CPU趋势,准备扩容方案', + memoryCritical: '内存使用率严重过高 ({usage}%)', + memoryCriticalImpact: '可能触发OOM,系统稳定性受威胁', + memoryCriticalAction: '检查内存泄漏,考虑增加内存或优化内存使用', + memoryHigh: '内存使用率偏高 ({usage}%)', + memoryHighImpact: '内存压力较大,需要关注', + memoryHighAction: '监控内存趋势,检查是否有内存泄漏', + ttftHigh: '首 Token 时间偏高 ({ttft}ms)', + ttftHighImpact: '用户感知时长增加', + ttftHighAction: '优化请求处理流程,减少前置逻辑耗时', + // Error rate diagnostics + upstreamCritical: '上游错误率严重偏高 ({rate}%)', + upstreamCriticalImpact: '可能影响大量用户请求', + upstreamCriticalAction: '检查上游服务健康状态,启用降级策略', + upstreamHigh: '上游错误率偏高 ({rate}%)', + upstreamHighImpact: '建议检查上游服务状态', + upstreamHighAction: '联系上游服务团队,准备降级方案', + errorHigh: '错误率过高 ({rate}%)', + errorHighImpact: '大量请求失败', + errorHighAction: '查看错误日志,定位错误根因,紧急修复', + errorElevated: '错误率偏高 ({rate}%)', + errorElevatedImpact: '建议检查错误日志', + errorElevatedAction: '分析错误类型和分布,制定修复计划', + // SLA diagnostics + slaCritical: 'SLA 严重低于目标 ({sla}%)', + slaCriticalImpact: '用户体验严重受损', + slaCriticalAction: '紧急排查错误原因,必要时采取限流保护', + slaLow: 'SLA 低于目标 ({sla}%)', + slaLowImpact: '需要关注服务质量', + slaLowAction: '分析SLA下降原因,优化系统性能', + // Health score diagnostics + healthCritical: '综合健康评分过低 ({score})', + healthCriticalImpact: '多个指标可能同时异常,建议优先排查错误与资源使用情况', + healthCriticalAction: '全面检查系统状态,优先处理critical级别问题', + healthLow: '综合健康评分偏低 ({score})', + healthLowImpact: '可能存在轻度波动,建议关注 SLA 与错误率', + healthLowAction: '监控指标趋势,预防问题恶化', + healthy: '所有系统指标正常', + healthyImpact: '服务运行稳定' + }, + // Error Log + errorLog: { + timeId: '时间 / ID', + commonErrors: { + contextDeadlineExceeded: '请求超时', + connectionRefused: '连接被拒绝', + rateLimit: '触发限流' + }, + time: '时间', + type: '类型', + context: '上下文', + platform: '平台', + model: '模型', + group: '分组', + user: '用户', + userId: '用户 ID', + apiKey: 'API Key', + keyDeletedBadge: 'Key 已删除', + account: '账号', + accountId: '账号 ID', + status: '状态码', + message: '响应内容', + ip: 'IP', + latency: '请求时长', + action: '操作', + noErrors: '该窗口内暂无错误。', + grp: 'GRP:', + acc: 'ACC:', + details: '详情', + phase: '阶段', + id: 'ID:', + typeUpstream: '上游', + typeRequest: '请求', + typeAuth: '认证', + typeRouting: '路由', + typeInternal: '内部', + endpoint: '端点', + requestType: '类型', + requestTypeSync: '同步', + requestTypeStream: '流式', + requestTypeWs: 'WS' + }, + // Error Details Modal + errorDetails: { + upstreamErrors: '上游错误', + requestErrors: '请求错误', + unresolved: '未解决', + resolved: '已解决', + viewErrors: '错误', + viewExcluded: '排除项', + statusCodeOther: '其他', + owner: { + provider: '服务商', + client: '客户端', + platform: '平台' + }, + phase: { + request: '请求', + auth: '认证', + routing: '路由', + upstream: '上游', + network: '网络', + internal: '内部' + }, + total: '总计:', + searchPlaceholder: '搜索 request_id / client_request_id / message' + }, + // Error Detail Modal + errorDetail: { + title: '错误详情', + titleWithId: '错误 #{id}', + noErrorSelected: '未选择错误。', + resolution: '已解决:', + failedToUpdateResolvedStatus: '更新解决状态失败', + classificationKeys: { + phase: '阶段', + owner: '归属方', + source: '来源', + resolvedAt: '解决时间', + resolvedBy: '解决人' + }, + source: { + upstream_http: '上游 HTTP' + }, + upstreamKeys: { + status: '状态码', + message: '消息', + detail: '详情', + upstreamErrors: '上游错误列表' + }, + upstreamEvent: { + account: '账号', + status: '状态码', + requestId: '请求ID' + }, + responsePreview: { + expand: '响应内容(点击展开)', + collapse: '响应内容(点击收起)' + }, + loading: '加载中…', + requestId: '请求 ID', + time: '时间', + phase: '阶段', + status: '状态码', + message: '消息', + basicInfo: '基本信息', + platform: '平台', + model: '模型', + group: '分组', + user: '用户', + account: '账号', + latency: '请求时长', + businessLimited: '业务限制', + requestPath: '请求路径', + inboundEndpoint: '入站端点', + upstreamEndpoint: '上游端点', + requestedModel: '请求模型', + upstreamModel: '上游模型', + requestType: '请求类型', + requestTypeUnknown: '未知', + requestTypeSync: '同步', + requestTypeStream: '流式', + requestTypeWs: 'WebSocket', + modelMapping: '模型映射', + timings: '时序信息', + auth: '认证', + routing: '路由', + upstream: '上游', + response: '响应', + classification: '错误分类', + errorBody: '错误体', + trimmed: '已截断', + markResolved: '标记已解决', + markUnresolved: '标记未解决', + tabOverview: '概览', + tabRequest: '请求详情', + tabResponse: '响应详情', + responseBody: '响应详情', + compareA: '对比 A', + compareB: '对比 B', + suggestion: '处理建议', + suggestUpstream: '⚠️ 上游服务不稳定,建议:检查上游账号状态 / 考虑切换账号', + suggestRequest: '⚠️ 客户端请求错误,建议:联系客户修正请求参数 / 手动标记已解决', + suggestAuth: '⚠️ 认证失败,建议:检查 API Key 是否有效 / 联系客户更新凭证', + suggestPlatform: '🚨 平台错误,建议立即排查修复', + suggestGeneric: '查看详情了解更多信息', + apiKeyPrefix: 'Key 前缀', + attemptedKeyPrefix: '尝试的 Key 前缀', + deletedKeyOwner: '已删除 Key 所有者', + keyDeletedBadge: 'Key 已删除' + }, + requestDetails: { + title: '请求明细', + details: '明细', + rangeLabel: '窗口:{range}', + rangeMinutes: '{n} 分钟', + rangeHours: '{n} 小时', + empty: '该窗口内暂无请求。', + emptyHint: '可尝试调整时间范围或取消部分筛选。', + failedToLoad: '加载请求明细失败', + requestIdCopied: '请求ID已复制', + copyFailed: '复制失败', + copy: '复制', + viewError: '查看错误', + kind: { + success: '成功', + error: '失败' + }, + table: { + time: '时间', + kind: '类型', + platform: '平台', + model: '模型', + duration: '耗时', + status: '状态码', + requestId: '请求ID', + actions: '操作' + } + }, + alertEvents: { + title: '告警事件', + description: '最近的告警触发/恢复记录(仅邮件通知)', + loading: '加载中...', + empty: '暂无告警事件', + loadFailed: '加载告警事件失败', + status: { + firing: '告警中', + resolved: '已恢复', + manualResolved: '手动已解决' + }, + detail: { + title: '告警详情', + loading: '加载详情中...', + empty: '暂无详情', + loadFailed: '加载告警详情失败', + manualResolve: '标记为已解决', + manualResolvedSuccess: '已标记为手动解决', + manualResolvedFailed: '标记为手动解决失败', + silence: '忽略此告警', + silenceSuccess: '已静默该告警', + silenceFailed: '静默失败', + viewRule: '查看规则', + viewLogs: '查看相关日志', + firedAt: '触发时间', + resolvedAt: '解决时间', + ruleId: '规则 ID', + dimensions: '维度信息', + historyTitle: '历史记录', + historyHint: '同一规则 + 相同维度的最近事件', + historyLoading: '加载历史中...', + historyEmpty: '暂无历史记录' + }, + table: { + time: '时间', + status: '状态', + severity: '级别', + platform: '平台', + ruleId: '规则ID', + title: '标题', + duration: '持续时间', + metric: '指标 / 阈值', + dimensions: '维度', + email: '邮件已发送', + emailSent: '已发送', + emailIgnored: '已忽略' + } + }, + alertRules: { + title: '告警规则', + description: '创建与管理系统阈值告警(仅邮件通知)', + loading: '加载中...', + empty: '暂无告警规则', + loadFailed: '加载告警规则失败', + saveSuccess: '警报规则保存成功', + saveFailed: '保存告警规则失败', + deleteSuccess: '警报规则删除成功', + deleteFailed: '删除告警规则失败', + create: '新建规则', + createTitle: '新建告警规则', + editTitle: '编辑告警规则', + deleteConfirmTitle: '确认删除该规则?', + deleteConfirmMessage: '将删除该规则及其关联的告警事件,是否继续?', + manage: '预警规则', + metricGroups: { + system: '系统指标', + group: '分组级别指标(需 group_id)', + account: '账号级别指标' + }, + metrics: { + successRate: '成功率 (%)', + errorRate: '错误率 (%)', + upstreamErrorRate: '上游错误率 (%)', + p95: 'P95 请求时长 (ms)', + p99: 'P99 请求时长 (ms)', + cpu: 'CPU 使用率 (%)', + memory: '内存使用率 (%)', + queueDepth: '并发排队深度', + groupAvailableAccounts: '分组可用账号数', + groupAvailableRatio: '分组可用比例 (%)', + groupRateLimitRatio: '分组限流比例 (%)', + accountRateLimitedCount: '限流账号数', + accountErrorCount: '错误账号数(不含临时不可调度)', + accountErrorRatio: '错误账号比例 (%)', + accountTempUnscheduledCount: '临时不可调度账号数', + overloadAccountCount: '过载账号数' + }, + metricDescriptions: { + successRate: '统计窗口内成功请求占比(0~100)。', + errorRate: '统计窗口内失败请求占比(0~100)。', + upstreamErrorRate: '统计窗口内上游错误占比(0~100)。', + p95: '统计窗口内 P95 请求耗时(毫秒)。', + p99: '统计窗口内 P99 请求耗时(毫秒)。', + cpu: '当前实例 CPU 使用率(0~100)。', + memory: '当前实例内存使用率(0~100)。', + queueDepth: '统计窗口内并发队列排队深度(等待中的请求数)。', + groupAvailableAccounts: '指定分组中当前可用账号数量(需要 group_id 过滤)。', + groupAvailableRatio: '指定分组中可用账号占比(0~100,需要 group_id 过滤)。', + groupRateLimitRatio: '指定分组中账号被限流的比例(0~100,需要 group_id 过滤)。', + accountRateLimitedCount: '统计窗口内被限流的账号数量。', + accountErrorCount: '统计窗口内产生错误的账号数量(不含临时不可调度)。', + accountErrorRatio: '统计窗口内错误账号占比(0~100)。', + accountTempUnscheduledCount: '当前处于临时不可调度状态的账号数量(如代理/凭据故障被自动摘除)。', + overloadAccountCount: '统计窗口内过载账号数量。' + }, + hints: { + recommended: '推荐:运算符 {operator},阈值 {threshold}{unit}', + groupRequired: '该指标为分组级别指标,必须选择分组(group_id)。', + groupOptional: '可选:通过 group_id 将规则限定到某个分组。' + }, + table: { + name: '名称', + metric: '指标', + severity: '级别', + enabled: '启用', + actions: '操作' + }, + form: { + name: '名称', + description: '描述', + metric: '指标', + operator: '运算符', + groupId: '分组(group_id)', + groupPlaceholder: '请选择分组', + allGroups: '全部分组', + threshold: '阈值', + severity: '级别', + window: '统计窗口(分钟)', + sustained: '连续样本数(每分钟)', + cooldown: '冷却期(分钟)', + enabled: '启用', + notifyEmail: '发送邮件通知' + }, + validation: { + title: '请先修正以下问题', + invalid: '规则不合法', + nameRequired: '名称不能为空', + metricRequired: '指标不能为空', + groupIdRequired: '分组级别指标必须指定 group_id', + operatorRequired: '运算符不能为空', + thresholdRequired: '阈值必须为数字', + windowRange: '统计窗口必须为 1 / 5 / 60 分钟之一', + sustainedRange: '连续样本数必须在 1 到 1440 之间', + cooldownRange: '冷却期必须在 0 到 1440 分钟之间' + } + }, + runtime: { + title: '运维监控运行设置', + description: '配置存储在数据库中,无需修改 config 文件即可生效。', + loading: '加载中...', + noData: '暂无运行设置', + loadFailed: '加载运行设置失败', + saveSuccess: '运行设置已保存', + saveFailed: '保存运行设置失败', + alertTitle: '告警评估器', + groupAvailabilityTitle: '分组可用性监控', + evalIntervalSeconds: '评估间隔(秒)', + silencing: { + title: '告警静默(维护模式)', + enabled: '启用静默', + globalUntil: '静默截止时间(RFC3339)', + untilHint: '建议填写截止时间,避免忘记关闭静默。', + reason: '原因', + reasonPlaceholder: '例如:计划维护', + entries: { + title: '高级:定向静默', + hint: '可选:仅静默特定规则或特定级别。字段留空表示匹配全部。', + add: '新增条目', + empty: '暂无定向静默条目', + entryTitle: '条目 #{n}', + ruleId: '规则ID(可选)', + ruleIdPlaceholder: '例如:1', + severities: '级别(可选)', + severitiesPlaceholder: '例如:P0,P1(留空=全部)', + until: '截止时间(RFC3339)', + reason: '原因', + validation: { + untilRequired: '条目截止时间不能为空', + untilFormat: '条目截止时间必须为合法的 RFC3339 时间戳', + ruleIdPositive: '条目 rule_id 必须为正整数', + severitiesFormat: '条目级别必须为 P0..P3 的逗号分隔列表' + } + }, + validation: { + timeFormat: '静默时间必须为合法的 RFC3339 时间戳' + } + }, + lockEnabled: '启用分布式锁', + lockKey: '分布式锁 Key', + lockTTLSeconds: '分布式锁 TTL(秒)', + showAdvancedDeveloperSettings: '显示高级开发者设置 (Distributed Lock)', + advancedSettingsSummary: '高级设置 (分布式锁)', + evalIntervalHint: '检测任务的执行频率,建议保持默认。', + validation: { + title: '请先修正以下问题', + invalid: '设置不合法', + evalIntervalRange: '评估间隔必须在 1 到 86400 秒之间', + lockKeyRequired: '启用分布式锁时必须填写 Lock Key', + lockKeyPrefix: '分布式锁 Key 必须以「{prefix}」开头', + lockKeyHint: '建议以「{prefix}」开头以避免冲突', + lockTtlRange: '分布式锁 TTL 必须在 1 到 86400 秒之间', + slaMinPercentRange: 'SLA 最低值必须在 0-100 之间', + ttftP99MaxRange: 'TTFT P99 最大值必须大于或等于 0', + requestErrorRateMaxRange: '请求错误率最大值必须在 0-100 之间', + upstreamErrorRateMaxRange: '上游错误率最大值必须在 0-100 之间' + } + }, + email: { + title: '邮件通知配置', + description: '配置告警/报告邮件通知(存储在数据库中)。', + loading: '加载中...', + noData: '暂无邮件通知配置', + loadFailed: '加载邮件通知配置失败', + saveSuccess: '邮件通知配置已保存', + saveFailed: '保存邮件通知配置失败', + alertTitle: '告警邮件', + reportTitle: '报告邮件', + recipients: '收件人', + recipientsHint: '若为空,系统可能会回退使用第一个管理员邮箱。', + minSeverity: '最低级别', + minSeverityAll: '全部级别', + rateLimitPerHour: '每小时限额', + batchWindowSeconds: '合并窗口(秒)', + includeResolved: '包含恢复通知', + dailySummary: '每日摘要', + weeklySummary: '每周摘要', + errorDigest: '错误摘要', + errorDigestMinCount: '错误摘要最小数量', + accountHealth: '账号健康报告', + accountHealthThreshold: '错误率阈值(%)', + cronPlaceholder: 'Cron 表达式', + reportHint: '发送时间使用 Cron 语法;留空将使用默认值。', + validation: { + title: '请先修正以下问题', + invalid: '邮件通知配置不合法', + alertRecipientsRequired: '已启用告警邮件,但未配置任何收件人', + reportRecipientsRequired: '已启用报告邮件,但未配置任何收件人', + invalidRecipients: '存在不合法的收件人邮箱', + rateLimitRange: '每小时限额必须为 ≥ 0 的数字', + batchWindowRange: '合并窗口必须在 0 到 86400 秒之间', + cronRequired: '启用定时任务时必须填写 Cron 表达式', + cronFormat: 'Cron 表达式格式可能不正确(至少应包含 5 段)', + digestMinCountRange: '错误摘要最小数量必须为 ≥ 0 的数字', + accountHealthThresholdRange: '账号健康错误率阈值必须在 0 到 100 之间' + } + }, + settings: { + title: '运维监控设置', + loadFailed: '加载设置失败', + saveSuccess: '运维监控设置保存成功', + saveFailed: '保存设置失败', + dataCollection: '数据采集', + evaluationInterval: '评估间隔(秒)', + evaluationIntervalHint: '检测任务的执行频率,建议保持默认', + alertConfig: '预警配置', + enableAlert: '开启预警', + alertRecipients: '预警接收邮箱', + emailPlaceholder: '输入邮箱地址', + recipientsHint: '若为空,系统将使用第一个管理员邮箱作为默认收件人', + minSeverity: '最低级别', + reportConfig: '评估报告配置', + enableReport: '开启评估报告', + reportRecipients: '评估报告接收邮箱', + dailySummary: '每日摘要', + weeklySummary: '每周摘要', + metricThresholds: '指标阈值配置', + metricThresholdsHint: '配置各项指标的告警阈值,超出阈值时将以红色显示', + slaMinPercent: 'SLA最低百分比', + slaMinPercentHint: 'SLA低于此值时显示为红色(默认:99.5%)', + ttftP99MaxMs: 'TTFT P99最大值(毫秒)', + ttftP99MaxMsHint: 'TTFT P99高于此值时显示为红色(默认:500ms)', + requestErrorRateMaxPercent: '请求错误率最大值(%)', + requestErrorRateMaxPercentHint: '请求错误率高于此值时显示为红色(默认:5%)', + upstreamErrorRateMaxPercent: '上游错误率最大值(%)', + upstreamErrorRateMaxPercentHint: '上游错误率高于此值时显示为红色(默认:5%)', + advancedSettings: '高级设置', + dataRetention: '数据保留策略', + enableCleanup: '启用数据清理', + cleanupSchedule: '清理计划(Cron)', + cleanupScheduleHint: '例如:0 2 * * * 表示每天凌晨2点', + errorLogRetentionDays: '错误日志保留天数', + minuteMetricsRetentionDays: '分钟指标保留天数', + hourlyMetricsRetentionDays: '小时指标保留天数', + retentionDaysHint: '建议保留 7-90 天,过长会占用存储空间;填 0 表示每次定时清理时清空所有历史', + aggregation: '预聚合任务', + enableAggregation: '启用预聚合任务', + aggregationHint: '预聚合可提升长时间窗口查询性能', + openaiQuotaAutoPause: 'OpenAI 账号配额自动暂停', + openaiQuotaAutoPauseHint: '当 OpenAI 账号 5h / 7d 用量达到阈值时,调度会自动跳过该账号;窗口滚动后自动恢复。账号级阈值优先于此全局默认值。', + openaiQuotaAutoPauseDefault5h: '默认 5h 用量阈值 (%)', + openaiQuotaAutoPauseDefault7d: '默认 7d 用量阈值 (%)', + openaiQuotaAutoPauseThresholdHint: '取值 0-100,留空或 0 表示不启用全局默认阈值。', + errorFiltering: '错误过滤', + ignoreCountTokensErrors: '忽略 count_tokens 错误', + ignoreCountTokensErrorsHint: '启用后,count_tokens 请求的错误将不会写入错误日志。', + ignoreContextCanceled: '忽略客户端断连错误', + ignoreContextCanceledHint: + '启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。', + ignoreNoAvailableAccounts: '忽略无可用账号错误', + ignoreNoAvailableAccountsHint: '启用后,"No available accounts" 错误将不会写入错误日志(不推荐,这通常是配置问题)。', + ignoreInvalidApiKeyErrors: '忽略无效 API Key 错误', + ignoreInvalidApiKeyErrorsHint: '启用后,无效或缺失 API Key 的错误(INVALID_API_KEY、API_KEY_REQUIRED)将不会写入错误日志。', + ignoreInsufficientBalanceErrors: '忽略余额不足错误', + ignoreInsufficientBalanceErrorsHint: '启用后,账号余额不足(Insufficient balance)的错误将不会写入错误日志。', + autoRefresh: '自动刷新', + enableAutoRefresh: '启用自动刷新', + enableAutoRefreshHint: '自动刷新仪表板数据,启用后会定期拉取最新数据。', + refreshInterval: '刷新间隔', + refreshInterval15s: '15 秒', + refreshInterval30s: '30 秒', + refreshInterval60s: '60 秒', + dashboardCards: '仪表盘卡片', + displayAlertEvents: '展示告警事件', + displayAlertEventsHint: '控制运维监控仪表盘中告警事件卡片是否显示,默认开启。', + displayOpenAITokenStats: '展示 OpenAI Token 请求统计', + displayOpenAITokenStatsHint: '控制运维监控仪表盘中 OpenAI Token 请求统计卡片是否显示,默认关闭。', + autoRefreshCountdown: '自动刷新:{seconds}s', + validation: { + title: '请先修正以下问题', + retentionDaysRange: '保留天数必须在 0-365 天之间(0 = 每次清理时清空所有)', + slaMinPercentRange: 'SLA最低百分比必须在0-100之间', + ttftP99MaxRange: 'TTFT P99最大值必须大于等于0', + requestErrorRateMaxRange: '请求错误率最大值必须在0-100之间', + upstreamErrorRateMaxRange: '上游错误率最大值必须在0-100之间', + openaiQuotaAutoPauseRange: 'OpenAI 配额自动暂停阈值必须在 0-100 之间' + } + }, + concurrency: { + title: '并发 / 排队', + byPlatform: '按平台', + byGroup: '按分组', + byAccount: '按账号', + byUser: '按用户', + showByUserTooltip: '切换用户视图,显示每个用户的并发使用情况', + switchToUser: '切换到用户视图', + switchToPlatform: '切换回平台视图', + totalRows: '共 {count} 项', + disabledHint: '已在设置中关闭实时监控。', + empty: '暂无数据', + queued: '队列 {count}', + rateLimited: '限流 {count}', + errorAccounts: '异常 {count}', + loadFailed: '加载并发数据失败' + }, + realtime: { + title: '实时信息', + connected: '实时已连接', + connecting: '实时连接中', + reconnecting: '实时重连中', + offline: '实时离线', + closed: '实时已关闭', + reconnectIn: '重连 {seconds}s' + }, + queryMode: { + auto: 'Auto(自动)', + raw: 'Raw(不聚合)', + preagg: 'Preagg(聚合)' + }, + accountAvailability: { + available: '可用', + unavailable: '不可用', + accountError: '异常' + }, + tooltips: { + totalRequests: '当前时间窗口内的总请求数和Token消耗量。', + throughputTrend: '当前窗口内的请求/QPS 与 token/TPS 趋势。', + switchRateTrend: '近5小时内账号切换次数 / 请求总数的趋势(平均切换次数)。', + latencyHistogram: '成功请求的请求时长分布(毫秒)。', + errorTrend: '错误趋势(SLA 口径排除业务限制;上游错误率排除 429/529)。', + errorDistribution: '按状态码统计的错误分布(SLA 口径,排除业务限制)。', + upstreamErrors: '上游服务返回的错误,包括API提供商的错误响应(排除429/529限流错误)。', + goroutines: + 'Go 运行时的协程数量(轻量级线程)。没有绝对"安全值",建议以历史基线为准。经验参考:<2000 常见;2000-8000 需关注;>8000 且伴随队列上升时,优先排查阻塞/泄漏。', + cpu: 'CPU 使用率,显示系统处理器的负载情况。', + memory: '内存使用率,包括已使用和总可用内存。', + db: '数据库连接池状态,包括活跃连接、空闲连接和等待连接数。', + redis: 'Redis 连接池状态,显示活跃和空闲的连接数。', + jobs: '后台任务执行状态,包括最近运行时间、成功时间和错误信息。', + qps: '每秒查询数(QPS)和每秒Token数(TPS),实时显示系统吞吐量。', + tokens: '当前时间窗口内处理的总Token数量。', + sla: '服务等级协议达成率,排除业务限制(如余额不足、配额超限)的成功请求占比。', + errors: '错误统计,包括总错误数、错误率和上游错误率。', + latency: '请求时长统计,包括 p50、p90、p95、p99 等百分位数。', + ttft: '首 Token 延迟(Time To First Token),衡量流式响应的首 Token 返回速度。', + health: '系统健康评分(0-100),综合考虑 SLA、错误率和资源使用情况。' + }, + charts: { + emptyRequest: '该时间窗口内暂无请求。', + emptyError: '该时间窗口内暂无错误。', + resetZoom: '重置', + resetZoomHint: '重置缩放(若启用)', + downloadChart: '下载', + downloadChartHint: '下载图表图片' + } + }, + + // Settings +} diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts new file mode 100644 index 0000000000..c15cc52990 --- /dev/null +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -0,0 +1,1021 @@ +export default { + // Dashboard + dashboard: { + title: '管理控制台', + description: '系统概览与统计数据', + apiKeys: 'API 密钥', + totalApiKeys: 'API 密钥总数', + activeApiKeys: '活跃密钥', + users: '用户', + totalUsers: '用户总数', + activeUsers: '活跃用户', + accounts: '账号', + totalAccounts: '账号总数', + activeAccounts: '活跃账号', + todayRequests: '今日请求', + totalRequests: '总请求数', + todayCost: '今日消费', + totalCost: '总消费', + actual: '实际', + standard: '标准', + accountCost: '成本', + todayTokens: '今日 Token', + totalTokens: '总 Token', + input: '输入', + output: '输出', + cacheToday: '今日缓存', + performance: '性能指标', + avgResponse: '平均响应', + averageTime: '平均时间', + timeRange: '时间范围', + granularity: '粒度', + day: '按天', + hour: '按小时', + modelDistribution: '模型分布', + groupDistribution: '分组使用分布', + metricTokens: '按 Token', + metricActualCost: '按实际消费', + tokenUsageTrend: 'Token 使用趋势', + noDataAvailable: '暂无数据', + model: '模型', + group: '分组', + noGroup: '无分组', + requests: '请求', + tokens: 'Token', + cache: '缓存', + recentUsage: '最近使用', + viewModelDistribution: '模型分布', + viewSpendingRanking: '用户消费榜', + spendingRankingTitle: '用户消费榜', + spendingRankingUser: '用户', + spendingRankingRequests: '请求', + spendingRankingTokens: 'Token', + spendingRankingSpend: '消费', + spendingRankingOther: '其他', + spendingRankingUsage: '用量', + spendShort: '消费', + requestsShort: '请求', + tokensShort: 'Token', + last7Days: '近 7 天', + noUsageRecords: '暂无使用记录', + startUsingApi: '开始使用 API 后,使用历史将显示在这里。', + viewAllUsage: '查看全部', + quickActions: '快捷操作', + manageUsers: '管理用户', + viewUserAccounts: '查看和管理用户账户', + manageAccounts: '管理账号', + configureAiAccounts: '配置 AI 平台账号', + batchImage: '批量生图', + batchImageDesc: '提交任务、复制 Agent 调用说明', + groupPricing: '分组定价', + groupPricingDesc: '设置批量折扣和冻结比例', + systemSettings: '系统设置', + configureSystem: '配置系统设置', + failedToLoad: '加载仪表盘数据失败' + }, + + backup: { + title: '数据库备份', + description: '全量数据库备份到 S3 兼容存储,支持定时备份与恢复', + s3: { + title: 'S3 存储配置', + description: '配置 S3 兼容存储(支持 Cloudflare R2)', + descriptionPrefix: '配置 S3 兼容存储(支持', + descriptionSuffix: ')', + enabled: '启用 S3 存储', + endpoint: '端点地址', + region: '区域', + bucket: '存储桶', + prefix: 'Key 前缀', + accessKeyId: 'Access Key ID', + secretAccessKey: 'Secret Access Key', + secretConfigured: '已配置,留空保持不变', + forcePathStyle: '强制路径风格', + testConnection: '测试连接', + testSuccess: 'S3 连接测试成功', + testFailed: 'S3 连接测试失败', + saved: 'S3 配置已保存' + }, + schedule: { + title: '定时备份', + description: '配置自动定时备份', + enabled: '启用定时备份', + cronExpr: 'Cron 表达式', + cronHint: '例如 "0 2 * * *" 表示每天凌晨 2 点', + retainDays: '备份过期天数', + retainDaysHint: '备份文件超过此天数后自动删除,0 = 永不过期', + retainCount: '最大保留份数', + retainCountHint: '最多保留的备份数量,0 = 不限制', + saved: '定时备份配置已保存' + }, + operations: { + title: '备份记录', + description: '创建手动备份和管理已有备份记录', + createBackup: '创建备份', + backing: '备份中...', + backupCreated: '备份创建成功', + expireDays: '过期天数', + alreadyInProgress: '已有备份正在进行中', + backupRunning: '备份进行中...', + backupFailed: '备份失败', + restoreRunning: '恢复进行中...', + restoreFailed: '恢复失败', + }, + columns: { + status: '状态', + fileName: '文件名', + size: '大小', + expiresAt: '过期时间', + triggeredBy: '触发方式', + startedAt: '开始时间', + actions: '操作' + }, + status: { + pending: '等待中', + running: '执行中', + completed: '已完成', + failed: '失败' + }, + progress: { + pending: '准备中', + dumping: '导出数据库', + uploading: '上传中', + }, + trigger: { + manual: '手动', + scheduled: '定时' + }, + neverExpire: '永不过期', + empty: '暂无备份记录', + actions: { + download: '下载', + restore: '恢复', + restoreConfirm: '确定要从此备份恢复吗?这将覆盖当前数据库!', + restorePasswordPrompt: '请输入管理员密码以确认恢复操作', + restoreSuccess: '数据库恢复成功', + deleteConfirm: '确定要删除此备份吗?', + deleted: '备份已删除' + }, + r2Guide: { + title: 'Cloudflare R2 配置教程', + intro: 'Cloudflare R2 提供 S3 兼容的对象存储,免费额度为 10GB 存储 + 每月 100 万次 A 类请求,非常适合数据库备份。', + step1: { + title: '创建 R2 存储桶', + line1: '登录 Cloudflare Dashboard (dash.cloudflare.com),左侧菜单选择「R2 对象存储」', + line2: '点击「创建存储桶」,输入名称(如 sub2api-backups),选择区域', + line3: '点击创建完成' + }, + step2: { + title: '创建 API 令牌', + line1: '在 R2 页面,点击右上角「管理 R2 API 令牌」', + line2: '点击「创建 API 令牌」,权限选择「对象读和写」', + line3: '建议指定存储桶范围(仅允许访问备份桶,更安全)', + line4: '创建后会显示 Access Key ID 和 Secret Access Key', + warning: 'Secret Access Key 只会显示一次,请立即复制保存!' + }, + step3: { + title: '获取 S3 端点地址', + desc: '在 R2 概览页面找到你的账户 ID(在 URL 或右侧面板中),端点格式为:', + accountId: '你的账户 ID' + }, + step4: { + title: '填写以下配置', + checkEnabled: '勾选', + bucketValue: '你创建的存储桶名称', + fromStep2: '第 2 步获取的值', + unchecked: '不勾选' + }, + freeTier: 'R2 免费额度:10GB 存储 + 每月 100 万次 A 类请求 + 1000 万次 B 类请求,对数据库备份完全够用。' + } + }, + + dataManagement: { + title: '数据管理', + description: '统一管理数据管理代理状态、对象存储配置和备份任务', + agent: { + title: '数据管理代理状态', + description: '系统会自动探测固定 Unix Socket,仅在可连通时启用数据管理功能。', + enabled: '数据管理代理已就绪,可继续进行数据管理操作。', + disabled: '数据管理代理不可用,当前仅可查看诊断信息。', + socketPath: 'Socket 路径', + version: '版本', + status: '状态', + uptime: '运行时长', + reasonLabel: '不可用原因', + reason: { + DATA_MANAGEMENT_AGENT_SOCKET_MISSING: '未检测到数据管理 Socket 文件', + DATA_MANAGEMENT_AGENT_UNAVAILABLE: '数据管理代理不可连通', + BACKUP_AGENT_SOCKET_MISSING: '未检测到备份 Socket 文件', + BACKUP_AGENT_UNAVAILABLE: '备份代理不可连通', + UNKNOWN: '未知原因' + } + }, + sections: { + config: { + title: '备份配置', + description: '配置备份源、保留策略与 S3 存储参数。' + }, + s3: { + title: 'S3 对象存储', + description: '配置并测试备份产物上传到标准 S3 对象存储。' + }, + backup: { + title: '备份操作', + description: '触发 PostgreSQL、Redis 与全量备份任务。' + }, + history: { + title: '备份历史', + description: '查看备份任务执行状态、错误与产物信息。' + } + }, + form: { + sourceMode: '源模式', + backupRoot: '备份根目录', + activePostgresProfile: '当前激活 PostgreSQL 配置', + activeRedisProfile: '当前激活 Redis 配置', + activeS3Profile: '当前激活 S3 账号', + retentionDays: '保留天数', + keepLast: '至少保留最近任务数', + uploadToS3: '上传到 S3', + useActivePostgresProfile: '使用当前激活 PostgreSQL 配置', + useActiveRedisProfile: '使用当前激活 Redis 配置', + useActiveS3Profile: '使用当前激活账号', + idempotencyKey: '幂等键(可选)', + secretConfigured: '已配置,留空不变', + source: { + profileID: '配置 ID(唯一)', + profileName: '配置名称', + setActive: '创建后立即设为激活配置' + }, + postgres: { + title: 'PostgreSQL', + host: '主机', + port: '端口', + user: '用户名', + password: '密码', + database: '数据库', + sslMode: 'SSL 模式', + containerName: '容器名(docker_exec 模式)' + }, + redis: { + title: 'Redis', + addr: '地址(host:port)', + username: '用户名', + password: '密码', + db: '数据库编号', + containerName: '容器名(docker_exec 模式)' + }, + s3: { + enabled: '启用 S3 上传', + profileID: '账号 ID(唯一)', + profileName: '账号名称', + endpoint: 'Endpoint(可选)', + region: 'Region', + bucket: 'Bucket', + accessKeyID: 'Access Key ID', + secretAccessKey: 'Secret Access Key', + prefix: '对象前缀', + forcePathStyle: '强制 path-style', + useSSL: '使用 SSL', + setActive: '创建后立即设为激活账号' + } + }, + sourceProfiles: { + createTitle: '创建数据源配置', + editTitle: '编辑数据源配置', + empty: '暂无配置,请先创建', + deleteConfirm: '确定删除配置 {profileID} 吗?', + columns: { + profile: '配置', + active: '激活状态', + connection: '连接信息', + database: '数据库', + updatedAt: '更新时间', + actions: '操作' + } + }, + s3Profiles: { + createTitle: '创建 S3 账号', + editTitle: '编辑 S3 账号', + empty: '暂无 S3 账号,请先创建', + editHint: '点击“编辑”将在右侧抽屉中修改账号信息。', + deleteConfirm: '确定删除 S3 账号 {profileID} 吗?', + columns: { + profile: '账号', + active: '激活状态', + storage: '存储配置', + updatedAt: '更新时间', + actions: '操作' + } + }, + history: { + total: '共 {count} 条', + empty: '暂无备份任务', + columns: { + jobID: '任务 ID', + type: '类型', + status: '状态', + triggeredBy: '触发人', + pgProfile: 'PostgreSQL 配置', + redisProfile: 'Redis 配置', + s3Profile: 'S3 账号', + finishedAt: '完成时间', + artifact: '产物', + error: '错误' + }, + status: { + queued: '排队中', + running: '执行中', + succeeded: '成功', + failed: '失败', + partial_succeeded: '部分成功' + } + }, + actions: { + refresh: '刷新状态', + disabledHint: '请先启动 datamanagementd 并确认 Socket 可连通。', + reloadConfig: '加载配置', + reloadSourceProfiles: '刷新数据源配置', + reloadProfiles: '刷新账号列表', + newSourceProfile: '新建数据源配置', + saveConfig: '保存配置', + configSaved: '配置保存成功', + testS3: '测试 S3 连接', + s3TestOK: 'S3 连接测试成功', + s3TestFailed: 'S3 连接测试失败', + newProfile: '新建账号', + saveProfile: '保存账号', + activateProfile: '设为激活', + profileIDRequired: '请输入账号 ID', + profileNameRequired: '请输入账号名称', + profileSelectRequired: '请先选择要编辑的账号', + profileCreated: 'S3 账号创建成功', + profileSaved: 'S3 账号保存成功', + profileActivated: 'S3 账号已切换为激活', + profileDeleted: 'S3 账号删除成功', + sourceProfileCreated: '数据源配置创建成功', + sourceProfileSaved: '数据源配置保存成功', + sourceProfileActivated: '数据源配置已切换为激活', + sourceProfileDeleted: '数据源配置删除成功', + createBackup: '创建备份任务', + jobCreated: '备份任务已创建:{jobID}({status})', + refreshJobs: '刷新任务', + loadMore: '加载更多' + } + }, + + affiliates: { + invitesDescription: '查看全站邀请关系和被邀请用户累计返利', + rebatesDescription: '查看每一笔产生返利的充值订单', + transfersDescription: '查看返利额度转入账户余额的提取流水', + errors: { + loadFailed: '加载邀请返利记录失败' + }, + records: { + search: '搜索', + searchPlaceholder: '邮箱、用户名、用户 ID、订单号', + startAt: '开始日期', + endAt: '结束日期', + inviter: '邀请人', + invitee: '被邀请人', + user: '用户', + affCode: '邀请码', + order: '订单', + totalRebate: '累计返利', + orderAmount: '充值金额', + payAmount: '支付金额', + rebateAmount: '返利金额', + paymentType: '支付方式', + orderStatus: '订单状态', + transferAmount: '提取金额', + balanceAfter: '提取后余额', + availableQuotaAfter: '提取后可提', + frozenQuotaAfter: '提取后冻结', + historyQuotaAfter: '提取后历史返利', + invitedAt: '邀请时间', + rebatedAt: '返利时间', + transferredAt: '提取时间' + }, + overview: { + title: '用户返利概览', + affCode: '邀请码', + rebateRate: '返利比例', + invitedCount: '邀请人数', + rebatedInviteeCount: '已产生返利人数', + availableQuota: '可提余额', + historyQuota: '历史返利' + } + }, + + // Users Management + users: { + title: '用户管理', + description: '管理用户账户和权限', + createUser: '创建用户', + editUser: '编辑用户', + deleteUser: '删除用户', + deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。", + searchPlaceholder: '邮箱/用户名/备注/API Key 模糊搜索...', + searchUsers: '邮箱/用户名/备注/API Key 模糊搜索', + roleFilter: '角色筛选', + allRoles: '全部角色', + allStatus: '全部状态', + allGroups: '全部分组', + searchGroups: '搜索分组...', + fuzzySearch: '模糊搜索', + apiKeyGroupFilter: 'API Key 分组', + apiKeyGroupExclusive: '专用分组', + apiKeyGroupPublic: '公开分组', + apiKeyGroupSubscription: '订阅分组', + apiKeyGroupDisabled: '已禁用分组', + authorizedGroupFilter: '授权分组', + allAuthorizedGroups: '全部授权分组', + searchAuthorizedGroups: '搜索授权分组...', + allApiKeyGroups: '全部 API Key 分组', + searchApiKeyGroups: '搜索 API Key 分组...', + statusFilter: '状态筛选', + allStatuses: '全部状态', + admin: '管理员', + user: '用户', + disabled: '禁用', + email: '邮箱', + password: '密码', + username: '用户名', + notes: '备注', + enterEmail: '请输入邮箱', + enterPassword: '请输入密码', + enterUsername: '请输入用户名(选填)', + enterNotes: '请输入备注(仅管理员可见)', + notesHint: '此备注仅对管理员可见', + enterNewPassword: '请输入新密码(选填)', + leaveEmptyToKeep: '留空则保持原密码不变', + generatePassword: '生成随机密码', + copyPassword: '复制密码', + creating: '创建中...', + updating: '更新中...', + columns: { + user: '用户', + id: 'ID', + email: '邮箱', + username: '用户名', + notes: '备注', + role: '角色', + groups: '分组', + subscriptions: '订阅分组', + balance: '余额', + balancePlatformQuota: '余额(平台配额)', + usage: '用量', + usageAnthropic: '用量 (Claude)', + usageOpenAI: '用量 (OpenAI)', + usageGemini: '用量 (Gemini)', + usageAntigravity: '用量 (Antigravity)', + concurrency: '并发数', + status: '状态', + lastActive: '最后活跃时间', + lastUsed: '最后使用时间', + created: '创建时间', + actions: '操作' + }, + today: '今日', + total: '近30天', + sortBy: '排序方式', + sortCurrentPageOnly: '仅对本页数据排序', + noSubscription: '暂无订阅', + publicGroupCount: '+{count} 公开', + exclusiveLabel: '专属', + publicLabel: '公开', + daysRemaining: '{days}天', + expired: '已过期', + disable: '禁用', + enable: '启用', + disableUser: '禁用用户', + enableUser: '启用用户', + viewApiKeys: '查看 API 密钥', + groups: '分组', + apiKeys: 'API密钥', + userApiKeys: '用户 API 密钥', + noApiKeys: '此用户暂无 API 密钥', + group: '分组', + none: '无', + groupChangedSuccess: '分组修改成功', + groupChangedWithGrant: '分组修改成功,已自动为用户添加「{group}」分组权限', + groupChangeFailed: '分组修改失败', + noUsersYet: '暂无用户', + createFirstUser: '创建您的第一个用户以开始使用系统', + userCreated: '用户创建成功', + userUpdated: '用户更新成功', + userDeleted: '用户删除成功', + userEnabled: '用户已启用', + userDisabled: '用户已禁用', + failedToLoad: '加载用户列表失败', + failedToCreate: '创建用户失败', + failedToUpdate: '更新用户失败', + failedToDelete: '删除用户失败', + failedToToggle: '更新用户状态失败', + failedToLoadApiKeys: '加载用户 API 密钥失败', + deleteConfirm: "确定要删除用户 '{email}' 吗?此操作无法撤销。", + roles: { + admin: '管理员', + user: '用户' + }, + form: { + emailLabel: '邮箱', + emailPlaceholder: '请输入邮箱', + usernameLabel: '用户名', + usernamePlaceholder: '请输入用户名(选填)', + notesLabel: '备注', + notesPlaceholder: '请输入备注(仅管理员可见)', + notesHint: '此备注仅对管理员可见', + passwordLabel: '密码', + passwordPlaceholder: '请输入密码(留空则不修改)', + roleLabel: '角色', + selectRole: '选择角色', + balanceLabel: '余额', + concurrencyLabel: '并发数', + statusLabel: '状态', + selectStatus: '选择状态', + rpmLimit: '每分钟请求数 (RPM)', + rpmLimitPlaceholder: '0 表示不限制', + rpmLimitHint: '该用户每分钟最大请求数,0 = 不限制;仅在所用分组未设置 rpm_limit 时作为兜底生效' + }, + adjustBalance: '调整余额', + adjustConcurrency: '调整并发数', + adjustmentAmount: '调整金额', + adjustmentAmountHint: '正数增加,负数减少', + currentBalance: '当前余额', + currentConcurrency: '当前并发数', + saving: '保存中...', + noUsers: '暂无用户', + noUsersDescription: '创建您的第一个用户以开始使用系统。', + userCreatedSuccess: '用户创建成功', + userUpdatedSuccess: '用户更新成功', + userDeletedSuccess: '用户删除成功', + balanceAdjustedSuccess: '余额调整成功', + concurrencyAdjustedSuccess: '并发数调整成功', + failedToSave: '保存用户失败', + failedToAdjust: '调整失败', + emailRequired: '请输入邮箱', + concurrencyMin: '并发数不能小于1', + soraStorageQuota: 'Sora 存储配额', + soraStorageQuotaHint: '单位 GB,0 表示使用分组或系统默认配额', + amountRequired: '请输入有效金额', + insufficientBalance: '余额不足', + setAllowedGroups: '设置允许分组', + allowedGroupsHint: '选择此用户可以使用的标准分组。订阅类型分组请在订阅管理中配置。', + noStandardGroups: '暂无标准分组', + allowAllGroups: '允许全部分组', + allowAllGroupsHint: '用户可以使用任何非专属分组', + allowedGroupsUpdated: '允许分组更新成功', + failedToLoadGroups: '加载分组列表失败', + failedToUpdateAllowedGroups: '更新允许分组失败', + // 用户分组配置 + groupConfig: '用户分组配置', + groupConfigHint: '为用户 {email} 配置专属分组倍率(覆盖分组默认倍率)', + exclusiveGroups: '专属分组', + publicGroups: '公开分组(默认可用)', + defaultRate: '默认倍率', + customRate: '专属倍率', + useDefaultRate: '使用默认', + customRatePlaceholder: '留空使用默认', + groupConfigUpdated: '分组配置更新成功', + replaceGroup: '替换分组', + clickToReplace: '点击替换分组', + replaceGroupTitle: '替换专属分组', + replaceGroupHint: '选择新分组替换「{old}」,将自动迁移绑定的 Key 并更新分组权限', + replaceGroupConfirm: '确认替换', + replaceGroupSuccess: '分组替换成功,已迁移 {count} 个 Key', + selectNewGroup: '请选择目标分组', + noOtherGroups: '没有其他可用的专属分组', + deposit: '充值', + withdraw: '退款', + depositAmount: '充值金额', + withdrawAmount: '退款金额', + withdrawAll: '全部', + depositNotesPlaceholder: '例如:新用户注册奖励、活动充值、补偿充值等', + withdrawNotesPlaceholder: '例如:服务问题退款、错误充值退回、账户注销退款等', + notesOptional: '备注为可选项,有助于未来查账', + amountHint: '请输入正数金额', + newBalance: '操作后余额', + depositing: '充值中...', + withdrawing: '退款中...', + confirmDeposit: '确认充值', + confirmWithdraw: '确认退款', + depositSuccess: '充值成功', + withdrawSuccess: '退款成功', + failedToDeposit: '充值失败', + failedToWithdraw: '退款失败', + useDepositWithdrawButtons: '请使用充值/退款按钮调整余额', + // 余额变动记录 + balanceHistory: '充值记录', + balanceHistoryTip: '点击查看充值记录', + columnAlwaysVisible: '该列固定显示,不可隐藏', + // 平台用量明细(悬浮显示) + platformBreakdown: '按平台拆分', + platformBreakdownEmpty: '暂无平台明细', + platformBreakdownHint: '悬浮查看各平台用量', + platformOther: '其他', + balanceHistoryTitle: '用户充值和并发变动记录', + noBalanceHistory: '暂无变动记录', + allTypes: '全部类型', + typeBalance: '余额(兑换码)', + typeAffiliateBalance: '余额(返利转入)', + typeAdminBalance: '余额(管理员调整)', + typeConcurrency: '并发(兑换码)', + typeAdminConcurrency: '并发(管理员调整)', + typeSubscription: '订阅', + failedToLoadBalanceHistory: '加载余额记录失败', + createdAt: '创建时间', + totalRecharged: '总充值', + // Settings Dropdowns + filterSettings: '筛选设置', + columnSettings: '列设置', + filterValue: '输入值', + // User Attributes + attributes: { + title: '用户属性配置', + description: '配置用户的自定义属性字段', + configButton: '属性配置', + addAttribute: '添加属性', + editAttribute: '编辑属性', + deleteAttribute: '删除属性', + deleteConfirm: "确定要删除属性 '{name}' 吗?所有用户的该属性值将被删除。", + noAttributes: '暂无自定义属性', + noAttributesHint: '点击上方按钮添加自定义属性', + key: '属性键', + keyHint: '用于程序引用,只能包含字母、数字和下划线', + name: '显示名称', + nameHint: '在表单中显示的名称', + type: '属性类型', + fieldDescription: '描述', + fieldDescriptionHint: '属性的说明文字', + placeholder: '占位符', + placeholderHint: '输入框的提示文字', + required: '必填', + enabled: '启用', + options: '选项配置', + optionsHint: '用于单选/多选类型', + addOption: '添加选项', + optionValue: '选项值', + optionLabel: '显示文本', + validation: '验证规则', + minLength: '最小长度', + maxLength: '最大长度', + min: '最小值', + max: '最大值', + pattern: '正则表达式', + patternMessage: '验证失败提示', + types: { + text: '单行文本', + textarea: '多行文本', + number: '数字', + email: '邮箱', + url: '链接', + date: '日期', + select: '单选', + multi_select: '多选' + }, + created: '属性创建成功', + updated: '属性更新成功', + deleted: '属性删除成功', + reordered: '属性排序更新成功', + failedToLoad: '加载属性列表失败', + failedToCreate: '创建属性失败', + failedToUpdate: '更新属性失败', + keyRequired: '请输入属性键', + nameRequired: '请输入显示名称', + optionsRequired: '请至少添加一个选项', + failedToDelete: '删除属性失败', + failedToReorder: '更新排序失败', + keyExists: '属性键已存在', + dragToReorder: '拖拽排序' + }, + platformQuota: { + menuItem: '平台限额', + title: '平台限额', + subtitle: '为用户 {email} 配置各上游平台的日 / 周 / 月用量上限', + columns: { + platform: '平台', + daily: '日 (USD)', + weekly: '周 (USD)', + monthly: '月 (USD, 30天滚动)', + usage: '当前用量', + }, + placeholder: '不限制', + save: '保存', + saving: '保存中...', + cancel: '取消', + clearAll: '全部清空(取消所有限额)', + clearAllConfirm: '确认清空全部平台的日 / 周 / 月限额?所有平台将变为"无限额",本地无法撤销,需要在保存前手动重填。', + reset: { + button: '重置该窗口', + confirm: '确认重置该用户 {platform} 平台的 {window} 用量?此操作立即生效。', + success: '已重置 {platform} {window} 用量', + failed: '重置失败', + }, + updateSuccess: '平台限额已更新', + updateFailed: '保存失败', + loadFailed: '加载失败', + hint: '留空 = 不限制该窗口。', + windowDaily: '日', + windowWeekly: '周', + windowMonthly: '月', + cellNotConfigured: '未配置', + cellColumnTooltip: '仅展示已设限额的平台', + subscriptionWarning: '此用户有活跃订阅,平台限额仅在余额(标准)模式下生效,订阅模式请求不受此限额约束。', + invalidNumber: '以下字段填写不是合法数字,请修正后再保存:{fields}', + } + }, + + // Groups Management + groups: { + title: '分组管理', + description: '管理 API 密钥分组和费率配置', + searchGroups: '搜索分组...', + createGroup: '创建分组', + editGroup: '编辑分组', + deleteGroup: '删除分组', + sortOrder: '排序', + columnSettings: '列设置', + sortOrderHint: '拖拽分组调整显示顺序,排在前面的分组会优先显示', + sortOrderUpdated: '排序已更新', + failedToUpdateSortOrder: '更新排序失败', + deleteConfirm: "确定要删除分组 '{name}' 吗?所有关联的 API 密钥将不再属于任何分组。", + deleteConfirmSubscription: + "确定要删除订阅分组 '{name}' 吗?此操作会让所有绑定此订阅的用户的 API Key 失效,并删除所有相关的订阅记录。此操作无法撤销。", + columns: { + name: '名称', + platform: '平台', + rateMultiplier: '费率倍数', + rpmOverride: 'RPM 覆盖', + rpmOverrideHint: '该用户在此分组的 RPM 上限;留空 = 使用分组默认;0 = 不限制', + rateDefault: '默认', + rpmDefault: '默认', + exclusive: '独占', + type: '类型', + priority: '优先级', + apiKeys: 'API 密钥数', + accounts: '账号数', + capacity: '容量', + usage: '用量', + status: '状态', + actions: '操作', + billingType: '计费类型', + userName: '用户名', + userEmail: '邮箱', + userNotes: '备注', + userStatus: '状态' + }, + usageToday: '今日', + usageTotal: '累计', + accountsAvailable: '可用:', + accountsRateLimited: '限流:', + accountsTotal: '总量:', + accountsUnit: '个账号', + form: { + name: '名称', + description: '描述', + platform: '平台', + rateMultiplier: '费率倍数', + status: '状态', + exclusive: '专属分组', + nameLabel: '分组名称', + namePlaceholder: '请输入分组名称', + descriptionLabel: '描述', + descriptionPlaceholder: '请输入描述(可选)', + rateMultiplierLabel: '费率倍数', + rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍', + rpmLimit: '每分钟请求数 (RPM)', + rpmLimitPlaceholder: '0 表示不限制', + rpmLimitHint: '每用户在本分组每分钟最大请求数,0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit)', + exclusiveLabel: '专属分组', + exclusiveHint: '专属分组,可以手动指定给用户', + platformLabel: '平台限制', + platformPlaceholder: '选择平台(留空则不限制)', + accountsLabel: '指定账号', + accountsPlaceholder: '选择账号(留空则不限制)', + priorityLabel: '优先级', + priorityHint: '数值越小优先级越高,用于账号调度', + statusLabel: '状态' + }, + exclusiveObj: { + yes: '是', + no: '否' + }, + exclusive: '专属', + exclusiveHint: '专属分组,可以手动指定给特定用户', + exclusiveTooltip: { + title: '什么是专属分组?', + description: + '开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。', + example: '使用场景:', + exampleContent: + '公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。' + }, + rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍', + platforms: { + all: '全部平台', + anthropic: 'Anthropic', + openai: 'OpenAI', + gemini: 'Gemini', + antigravity: 'Antigravity', + grok: 'Grok', + }, + saving: '保存中...', + noGroups: '暂无分组', + noGroupsDescription: '创建分组以更好地管理 API 密钥和费率。', + groupCreatedSuccess: '分组创建成功', + groupUpdatedSuccess: '分组更新成功', + groupDeletedSuccess: '分组删除成功', + failedToLoad: '加载分组列表失败', + failedToSave: '保存分组失败', + failedToDelete: '删除分组失败', + allPlatforms: '全部平台', + allStatus: '全部状态', + allGroups: '全部分组', + exclusiveFilter: '专属', + nonExclusive: '公开', + public: '公开', + rateAndAccounts: '{rate}x 费率 · {count} 个账号', + accountsCount: '{count} 个账号', + rateLabel: '倍率', + accountFilters: { + title: '账号过滤控制', + oauthOnly: '仅允许 OAuth 账号', + oauthOnlyEnabled: '已启用 — 排除 API Key 类型账号', + privacySetOnly: '仅允许隐私保护已设置的账号', + privacySetOnlyEnabled: '已启用 — Privacy 未设置的账号将被排除', + disabled: '未启用' + }, + enterGroupName: '请输入分组名称', + optionalDescription: '可选描述', + platformHint: '选择此分组关联的平台', + platformNotEditable: '创建后不可更改平台', + noGroupsYet: '暂无分组', + createFirstGroup: '创建您的第一个分组来组织 API 密钥。', + creating: '创建中...', + updating: '更新中...', + limitDay: '日', + limitWeek: '周', + limitMonth: '月', + groupCreated: '分组创建成功', + groupUpdated: '分组更新成功', + groupDeleted: '分组删除成功', + failedToCreate: '创建分组失败', + failedToUpdate: '更新分组失败', + nameRequired: '请输入分组名称', + rateMultipliers: '专属倍率', + rateMultipliersTitle: '分组专属倍率管理', + addUserRate: '添加用户专属倍率', + rpmOverrides: '专属 RPM', + rpmOverridesTitle: '分组专属 RPM 管理', + addUserRpm: '添加用户专属 RPM', + noRpmOverrides: '暂无用户设置了专属 RPM', + rpmSaved: '专属 RPM 已保存', + groupRpmDefault: '分组默认 RPM', + searchUserPlaceholder: '搜索用户邮箱...', + noRateMultipliers: '暂无用户设置了专属倍率', + rateUpdated: '专属倍率已更新', + rateDeleted: '专属倍率已删除', + rateAdded: '专属倍率已添加', + clearAll: '全部清空', + confirmClearAll: '确定要清空该分组所有用户的专属倍率设置吗?此操作不可撤销。', + rateCleared: '已清空所有专属倍率', + batchAdjust: '批量调整倍率', + multiplierFactor: '乘数', + applyMultiplier: '应用', + rateAdjusted: '倍率已批量调整', + rateSaved: '专属倍率已保存', + finalRate: '最终倍率', + unsavedChanges: '有未保存的修改', + revertChanges: '撤销修改', + userInfo: '用户信息', + subscription: { + title: '订阅设置', + type: '计费类型', + typeHint: '标准计费从用户余额扣除。订阅模式使用配额限制。', + typeNotEditable: '分组创建后无法修改计费类型。', + standard: '标准(余额)', + subscription: '订阅(配额)', + dailyLimit: '每日限额(USD)', + weeklyLimit: '每周限额(USD)', + monthlyLimit: '每月限额(USD)', + defaultValidityDays: '默认有效期(天)', + validityHint: '分配给用户时订阅的有效天数', + noLimit: '无限制' + }, + imagePricing: { + title: '图片生成计费', + description: '配置图片生成能力和图片基础单价,留空则使用默认价格', + allowImageGeneration: '允许当前分组生图', + allowBatchImageGeneration: '允许当前分组批量生图', + independentMultiplier: '生图倍率独立', + imageMultiplier: '生图独立倍率', + batchDiscountMultiplier: '批量生图折扣倍率', + batchHoldMultiplier: '批量冻结价格比例', + batchSectionHint: '批量生图仅影响批量任务:结算价格会叠加批量折扣倍率,提交时冻结金额按普通生图原价 × 批量冻结价格比例计算。参考图也会产生上游输入 token 消耗,建议批量生图折扣倍率设置大于 0.5。', + batchDisabledHint: '请先开启当前分组生图,才能开启批量生图。', + batchGeminiOnlyHint: '批量生图当前仅支持 Gemini 分组。', + modeHint: '默认关闭独立倍率时,图片费用 = 图片价格 × 当前分组有效倍率;开启独立倍率后,图片费用 = 图片价格 × 生图独立倍率。', + finalPricePreview: '最终单张价格预览', + notConfigured: '未配置' + }, + peakRate: { + enable: '启用高峰倍率', + peakStart: '高峰开始', + peakEnd: '高峰结束', + peakMultiplier: '高峰倍率', + multiplierHint: '作用于 token 计费倍率;token 计费的图片 token 同样适用,0 表示高峰 token 请求按 0 倍计费' + }, + modelsList: { + title: '自定义 /v1/models 模型列表', + hint: '仅影响 /v1/models 展示结果,不影响白名单模型调用和账号调度。', + loading: '正在加载模型列表...', + empty: '暂无可展示模型', + selectedSummary: '已选 {selected} / {total}', + selectAll: '全选', + invertSelection: '反选' + }, + claudeCode: { + title: 'Claude Code 客户端限制', + tooltip: + '启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。', + enabled: '仅限 Claude Code', + disabled: '允许所有客户端', + fallbackGroup: '降级分组', + fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝', + noFallback: '不降级(直接拒绝)' + }, + openaiMessages: { + title: 'OpenAI Messages 调度配置', + allowDispatch: '允许 /v1/messages 调度', + allowDispatchHint: '启用后,此 OpenAI 分组的 API Key 可以通过 /v1/messages 端点调度请求', + familyMappingTitle: '系列默认映射', + familyMappingHint: '当请求命中 Opus、Sonnet、Haiku 系列时,会优先使用这里配置的目标模型。', + opusModel: 'Opus 映射模型', + opusModelPlaceholder: '例如: gpt-5.4', + sonnetModel: 'Sonnet 映射模型', + sonnetModelPlaceholder: '例如: gpt-5.3-codex', + haikuModel: 'Haiku 映射模型', + haikuModelPlaceholder: '例如: gpt-5.4-mini', + exactMappingTitle: '精确模型覆盖', + exactMappingHint: '精确 Claude 模型覆盖优先级高于系列默认映射,可将某个具体 Claude 模型单独映射到不同的目标模型。', + noExactMappings: '暂无精确模型覆盖规则', + addExactMapping: '添加精确映射', + claudeModel: 'Claude 模型', + claudeModelPlaceholder: '例如: claude-sonnet-4-5-20250929', + targetModel: '目标模型', + targetModelPlaceholder: '例如: gpt-5.4', + removeExactMapping: '删除精确映射' + }, + invalidRequestFallback: { + title: '无效请求兜底分组', + hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底', + noFallback: '不兜底' + }, + copyAccounts: { + title: '从分组复制账号', + tooltip: '选择一个或多个相同平台的分组,创建后会自动将这些分组的所有账号绑定到新分组(去重)。', + tooltipEdit: '选择一个或多个相同平台的分组,保存后当前分组的账号会被替换为这些分组的账号(去重)。', + selectPlaceholder: '选择分组以复制其账号...', + hint: '可选多个分组,账号会自动去重', + hintEdit: '⚠️ 注意:这会替换当前分组的所有账号绑定' + }, + modelRouting: { + title: '模型路由配置', + tooltip: + '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。', + enabled: '已启用', + disabled: '已禁用', + disabledHint: '启用后,配置的路由规则才会生效', + addRule: '添加路由规则', + modelPattern: '模型模式', + modelPatternPlaceholder: 'claude-opus-*', + modelPatternHint: '支持 * 通配符,如 claude-opus-* 匹配所有 opus 模型', + accounts: '优先账号', + selectAccounts: '选择账号', + noAccounts: '此分组暂无账号', + loadingAccounts: '加载账号中...', + removeRule: '删除规则', + noRules: '暂无路由规则', + noRulesHint: '添加路由规则以将特定模型请求优先路由到指定账号', + searchAccountPlaceholder: '搜索账号...', + accountsHint: '选择此模型模式优先使用的账号' + }, + mcpXml: { + title: 'MCP XML 协议注入', + tooltip: '启用后,当请求包含 MCP 工具时,会在 system prompt 中注入 XML 格式调用协议提示词。关闭此选项可避免对某些客户端造成干扰。', + enabled: '已启用', + disabled: '已禁用' + }, + supportedScopes: { + title: '支持的模型系列', + tooltip: '选择此分组支持的模型系列。未勾选的系列将不会被路由到此分组。', + claude: 'Claude', + geminiText: 'Gemini Text', + geminiImage: 'Gemini Image', + hint: '至少选择一个模型系列' + } + }, + + // Available Channels (aggregated read-only view) +} diff --git a/frontend/src/i18n/locales/zh/admin/resources.ts b/frontend/src/i18n/locales/zh/admin/resources.ts new file mode 100644 index 0000000000..ec4753cf4b --- /dev/null +++ b/frontend/src/i18n/locales/zh/admin/resources.ts @@ -0,0 +1,581 @@ +export default { + scheduledTests: { + title: '定时测试', + addPlan: '添加计划', + editPlan: '编辑计划', + deletePlan: '删除计划', + model: '模型', + cronExpression: 'Cron 表达式', + enabled: '启用', + lastRun: '上次运行', + nextRun: '下次运行', + maxResults: '最大结果数', + noPlans: '暂无定时测试计划', + confirmDelete: '确定要删除此计划吗?', + createSuccess: '计划创建成功', + updateSuccess: '计划更新成功', + deleteSuccess: '计划删除成功', + results: '测试结果', + noResults: '暂无测试结果', + responseText: '响应', + errorMessage: '错误', + success: '成功', + failed: '失败', + running: '运行中', + schedule: '定时测试', + cronHelp: '标准 5 字段 cron 表达式(例如 */30 * * * *)', + cronTooltipTitle: 'Cron 表达式示例:', + cronTooltipMeaning: '用于定义自动执行测试的时间规则,格式依次为:分钟 小时 日 月 星期。', + cronTooltipExampleEvery30Min: '*/30 * * * *:每 30 分钟运行一次', + cronTooltipExampleHourly: '0 * * * *:每小时整点运行一次', + cronTooltipExampleDaily: '0 9 * * *:每天 09:00 运行一次', + cronTooltipExampleWeekly: '0 9 * * 1:每周一 09:00 运行一次', + cronTooltipRange: '推荐填写范围:使用标准 5 字段 cron;如果只是健康检查,建议从每 30 分钟、每 1 小时或每天固定时间开始,不建议一开始就设置过高频率。', + maxResultsTooltipTitle: '最大结果数说明:', + maxResultsTooltipMeaning: '用于限制单个计划最多保留多少条历史测试结果,避免结果列表无限增长。', + maxResultsTooltipBody: '系统只会保留最近的测试结果;当保存数量超过这个值时,更早的历史记录会自动清理,避免列表过长和存储持续增长。', + maxResultsTooltipExample: '例如填写 100,表示最多保存最近 100 次测试结果;第 101 次结果写入后,最早的一条会被清理。', + maxResultsTooltipRange: '推荐填写范围:一般可填 20 到 200。只关注近期可用性时可填 20-50;需要回看较长时间的波动趋势时可填 100-200。', + autoRecover: '自动恢复', + autoRecoverHelp: '测试成功后自动恢复异常状态的账号' + }, + + // Proxies Management + proxies: { + title: 'IP管理', + description: '管理代理服务器配置', + createProxy: '添加代理', + editProxy: '编辑代理', + deleteProxy: '删除代理', + ad: { + inline: '正在寻找合适的代理 IP?' + }, + deleteConfirmMessage: "确定要删除代理 '{name}' 吗?", + testProxy: '测试代理', + dataImport: '导入', + dataExportSelected: '导出选中', + dataImportTitle: '导入代理', + dataImportHint: '上传代理导出的 JSON 文件以批量导入代理。', + dataImportWarning: '导入将创建或复用代理,保留状态并在完成后自动触发延迟检测。', + dataImportFile: '数据文件', + dataImportButton: '开始导入', + dataImporting: '导入中...', + dataImportSelectFile: '请选择数据文件', + dataImportParseFailed: '数据解析失败', + dataImportFailed: '数据导入失败', + dataImportResult: '导入结果', + dataImportResultSummary: '创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed}', + dataImportErrors: '失败详情', + dataImportSuccess: '导入完成:创建 {proxy_created},复用 {proxy_reused}', + dataImportCompletedWithErrors: '导入完成但有错误:失败 {proxy_failed}', + dataExport: '导出', + dataExportConfirmMessage: '导出的数据包含代理的敏感信息,请妥善保存。', + dataExportConfirm: '确认导出', + dataExported: '数据导出成功', + dataExportFailed: '数据导出失败', + columns: { + name: '名称', + protocol: '协议', + address: '地址', + auth: '认证', + location: '地理位置', + status: '状态', + accounts: '账号数', + latency: '延迟', + expiry: '有效期', + createdAt: '创建时间', + actions: '操作', + nameLabel: '名称', + namePlaceholder: '请输入代理名称', + protocolLabel: '协议', + selectProtocol: '选择协议', + hostLabel: '主机', + hostPlaceholder: '请输入主机地址', + portLabel: '端口', + portPlaceholder: '请输入端口', + usernameLabel: '用户名(可选)', + usernamePlaceholder: '请输入用户名', + passwordLabel: '密码(可选)', + passwordPlaceholder: '请输入密码', + priorityLabel: '优先级', + statusLabel: '状态' + }, + filters: { + protocol: '协议', + allProtocols: '全部协议', + status: '状态', + allStatuses: '全部状态' + }, + // Additional keys used in ProxiesView + copyProxyUrl: '复制代理 URL', + urlCopied: '代理 URL 已复制', + allProtocols: '全部协议', + allStatus: '全部状态', + searchProxies: '搜索代理...', + protocols: { + http: 'HTTP', + https: 'HTTPS', + socks5: 'SOCKS5', + socks5h: 'SOCKS5H (远程 DNS)', + }, + name: '名称', + protocol: '协议', + host: '主机', + port: '端口', + username: '用户名(可选)', + password: '密码(可选)', + status: '状态', + enterProxyName: '请输入代理名称', + optionalAuth: '可选认证信息', + leaveEmptyToKeep: '留空保持不变', + form: { + hostPlaceholder: '请输入主机地址', + portPlaceholder: '请输入端口' + }, + noProxiesYet: '暂无代理', + createFirstProxy: '添加您的第一个代理以开始使用。', + testConnection: '测试连接', + qualityCheck: '质量检测', + batchQualityCheck: '批量质量检测', + batchTest: '批量测试', + testFailed: '失败', + latencyFailed: '链接失败', + batchTestEmpty: '暂无可测试的代理', + batchTestDone: '批量测试完成,共测试 {count} 个代理', + batchTestFailed: '批量测试失败', + batchDeleteAction: '删除', + batchDelete: '批量删除', + batchDeleteConfirm: '确定删除选中的 {count} 个代理吗?已被账号使用的将自动跳过。', + batchDeleteDone: '已删除 {deleted} 个代理,跳过 {skipped} 个', + batchDeleteSkipped: '已跳过 {skipped} 个代理', + batchDeleteFailed: '批量删除失败', + deleteBlockedInUse: '该代理已有账号使用,无法删除', + accountsTitle: '使用该IP的账号', + accountsEmpty: '暂无账号使用此代理', + accountsFailed: '获取账号列表失败', + accountName: '账号名称', + accountPlatform: '所属平台', + accountNotes: '备注', + // Batch import + standardAdd: '标准添加', + batchAdd: '快捷添加', + batchInput: '代理列表', + batchInputPlaceholder: + "每行输入一个代理,支持以下格式:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443", + batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口", + parsedCount: '有效 {count} 个', + invalidCount: '无效 {count} 个', + duplicateCount: '重复 {count} 个', + importing: '导入中...', + importProxies: '导入 {count} 个代理', + batchImportSuccess: '成功导入 {created} 个代理,跳过 {skipped} 个重复', + batchImportAllSkipped: '全部 {skipped} 个代理已存在,跳过导入', + failedToImport: '批量导入失败', + // Other messages + saving: '保存中...', + testing: '测试中...', + creating: '创建中...', + updating: '更新中...', + noProxies: '暂无代理', + noProxiesDescription: '添加代理服务器以增强 API 访问稳定性。', + proxyCreated: '代理添加成功', + proxyUpdated: '代理更新成功', + proxyDeleted: '代理删除成功', + proxyWorking: '代理连接正常', + proxyWorkingWithLatency: '代理连接正常,延迟 {latency}ms', + proxyTestFailed: '代理测试失败', + qualityCheckDone: '质量检测完成:评分 {score}({grade})', + qualityCheckFailed: '代理质量检测失败', + batchQualityDone: '批量质量检测完成,共检测 {count} 个;优质 {healthy} 个,告警 {warn} 个,挑战 {challenge} 个,异常 {failed} 个', + batchQualityFailed: '批量质量检测失败', + batchQualityEmpty: '暂无可检测质量的代理', + qualityReportTitle: '代理质量检测报告', + qualityGrade: '等级 {grade}', + qualityExitIP: '出口 IP', + qualityCountry: '出口地区', + qualityBaseLatency: '基础延迟', + qualityCheckedAt: '检测时间', + qualityTableTarget: '检测项', + qualityTableStatus: '状态', + qualityTableLatency: '延迟', + qualityTableMessage: '说明', + qualityInline: '质量 {grade}/{score}', + qualityStatusHealthy: '优质', + qualityStatusPass: '通过', + qualityStatusWarn: '告警', + qualityStatusFail: '失败', + qualityStatusChallenge: '挑战', + qualityTargetBase: '基础连通性', + proxyCreatedSuccess: '代理添加成功', + proxyUpdatedSuccess: '代理更新成功', + proxyDeletedSuccess: '代理删除成功', + testSuccess: '代理测试通过', + failedToLoad: '加载代理列表失败', + failedToSave: '保存代理失败', + failedToDelete: '删除代理失败', + failedToCreate: '创建代理失败', + failedToUpdate: '更新代理失败', + failedToTest: '测试代理失败', + nameRequired: '请输入代理名称', + hostRequired: '请输入主机地址', + portInvalid: '端口必须在 1-65535 之间', + deleteConfirm: "确定要删除代理 '{name}' 吗?使用此代理的账号将被移除代理设置。", + neverExpires: '永不过期', + expired: '已过期', + overdueDays: '已超期 {days} 天', + expiringInDays: '{days} 天后到期', + remainingDays: '剩余 {days} 天', + expiresAt: '有效期', + nDays: '{days} 天', + expiryDaysPlaceholder: '自定义天数,留空 = 永不过期', + expiryWarnDays: '到期提醒提前天数', + fallbackMode: '失败回退', + fallbackNone: '不回退', + fallbackProxy: '指定备用代理', + fallbackDirect: '回退直连', + backupProxy: '备用代理', + }, + + // Redeem Codes Management + redeem: { + title: '兑换码管理', + description: '生成和管理兑换码', + generateCodes: '生成兑换码', + columns: { + code: '兑换码', + type: '类型', + value: '面值', + status: '状态', + usedBy: '使用者', + usedAt: '使用时间', + expiresAt: '过期时间', + createdAt: '创建时间', + actions: '操作' + }, + types: { + balance: '余额', + concurrency: '并发数', + subscription: '订阅', + invitation: '邀请码', + // 管理员在用户管理页面调整余额/并发时产生的记录 + admin_balance: '余额(管理员)', + admin_concurrency: '并发数(管理员)' + }, + // 用于选择器和筛选器的直接键 + balance: '余额', + concurrency: '并发数', + subscription: '订阅', + invitation: '邀请码', + invitationHint: '邀请码用于限制用户注册,使用后自动标记为已使用。', + allTypes: '全部类型', + allStatus: '全部状态', + unused: '未使用', + used: '已使用', + searchCodes: '搜索兑换码或邮箱...', + exportCsv: '导出 CSV', + batchUpdate: '批量修改', + batchUpdateTitle: '批量修改兑换码', + selectedCount: '已选择 {count} 个兑换码', + clearSelection: '清空选择', + selectCodesFirst: '请先选择兑换码', + noBatchFieldsSelected: '请至少勾选一个要修改的字段', + batchUpdateSuccess: '成功修改 {count} 个兑换码', + failedToBatchUpdate: '批量修改兑换码失败', + batchFields: { + status: '状态', + expiresAt: '过期时间', + notes: '备注', + group: '分组' + }, + batchNotesPlaceholder: '输入新的备注,留空可清空备注', + clearGroup: '清空分组', + deleteAllUnused: '删除全部未使用', + deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。', + deleteAllUnusedConfirm: '确定要删除全部未使用的兑换码吗?此操作无法撤销。', + deleteAll: '全部删除', + generateCodesTitle: '生成兑换码', + generatedSuccessfully: '生成成功', + codesCreated: '已创建 {count} 个兑换码', + codeType: '类型', + amount: '金额 ($)', + value: '面值', + count: '数量', + generate: '生成', + copyAll: '全部复制', + download: '下载', + codesExported: '兑换码导出成功', + codeDeleted: '兑换码删除成功', + codesDeleted: '成功删除 {count} 个未使用的兑换码', + noUnusedCodes: '没有未使用的兑换码可删除', + userPrefix: '用户 #{id}', + failedToExport: '导出兑换码失败', + failedToDeleteUnused: '删除未使用的兑换码失败', + failedToCopy: '复制失败', + selectGroup: '选择分组', + selectGroupPlaceholder: '选择订阅分组', + validityDays: '有效天数', + codeExpiry: '兑换码过期', + neverExpires: '永不过期', + expiryPresetDays: '{days} 天', + customExpiry: '自定义', + customExpiryDays: '自定义天数', + expiryDaysRequired: '请输入有效的过期天数', + groupRequired: '请选择订阅分组', + days: '天', + status: { + unused: '未使用', + used: '已使用', + expired: '已过期', + disabled: '已禁用' + }, + form: { + typeLabel: '类型', + selectType: '选择类型', + valueLabel: '面值', + valuePlaceholder: '请输入面值', + balanceHint: '余额金额(美元)', + concurrencyHint: '并发数增量', + countLabel: '数量', + countPlaceholder: '请输入数量', + countHint: '要生成的兑换码数量', + prefixLabel: '前缀(可选)', + prefixPlaceholder: '例如:GIFT', + expiresLabel: '过期时间(可选)' + }, + filters: { + type: '类型', + allTypes: '全部类型', + status: '状态', + allStatuses: '全部状态', + search: '搜索兑换码' + }, + generating: '生成中...', + copyCode: '复制', + copied: '已复制!', + disableCode: '禁用', + enableCode: '启用', + deleteCode: '删除', + deleteConfirmMessage: '确定要删除此兑换码吗?', + noCodes: '暂无兑换码', + noCodesDescription: '生成兑换码以向用户分发余额或并发数。', + codesGeneratedSuccess: '兑换码生成成功,共 {count} 个', + codeDisabledSuccess: '兑换码已禁用', + codeEnabledSuccess: '兑换码已启用', + codeDeletedSuccess: '兑换码删除成功', + failedToLoad: '加载兑换码列表失败', + failedToGenerate: '生成兑换码失败', + failedToUpdate: '更新兑换码失败', + failedToDelete: '删除兑换码失败' + }, + + // Announcements + announcements: { + title: '公告管理', + description: '创建公告并按条件投放', + createAnnouncement: '创建公告', + editAnnouncement: '编辑公告', + deleteAnnouncement: '删除公告', + searchAnnouncements: '搜索公告...', + status: '状态', + allStatus: '全部状态', + columns: { + title: '标题', + status: '状态', + notifyMode: '通知方式', + targeting: '展示条件', + timeRange: '有效期', + createdAt: '创建时间', + actions: '操作' + }, + statusLabels: { + draft: '草稿', + active: '展示中', + archived: '已归档' + }, + notifyModeLabels: { + silent: '静默', + popup: '弹窗' + }, + form: { + title: '标题', + content: '内容(支持 Markdown)', + status: '状态', + notifyMode: '通知方式', + notifyModeHint: '弹窗模式会自动弹出通知给用户', + startsAt: '开始时间', + endsAt: '结束时间', + startsAtHint: '留空表示立即生效', + endsAtHint: '留空表示永久生效', + targetingMode: '展示条件', + targetingAll: '所有用户', + targetingCustom: '按条件', + addOrGroup: '添加 OR 条件组', + addAndCondition: '添加 AND 条件', + conditionType: '条件类型', + conditionSubscription: '订阅套餐', + conditionBalance: '余额', + operator: '运算符', + balanceValue: '余额阈值', + selectPackages: '选择套餐' + }, + operators: { + gt: '>', + gte: '≥', + lt: '<', + lte: '≤', + eq: '=' + }, + targetingSummaryAll: '全部用户', + targetingSummaryCustom: '自定义({groups} 组)', + timeImmediate: '立即', + timeNever: '永久', + readStatus: '已读情况', + eligible: '符合条件', + readAt: '已读时间', + unread: '未读', + searchUsers: '搜索用户...', + failedToLoad: '加载公告失败', + failedToCreate: '创建公告失败', + failedToUpdate: '更新公告失败', + failedToDelete: '删除公告失败', + failedToLoadReadStatus: '加载已读情况失败', + deleteConfirm: '确定要删除该公告吗?此操作无法撤销。' + }, + + // Promo Codes + promo: { + title: '优惠码管理', + description: '创建和管理注册优惠码', + createCode: '创建优惠码', + editCode: '编辑优惠码', + deleteCode: '删除优惠码', + searchCodes: '搜索优惠码...', + allStatus: '全部状态', + columns: { + code: '优惠码', + bonusAmount: '赠送金额', + maxUses: '最大使用次数', + usedCount: '已使用', + usage: '使用量', + status: '状态', + expiresAt: '过期时间', + createdAt: '创建时间', + actions: '操作' + }, + // 表单标签(扁平结构便于模板使用) + code: '优惠码', + autoGenerate: '留空自动生成', + codePlaceholder: '输入优惠码或留空', + bonusAmount: '赠送金额 ($)', + maxUses: '最大使用次数', + zeroUnlimited: '0 = 无限制', + expiresAt: '过期时间', + notes: '备注', + notesPlaceholder: '可选备注信息', + status: '状态', + neverExpires: '永不过期', + // 状态标签 + statusActive: '启用', + statusDisabled: '禁用', + statusExpired: '已过期', + statusMaxUsed: '已用完', + // 使用记录 + usageRecords: '使用记录', + viewUsages: '查看使用记录', + noUsages: '暂无使用记录', + userPrefix: '用户 #{id}', + copied: '已复制!', + // 消息 + noCodesYet: '暂无优惠码', + createFirstCode: '创建您的第一个优惠码,为新用户提供注册奖励。', + codeCreated: '优惠码创建成功', + codeUpdated: '优惠码更新成功', + codeDeleted: '优惠码删除成功', + deleteCodeConfirm: '确定要删除此优惠码吗?此操作无法撤销。', + copyRegisterLink: '复制注册链接', + registerLinkCopied: '注册链接已复制到剪贴板', + failedToLoad: '加载优惠码失败', + failedToCreate: '创建优惠码失败', + failedToUpdate: '更新优惠码失败', + failedToDelete: '删除优惠码失败', + failedToLoadUsages: '加载使用记录失败' + }, + + // Usage Records + usage: { + title: '使用记录', + description: '查看和管理所有用户的使用记录', + userFilter: '用户', + searchUserPlaceholder: '按邮箱搜索用户...', + searchApiKeyPlaceholder: '按名称搜索 API 密钥...', + searchAccountPlaceholder: '按名称搜索账号...', + selectedUser: '已选择', + user: '用户', + account: '账户', + group: '分组', + requestId: '请求ID', + requestIdCopied: '请求ID已复制', + allModels: '全部模型', + allAccounts: '全部账户', + allGroups: '全部分组', + allTypes: '全部类型', + inputCost: '输入费用', + outputCost: '输出费用', + cacheCreationCost: '缓存创建费用', + cacheReadCost: '缓存读取费用', + inputTokens: '输入 Token', + outputTokens: '输出 Token', + cacheCreationTokens: '缓存创建 Token', + cacheCreation5mTokens: '缓存创建', + cacheCreation1hTokens: '缓存创建', + cacheReadTokens: '缓存读取 Token', + failedToLoad: '加载使用记录失败', + billingType: '计费类型', + allBillingTypes: '全部计费类型', + billingTypeBalance: '钱包余额', + billingTypeSubscription: '订阅套餐', + billingMode: '计费模式', + billingModeToken: '按量', + billingModePerRequest: '按次', + billingModeImage: '按次(图片)', + allBillingModes: '全部计费模式', + ipAddress: 'IP', + clickToViewBalance: '点击查看充值记录', + failedToLoadUser: '加载用户信息失败', + userDeletedBadge: '已删除', + cleanup: { + button: '清理', + title: '清理使用记录', + warning: '清理不可恢复,且会影响历史统计回看。', + submit: '提交清理', + submitting: '提交中...', + confirmTitle: '确认清理', + confirmMessage: '确定要提交清理任务吗?清理不可恢复。', + confirmSubmit: '确认清理', + cancel: '取消任务', + cancelConfirmTitle: '确认取消', + cancelConfirmMessage: '确定要取消该清理任务吗?', + cancelConfirm: '确认取消', + cancelSuccess: '清理任务已取消', + cancelFailed: '取消清理任务失败', + recentTasks: '最近清理任务', + loadingTasks: '正在加载任务...', + noTasks: '暂无清理任务', + range: '时间范围', + deletedRows: '删除数量', + missingRange: '请选择时间范围', + submitSuccess: '清理任务已创建', + submitFailed: '创建清理任务失败', + loadFailed: '加载清理任务失败', + status: { + pending: '待执行', + running: '执行中', + succeeded: '已完成', + failed: '失败', + canceled: '已取消' + } + } + }, + + // Ops Monitoring +} diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts new file mode 100644 index 0000000000..bf848d1cc3 --- /dev/null +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -0,0 +1,1264 @@ +export default { + settings: { + title: '系统设置', + description: '管理注册、邮箱验证、默认值和 SMTP 设置', + tabs: { + general: '通用设置', + agreement: '登录条款', + features: '功能开关', + security: '安全与认证', + users: '用户默认值', + gateway: '网关服务', + email: '邮件设置', + backup: '数据备份', + payment: '支付设置', + }, + features: { + channelMonitor: { + title: '渠道监控', + description: '定期对配置的渠道发起健康检查,向用户展示可用性与延迟。关闭后调度器停止扫描,用户端列表为空。', + configureLink: '前往 渠道管理 > 渠道监控 配置监控项', + enabled: '启用渠道监控', + enabledHint: '关闭后后台不再执行定时检测,已有数据保留。', + defaultInterval: '默认检测间隔(秒)', + defaultIntervalHint: '新建渠道监控时表单的默认值,可被单个渠道覆盖。范围 15 – 3600 秒。', + }, + availableChannels: { + title: '可用渠道', + description: '向已登录用户展示他们能访问的渠道、模型和定价聚合视图。默认关闭。', + configureLink: '前往 渠道管理 > 渠道定价 配置模型价格', + enabled: '启用可用渠道', + enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。', + }, + riskControl: { + title: '风控中心', + description: '启用内容审计菜单和全端点请求审核入口。默认关闭。', + configureLink: '前往 风控中心 配置内容审计', + enabled: '启用风控中心', + enabledHint: '关闭后管理员侧边栏入口隐藏,网关内容审计不会执行。', + cyberSessionBlock: 'cyber 会话自动屏蔽', + cyberSessionBlockHint: '开启后,被上游网络安全策略(cyber_policy)拦截的会话将在 TTL 内被本地屏蔽,不再发往上游。仅屏蔽该会话,不影响同 Key 其他会话。', + cyberSessionBlockTTL: '屏蔽时长(秒)', + }, + affiliate: { + title: '邀请返利', + description: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。', + enabled: '启用邀请返利', + enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。', + rebateRate: '全局返利比例', + rebateRateHint: '充值后返给邀请人的默认比例(0-100%,例如填写 10 表示返利 10%)。', + freezeHours: '返利冻结期(小时)', + freezeHoursDesc: '新产生的返利将在冻结期内无法提现。0 = 不冻结。', + durationDays: '返利有效期(天)', + durationDaysDesc: '被邀请用户注册后多少天内的充值产生返利。0 = 永久有效。', + perInviteeCap: '单人返利上限', + perInviteeCapDesc: '每个被邀请用户最多产生的返利总额。0 = 无上限。', + customUsers: { + title: '专属用户配置', + description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。', + addButton: '添加专属用户', + searchPlaceholder: '搜索邮箱或用户名', + batchButton: '批量设置比例(已选 {count})', + empty: '暂无专属配置用户', + customBadge: '自定义', + useGlobal: '沿用全局', + resetTitle: '重置该用户的专属配置', + resetMessage: '确认将 {email} 的专属配置全部重置为默认?\n• 专属返利比例将清除(沿用全局)\n• 邀请码将重新生成为系统随机码(已分发的旧邀请链接将失效)', + totalLabel: '共 {total} 条', + col: { + email: '邮箱', + username: '用户名', + code: '邀请码', + rate: '专属比例', + actions: '操作', + }, + }, + modal: { + addTitle: '添加专属用户', + editTitle: '编辑专属配置', + userLabel: '用户', + userPlaceholder: '搜索邮箱或用户名', + changeUser: '更换用户', + codeLabel: '专属邀请码(可选)', + codePlaceholder: '例如 VIP2026', + codeHint: '4-32 位,仅支持大写字母、数字、下划线、连字符;留空表示不修改;输入将自动转大写。', + rateLabel: '专属返利比例(可选)', + ratePlaceholder: '例如 30', + rateHint: '0-100%;留空(编辑模式下)表示清除专属比例并沿用全局。', + errorBadRate: '请输入 0-100 之间的比例', + errorEmpty: '至少填写一项:专属邀请码或专属返利比例', + }, + batchModal: { + title: '批量设置专属比例(已选 {count} 个用户)', + hint: '为所选用户统一设置专属返利比例。', + placeholder: '例如 30', + clearHint: '留空提交将清除所选用户的专属比例。', + }, + }, + }, + emailTabDisabledTitle: '邮箱验证未启用', + emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。', + registration: { + title: '注册设置', + description: '控制用户注册和验证', + enableRegistration: '开放注册', + enableRegistrationHint: '允许新用户注册', + emailVerification: '邮箱验证', + emailVerificationHint: '新用户注册时需要验证邮箱', + emailSuffixWhitelist: '邮箱域名白名单', + emailSuffixWhitelistHint: + "仅允许使用指定域名的邮箱注册账号(例如 {'@'}qq.com, {'@'}gmail.com, *.edu.cn)", + emailSuffixWhitelistPlaceholder: "{'@'}example.com, *.edu.cn", + emailSuffixWhitelistInputHint: '留空则不限制。使用 *.edu.cn 可匹配 edu.cn 及其子域名。', + promoCode: '优惠码', + promoCodeHint: '允许用户在注册时使用优惠码', + invitationCode: '邀请码注册', + invitationCodeHint: '开启后,用户注册时需要填写有效的邀请码', + passwordReset: '忘记密码', + passwordResetHint: '允许用户通过邮箱重置密码', + frontendUrl: '前端地址', + frontendUrlPlaceholder: 'https://example.com', + frontendUrlHint: '用于生成邮件中的密码重置链接,例如 https://example.com', + totp: '双因素认证 (2FA)', + totpHint: '允许用户使用 Google Authenticator 等应用进行二次验证', + totpKeyNotConfigured: + '请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。' + }, + turnstile: { + title: 'Cloudflare Turnstile', + description: '登录和注册的机器人防护', + enableTurnstile: '启用 Turnstile', + enableTurnstileHint: '需要 Cloudflare Turnstile 验证', + siteKey: '站点密钥', + secretKey: '私密密钥', + siteKeyHint: '从 Cloudflare Dashboard 获取', + cloudflareDashboard: 'Cloudflare Dashboard', + secretKeyHint: '服务端验证密钥(请保密)', + secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' + }, + apiKeyAcl: { + title: 'API Key IP 访问控制', + description: '控制 API Key 白名单和黑名单使用哪个客户端 IP 判断', + trustForwardedIp: '信任反代传递的客户端 IP', + trustForwardedIpHint: + '默认关闭。仅在源站只允许 Cloudflare 或 Nginx 反代访问时开启;开启后 API Key IP 白/黑名单会使用 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For,与使用记录中的请求 IP 保持一致。' + }, + linuxdo: { + title: 'LinuxDo Connect 登录', + description: '配置 LinuxDo Connect OAuth,用于 Sub2API 用户登录', + enable: '启用 LinuxDo 登录', + enableHint: '在登录/注册页面显示 LinuxDo 登录入口', + clientId: 'Client ID', + clientIdPlaceholder: '例如:hprJ5pC3...', + clientIdHint: '从 Connect.Linux.Do 后台获取', + clientSecret: 'Client Secret', + clientSecretPlaceholder: '********', + clientSecretHint: '用于后端交换 token(请保密)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。', + redirectUrl: '回调地址(Redirect URL)', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/linuxdo/callback', + redirectUrlHint: '需与 Connect.Linux.Do 中配置的回调地址一致(必须是 http(s) 完整 URL)', + quickSetCopy: '使用当前站点生成并复制', + redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板' + }, + dingtalk: { + title: '钉钉登录', + description: '配置钉钉 OAuth,用于 Sub2API 用户登录', + enable: '启用钉钉登录-企业内部应用', + enableHint: '在登录/注册页面显示钉钉登录入口', + clientId: 'Client ID(AppKey)', + clientIdPlaceholder: '例如:dingxxxxxxxxxxxxxxxx', + clientIdHint: '从钉钉开放平台应用详情页获取', + clientSecret: 'Client Secret(AppSecret)', + clientSecretPlaceholder: '********', + clientSecretHint: '用于后端交换 token(请保密)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。', + redirectUrl: '回调地址(Redirect URL)', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/dingtalk/callback', + redirectUrlHint: '需与钉钉开放平台中配置的回调地址一致(必须是 http(s) 完整 URL)', + corpPolicy: { + label: '企业限制策略', + hint: '控制哪些钉钉账号(企业)可以登录', + none: '不限制(所有钉钉账号均可登录)', + internalOnly: '仅本企业(Internal Only)' + }, + bypassRegistration: '开放钉钉注册', + bypassRegistrationHint: '即使「开放注册」关闭时也可以通过钉钉登录来注册', + syncDisplayName: '同步钉钉姓名', + syncDisplayNameHint: '登录时将钉钉姓名写入 username 字段(同时记录到 dingtalk_name 属性)', + syncCorpEmail: '同步企业邮箱', + syncCorpEmailHint: '登录时将钉钉企业邮箱写入 dingtalk_email 属性(不影响登录邮箱)', + syncCorpEmailPermissionHint: '需在钉钉开放平台 → 应用 → 权限管理中为本应用申请「邮箱等个人信息(fieldEmail)」权限,否则 OAPI 不会返回企业邮箱字段', + syncDept: '同步部门', + syncDeptHint: '登录时将钉钉首个部门完整路径写入 dingtalk_department 属性(每次登录实时拉取)', + syncDeptPermissionHint: '需在钉钉开放平台 → 应用 → 权限管理中为本应用申请「通讯录部门信息读权限(qyapi_get_department_list)」,否则无法递归出部门路径', + syncDisplayNameTarget: '属性键', + syncDisplayNameTargetHint: '默认 dingtalk_name / 钉钉姓名;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)', + syncCorpEmailTarget: '属性键', + syncCorpEmailTargetHint: '默认 dingtalk_email / 钉钉企业邮箱;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)', + syncDeptTarget: '属性键', + syncDeptTargetHint: '默认 dingtalk_department / 钉钉部门;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)', + syncAttrDisplayName: '显示名称' + }, + oidc: { + title: 'OIDC 登录', + description: '配置标准 OIDC Provider(例如 Keycloak)', + enable: '启用 OIDC 登录', + enableHint: '在登录/注册页面显示 OIDC 登录入口', + providerName: 'Provider 名称', + providerNamePlaceholder: '例如 Keycloak', + clientId: 'Client ID', + clientIdPlaceholder: 'OIDC client id', + clientSecret: 'Client Secret', + clientSecretPlaceholder: '********', + clientSecretHint: '用于后端交换 token(请保密)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。', + issuerUrl: 'Issuer URL', + issuerUrlPlaceholder: 'https://id.example.com/realms/main', + discoveryUrl: 'Discovery URL', + discoveryUrlPlaceholder: '可选,留空将基于 issuer 自动推导', + authorizeUrl: 'Authorize URL', + authorizeUrlPlaceholder: '可选,可通过 discovery 自动获取', + tokenUrl: 'Token URL', + tokenUrlPlaceholder: '可选,可通过 discovery 自动获取', + userinfoUrl: 'UserInfo URL', + userinfoUrlPlaceholder: '可选,可通过 discovery 自动获取', + jwksUrl: 'JWKS URL', + jwksUrlPlaceholder: '可选;启用严格 ID Token 校验时必填', + scopes: 'Scopes', + scopesPlaceholder: 'openid email profile', + scopesHint: '必须包含 openid', + redirectUrl: '后端回调地址(Redirect URL)', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback', + redirectUrlHint: '必须与 OIDC Provider 中配置的回调地址一致', + quickSetCopy: '使用当前站点生成并复制', + redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板', + frontendRedirectUrl: '前端回调路径', + frontendRedirectUrlPlaceholder: '/auth/oidc/callback', + frontendRedirectUrlHint: '后端回调完成后重定向到此前端路径', + tokenAuthMethod: 'Token 鉴权方式', + clockSkewSeconds: '时钟偏移(秒)', + allowedSigningAlgs: '允许的签名算法', + allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256', + usePkce: '启用 PKCE', + validateIdToken: '校验 ID Token', + requireEmailVerified: '要求邮箱已验证', + userinfoEmailPath: 'UserInfo 邮箱字段路径', + userinfoEmailPathPlaceholder: '例如 data.email', + userinfoIdPath: 'UserInfo ID 字段路径', + userinfoIdPathPlaceholder: '例如 data.id', + userinfoUsernamePath: 'UserInfo 用户名字段路径', + userinfoUsernamePathPlaceholder: '例如 data.username' + }, + defaults: { + title: '用户默认设置', + description: '新用户的默认值', + defaultBalance: '默认余额', + defaultBalanceHint: '新用户的初始余额', + affiliateRebateRate: '邀请返利比例', + affiliateRebateRateHint: '充值后返给邀请人的比例(0-100%,例如填写 10 表示返利 10%)', + defaultConcurrency: '默认并发数', + defaultConcurrencyHint: '新用户的最大并发请求数', + defaultUserRpmLimit: '默认用户 RPM 限制', + defaultUserRpmLimitHint: '新用户默认每分钟最大请求数,0 = 不限制;仅作用于新用户创建时初始化', + defaultSubscriptions: '默认订阅列表', + defaultSubscriptionsHint: '新用户创建或注册时自动分配这些订阅', + addDefaultSubscription: '添加默认订阅', + defaultSubscriptionsEmpty: '未配置默认订阅。新用户不会自动获得订阅套餐。', + defaultSubscriptionsDuplicate: '默认订阅存在重复分组:{groupId}。每个分组只能出现一次。', + subscriptionGroup: '订阅分组', + subscriptionValidityDays: '有效期(天)', + defaultPlatformQuotas: '默认平台限额(注册时分配)', + defaultPlatformQuotasHint: '新用户注册时自动写入平台限额记录;已有用户不受影响。留空 = 该平台该窗口不限制。', + platformQuotaNotice: '月限额为 30 天滚动窗口,非自然月', + }, + platformQuota: { + platform: '平台', + daily: '日限额 (USD)', + weekly: '周限额 (USD)', + monthly: '月限额 (USD, 30天滚动)', + placeholder: '不限', + }, + claudeCode: { + title: 'Claude Code 设置', + description: '控制 Claude Code 客户端访问要求', + minVersion: '最低版本号', + minVersionPlaceholder: '例如 2.1.63', + minVersionHint: '拒绝低于此版本的 Claude Code 客户端请求(semver 格式)。留空则不检查版本。', + maxVersion: '最高版本号', + maxVersionPlaceholder: '例如 2.5.0', + maxVersionHint: '拒绝高于此版本的 Claude Code 客户端请求(semver 格式)。留空则不限制最高版本。' + }, + scheduling: { + title: '网关调度设置', + description: '控制 API Key 的调度行为', + allowUngroupedKey: '允许未分组 Key 调度', + allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。' + }, + gatewayForwarding: { + title: '请求转发行为', + description: '控制请求转发到上游 OAuth 账号时的行为', + fingerprintUnification: '指纹统一化', + fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。', + metadataPassthrough: 'Metadata 透传', + metadataPassthroughHint: '透传客户端原始 metadata.user_id,不进行重写。可能提高上游缓存命中率。', + cchSigning: 'CCH 签名', + cchSigningHint: '对转发请求的 billing header 进行 CCH 哈希签名。关闭时保留原始占位符。', + claudeOAuthSystemPromptInjection: 'Claude OAuth System 注入', + claudeOAuthSystemPromptInjectionHint: '为非 Claude Code 客户端的 Claude OAuth 请求注入 Claude Code 形态的 system blocks。默认开启。', + claudeOAuthSystemPrompt: 'Claude OAuth 扩展提示词', + claudeOAuthSystemPromptPlaceholder: '留空时使用内置 Claude Code 扩展提示词。', + claudeOAuthSystemPromptHint: '兼容旧配置:仅控制第三个注入的 system block。', + claudeOAuthSystemPromptBlocks: 'Claude OAuth System Blocks', + claudeOAuthSystemPromptBlocksPlaceholder: '留空时使用内置 3 个 blocks。支持数组或 {"blocks": [...]}。', + claudeOAuthSystemPromptBlocksHint: '每个 block 会保存为带 enabled、type、text、可选 cache_control 的 JSON。{billing_header} 会按请求动态生成;Claude Code 身份提示词和扩展提示词可直接编辑,也可用预设恢复默认值。', + systemBlockTitle: 'System Block {index}', + systemBlockPreset: '预设', + systemBlockPresetBilling: 'Billing Header', + systemBlockPresetIdentity: 'Claude Code 身份提示词', + systemBlockPresetExpansion: 'Claude Code 扩展提示词', + systemBlockPresetCustom: '自定义', + systemBlockType: '类型', + systemBlockTypeText: '文本', + systemBlockText: '内容', + systemBlockCacheControl: 'Cache Control', + systemBlockHide: '隐藏 block 详情', + systemBlockShow: '展示 block 详情', + addSystemBlock: '添加 block', + resetSystemBlocks: '恢复默认', + cacheTTL5m: '5 分钟', + cacheTTL1h: '1 小时', + anthropicCacheTTL1hInjection: 'Anthropic 缓存 TTL 注入', + anthropicCacheTTL1hInjectionHint: '开启后,对 Anthropic OAuth/Setup Token 请求体中已有的 ephemeral 缓存块强制写入 1h;响应 usage 默认按 5m 回写计费,账号级 TTL 计费设置优先。', + rewriteMessageCacheControl: '改写消息缓存断点', + rewriteMessageCacheControlHint: '默认关闭,保留客户端在 messages 内容块中的 cache_control。开启后会清除客户端断点并注入代理断点,适合不自行管理缓存策略的客户端。', + clientDatelineNormalization: '客户端 dateline 归一化', + clientDatelineNormalizationHint: '默认开启。将 Anthropic OAuth/Setup Token 请求体中 "Today\'s date is …" 语句里的撇号与日期分隔符还原为 ASCII 撇号 + 短横线 (2026-07-01) 的规范形态,抹除某些客户端在检测到非官方 base URL 时注入的隐写指纹位。仅作用于 system prompt 与 块内,API Key 账号不受影响。', + antigravityUserAgentVersion: 'Antigravity UA 版本', + antigravityUserAgentVersionPlaceholder: '1.23.2', + antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。', + openaiCodexUserAgent: 'OpenAI Codex UA', + openaiCodexUserAgentPlaceholder: 'codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)', + openaiCodexUserAgentHint: '用于规避 OpenAI 上游 Cloudflare 对浏览器 UA 的访问质询。仅在检测到客户端 User-Agent 为浏览器(Mozilla/...)时生效,其他客户端原样透传。留空使用内置默认值。', + codexHardeningTitle: 'Codex 设置', + codexClientRestrictionTitle: 'Codex 客户端限制', + codexHardeningDesc: + '仅对已开启「仅允许 Codex 官方客户端」的 OpenAI OAuth 账号生效(全局)。在 User-Agent/Originator 之外,用版本区间、引擎指纹门与黑/白名单巩固判定。', + minCodexVersion: '最低 Codex 版本', + minCodexVersionPlaceholder: '例如 0.142.0', + maxCodexVersion: '最高 Codex 版本', + maxCodexVersionPlaceholder: '例如 0.200.0', + codexVersionHint: + '仅对官方客户端生效,校验其版本是否落在 [最低, 最高] 区间。留空表示该侧不限制。', + codexFingerprintSignals: 'Codex 引擎指纹信号', + codexFingerprintSignalsDesc: + '定义引擎指纹信号:勾「必须」的信号需全部命中(AND),每条 / 分隔的变体取或(OR);一条都不勾即不校验。默认只勾 x-codex- 前缀。类型:头精确 / 头前缀 / body 路径。', + codexFpTypeHeaderExact: '头精确', + codexFpTypeHeaderPrefix: '头前缀', + codexFpTypeBodyPath: 'body 路径', + codexFpMatchPlaceholder: '匹配,变体用 / 分隔(如 session-id / session_id 或 x-codex-)', + codexFpRequired: '必须', + codexFingerprintNoRequiredWarn: '未勾选任何「必须」信号——引擎指纹门当前不生效,等于放行所有通过身份/版本的候选。如需启用校验,请至少勾选一条信号。', + codexAllowAppServer: 'Codex app-server', + codexAllowAppServerDesc: + '放行内嵌 Codex 引擎、经 app-server 协议接入的第三方客户端(如 Claude Code 的 codex 插件)。默认关闭;开启后此类客户端通过引擎指纹门(下方信号列表)即放行,关闭则仅放行官方客户端与白名单。', + codexBlacklist: 'User-Agent/Originator 黑名单', + codexBlacklistDesc: + '命中任一字段即拒,优先于一切放行。originator 精确匹配,User-Agent 为包含匹配(多个用逗号分隔)。', + codexWhitelist: 'User-Agent/Originator 白名单', + codexWhitelistDesc: + '放行官方集之外的客户端:需 originator 精确,且每个 User-Agent 标记都命中。默认仍需过引擎指纹门,勾「跳过引擎指纹」可免。', + codexWhitelistSkipFingerprint: '跳过引擎指纹', + codexWhitelistSkipFingerprintTooltip: + '风险:勾选后该条仅凭 originator + User-Agent(均可伪造)放行,不再要求引擎指纹兜底。仅用于确属可信、但本身不发 codex 引擎指纹的第三方客户端。', + codexOriginatorPlaceholder: 'originator(精确,如 opencode)', + codexUaContainsPlaceholder: 'User-Agent 包含标记,逗号分隔(如 opencode/)', + codexAddRow: '添加一条', + codexRemoveRow: '删除', + }, + webSearchEmulation: { + title: 'Web Search 模拟', + description: '为不原生支持搜索的 Anthropic API Key 账号注入 web search 能力', + enabled: '启用 Web Search 模拟', + enabledHint: '全局开关。关闭后所有渠道和账号的 web search 模拟均不生效。', + providers: '搜索服务商', + addProvider: '添加服务商', + providerType: '服务商类型', + apiKey: 'API Key', + apiKeyPlaceholder: '输入 API Key', + apiKeyConfigured: '已配置', + showApiKey: '显示', + hideApiKey: '隐藏', + copyApiKey: '复制', + copied: '已复制', + quotaLimit: '配额上限', + quotaLimitHint: '留空表示无限制;填写时必须大于 0', + quotaLimitMustBePositive: '配额上限必须大于 0', + subscribedAt: '订阅时间', + subscribedAtHint: '配额从此日期起每月自动重置;留空则不自动重置', + quotaUsage: '用量', + resetUsage: '重置', + resetUsageConfirm: '确定要重置此服务商的用量计数吗?', + resetUsageSuccess: '用量已重置', + proxy: '代理', + removeProvider: '删除', + noProviders: '未配置搜索服务商', + test: '测试', + testDefaultQuery: '搜索今年世界大事件', + testing: '搜索中...', + testResultTitle: '搜索结果', + testResultProvider: '服务商', + testNoResults: '无搜索结果', + }, + site: { + title: '站点设置', + description: '自定义站点品牌', + backendMode: 'Backend 模式', + backendModeDescription: + '禁用用户注册、公开页面和自助服务功能。仅管理员可以登录和管理平台。', + siteName: '站点名称', + siteNameHint: '显示在邮件和页面标题中', + siteNamePlaceholder: 'Sub2API', + siteSubtitle: '站点副标题', + siteSubtitleHint: '显示在登录和注册页面', + siteSubtitlePlaceholder: '订阅转 API 转换平台', + apiBaseUrl: 'API 端点地址', + apiBaseUrlHint: '用于"使用密钥"、"导入到 CC Switch"和回调地址建议,留空则使用当前站点地址', + apiBaseUrlPlaceholder: 'https://api.example.com', + tablePreferencesTitle: '通用表格设置', + tablePreferencesDescription: '设置后台与用户侧表格组件的默认分页行为', + tableDefaultPageSize: '默认每页条数', + tableDefaultPageSizeHint: '必须为 5-1000 之间的整数', + tablePageSizeOptions: '可选每页条数列表', + tablePageSizeOptionsPlaceholder: '10, 20, 50, 100', + tablePageSizeOptionsHint: '使用英文逗号分隔,取值范围 5-1000,保存时会自动去重并排序', + tableDefaultPageSizeRangeError: '默认每页条数必须在 {min}-{max} 之间', + tablePageSizeOptionsFormatError: '可选每页条数格式无效,请输入 {min}-{max} 之间的整数并用英文逗号分隔', + customEndpoints: { + title: '自定义端点', + description: '添加额外的 API 端点地址,用户可在「API Keys」页面快速复制', + itemLabel: '端点 #{n}', + name: '名称', + namePlaceholder: '如:OpenAI Compatible', + endpointUrl: '端点地址', + endpointUrlPlaceholder: 'https://api2.example.com', + descriptionLabel: '介绍', + descriptionPlaceholder: '如:支持 OpenAI 格式请求', + add: '添加端点', + }, + contactInfo: '客服联系方式', + contactInfoPlaceholder: '例如:QQ: 123456789', + contactInfoHint: '填写客服联系方式,将展示在兑换页面、个人资料等位置', + docUrl: '文档链接', + docUrlHint: '文档网站的链接。留空则隐藏文档链接。', + docUrlPlaceholder: 'https://docs.example.com', + siteLogo: '站点Logo', + uploadImage: '上传图片', + remove: '移除', + logoHint: 'PNG、JPG 或 SVG 格式,最大 300KB。建议:80x80px 正方形图片。', + logoSizeError: '图片大小超过 300KB 限制({size}KB)', + logoTypeError: '请选择图片文件', + logoReadError: '读取图片文件失败', + homeContent: '首页内容', + homeContentPlaceholder: + '在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。', + homeContentHint: + '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。', + homeContentIframeWarning: + '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。', + hideCcsImportButton: '隐藏 CCS 导入按钮', + hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮' + }, + purchase: { + title: '充值/订阅页面', + description: '在侧边栏展示“充值/订阅”入口,并在页面内通过 iframe 打开指定链接', + enabled: '显示充值/订阅入口', + enabledHint: '仅在标准模式(非简单模式)下展示', + url: '充值/订阅页面 URL', + urlPlaceholder: 'https://example.com/purchase', + urlHint: '必须是完整的 http(s) 链接', + iframeWarning: + '⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSP(frame-ancestors)禁止被 iframe 嵌入,出现空白时可引导用户使用”新窗口打开”。', + integrationDoc: '支付集成文档', + integrationDocHint: '包含接口说明、幂等语义及示例代码' + }, + soraClient: { + title: 'Sora 客户端', + description: '控制是否在侧边栏展示 Sora 客户端入口', + enabled: '启用 Sora 客户端', + enabledHint: '开启后,侧边栏将显示 Sora 入口,用户可访问 Sora 功能' + }, + customMenu: { + title: '自定义菜单页面', + description: '添加自定义 iframe 页面到侧边栏导航。每个页面可以设置为普通用户或管理员可见。', + itemLabel: '菜单项 #{n}', + name: '菜单名称', + namePlaceholder: '如:帮助中心', + url: '页面 URL', + urlPlaceholder: 'https://example.com/page', + iconSvg: 'SVG 图标', + iconSvgPlaceholder: '...', + iconPreview: '图标预览', + uploadSvg: '上传 SVG', + removeSvg: '清除', + visibility: '可见角色', + visibilityUser: '普通用户', + visibilityAdmin: '管理员', + add: '添加菜单项', + remove: '删除', + moveUp: '上移', + moveDown: '下移', + }, + payment: { + title: '支付设置', + description: '配置支付系统选项', + configGuide: '支付配置指南', + enabled: '启用支付', + enabledHint: '启用或禁用支付系统', + enabledPaymentTypes: '启用的服务商', + enabledPaymentTypesHint: '禁用服务商将同时禁用对应的实例。', + findProvider: '正在寻找合适的易支付服务商?', + minAmount: '最低金额', + maxAmount: '最高金额', + dailyLimit: '每日限额', + balanceRechargeMultiplier: '余额充值倍率', + balanceRechargeMultiplierHint: '用户每支付 1 CNY 可获得多少 USD 余额', + balanceRechargePreview: '预览:1 CNY = {usd} USD', + subscriptionUsdToCnyRate: '订阅 CNY 换算汇率', + subscriptionUsdToCnyRateHint: + 'CNY 支付通道下,套餐每 1 USD 价格收取多少 CNY(如 7.15)。0 或留空 = 不换算,订阅按 price 数值直接收款。启用后所有套餐 price 必须按 USD 定价', + subscriptionUsdToCnyRateDisabled: '未启用(按 price 直付)', + rechargeFeeRate: '充值手续费率', + rechargeFeeRateHint: '用户充值时额外收取的手续费百分比,0 表示不收取手续费', + rechargeFeePreview: '预览:充值 100 元,手续费 {fee} 元', + orderTimeout: '订单超时时间', + orderTimeoutHint: '单位:分钟,至少 1 分钟', + maxPendingOrders: '最大待支付订单数', + cancelRateLimit: '限制取消频率', + cancelRateLimitHint: '启用后,用户在时间窗口内取消订单次数超限将无法创建新订单', + cancelRateLimitEvery: '每', + cancelRateLimitAllowMax: '最多', + cancelRateLimitTimes: '次', + cancelRateLimitWindow: '时间窗口', + cancelRateLimitUnit: '周期', + cancelRateLimitMax: '最大取消次数', + cancelRateLimitUnitMinute: '分钟', + cancelRateLimitUnitHour: '小时', + cancelRateLimitUnitDay: '天', + cancelRateLimitWindowMode: '窗口模式', + cancelRateLimitWindowModeRolling: '滚动', + cancelRateLimitWindowModeFixed: '固定', + alipayForceQRCode: '支付宝强制二维码支付', + alipayForceQRCodeHint: '启用后,移动端支付宝用户将统一使用二维码扫码支付,不再跳转至手机网站支付', + helpText: '帮助文本', + helpImageUrl: '帮助图片链接', + manageProviders: '管理服务商', + balancePaymentDisabled: '禁用余额充值', + noLimit: '留空表示不限制', + helpImage: '帮助图片', + helpImagePlaceholder: '上传或输入图片链接', + helpTextPlaceholder: '输入帮助说明文本...', + providerEasypay: '易支付', + providerAlipay: '支付宝官方', + providerWxpay: '微信官方', + providerStripe: 'Stripe', + providerAirwallex: 'Airwallex', + typeDisabled: '类型已禁用', + enableTypesFirst: '请先在上方启用至少一种服务商', + easypayRedirect: '跳转', + paymentMode: '支付模式', + modeRedirect: '跳转', + modeQRCode: '二维码', + modePopup: '弹窗', + validationNameRequired: '服务商名称不能为空', + validationTypesRequired: '请至少选择一种支持的支付方式', + validationFieldRequired: '{field} 不能为空', + validationEasyPayCustomMethodRequired: '每个易支付自定义方式都必须填写支付方式和上游 type', + validationEasyPayCustomMethodTypeInvalid: '易支付自定义支付方式只能包含小写字母、数字、下划线和短横线', + validationEasyPayCustomMethodUpstreamTypeInvalid: '易支付上游 type 只能包含小写字母、数字、下划线和短横线', + validationEasyPayCustomMethodReserved: '易支付自定义支付方式不能使用内置的 alipay 或 wxpay', + validationEasyPayCustomMethodPrefixReserved: '易支付自定义支付方式不能以 alipay 或 wxpay 开头', + validationEasyPayCustomMethodDuplicate: '易支付自定义支付方式不能重复', + field_apiBase: 'API 基础地址', + field_notifyUrl: '异步通知地址', + field_returnUrl: '同步跳转地址', + callbackBaseUrl: '回调基础地址', + field_privateKey: '私钥', + field_publicKey: '公钥', + field_mpAppId: '公众号 App ID', + field_mchId: '商户号', + field_apiV3Key: 'API v3 密钥', + field_publicKeyId: '公钥 ID', + field_certSerial: '证书序列号', + field_h5AppName: 'H5 应用名称', + field_h5AppUrl: 'H5 应用地址', + wxpayConfigHint: '微信支付通常只需要填写 App ID。公众号 App ID、H5 应用名称、H5 应用地址仅在公众号支付或 H5 场景有特殊要求时再填写。', + wxpayAdvancedOptions: '微信支付高级可选项', + field_secretKey: '密钥', + field_clientId: 'Client ID', + field_apiKey: 'API Key', + field_publishableKey: '公开密钥', + field_webhookSecret: 'Webhook 密钥', + field_countryCode: '国家/地区代码', + field_currency: '支付币种', + field_accountId: 'Airwallex 账户 ID', + field_airwallexApiBaseHint: '必须和 API Key 所属环境一致:沙箱/测试密钥使用 https://api-demo.airwallex.com/api/v1,生产密钥使用 https://api.airwallex.com/api/v1。环境混用会返回 credentials_invalid / Access Denied。', + field_paymentCurrencyHint: '默认 CNY。Stripe 和 Airwallex 可按账户支持从下拉项选择 HKD、USD 等币种;微信、支付宝、易支付仍按 CNY。', + field_accountIdHint: '不涉及多账户、组织级密钥或连接账户收款时可以不填;单账户 Scoped API Key 会默认使用所选账户。', + field_cid: '支付渠道 ID', + field_cidAlipay: '支付宝渠道 ID', + field_cidWxpay: '微信渠道 ID', + easypayCustomMethods: '易支付自定义支付方式', + easypayCustomMethodsHint: '添加当前易支付服务商额外支持的支付方式。支付方式会记录到 Sub2API 订单中,上游 type 会作为易支付 type 参数提交。', + addCustomMethod: '添加方式', + customMethodType: '支付方式', + customMethodUpstreamType: '上游 type', + customMethodDisplayName: '显示名称', + stripeWebhookHint: '请在 Stripe Dashboard 中将以下地址配置为 Webhook 端点:', + stripeWebhookApiVersionHint: 'Webhook 端点的 API 版本请与当前集成的 Stripe SDK 对齐,建议选择 {version};版本不一致可能导致回调事件解析失败。', + airwallexWebhookHint: '请在 Airwallex 后台将以下地址配置为 Webhook 端点;事件至少选择 Payment Intent -> Succeeded(payment_intent.succeeded),建议同时选择 Payment Intent -> Cancelled(payment_intent.cancelled);API version 选择账户默认或最新稳定版本。', + airwallexGuideSummary: '创建 Airwallex Scoped API 密钥时,建议只在账户级权限中为 Payment Acceptance 勾选读取和写入。', + airwallexGuideNote: '不需要勾选 Spend、Payouts、Transfers、Funds Splits、POS 终端等与在线收款无关的权限。Webhook 事件至少选择 payment_intent.succeeded,建议同时选择 payment_intent.cancelled;API version 选择账户默认或最新稳定版本。', + limitsTitle: '限额配置', + limitSingleMin: '单笔最低', + limitSingleMax: '单笔最高', + limitDaily: '每日限额', + limitsHint: '全部留空使用全局配置,部分填写时留空项表示不限制', + limitsUseGlobal: '使用全局配置', + limitsNoLimit: '不限制', + productNamePrefix: '商品名前缀', + productNameSuffix: '商品名后缀', + preview: '预览', + loadBalanceStrategy: '负载均衡策略', + strategyRoundRobin: '轮询', + strategyLeastAmount: '最少金额', + providerManagement: '服务商管理', + providerManagementDesc: '管理支付服务商实例', + createProvider: '添加服务商', + editProvider: '编辑服务商', + deleteProvider: '删除服务商', + deleteProviderConfirm: '确定要删除此服务商吗?', + providerName: '服务商名称', + providerKey: '服务商类型', + selectProviderKey: '选择服务商类型', + providerConfig: '凭证配置', + paymentGuideTrigger: '查看支付方式说明', + guideOpenLabel: '开通:', + guideCallLabel: '调用:', + guideFallbackLabel: '降级:', + alipayGuideSummary: '桌面优先扫码单,失败再走收银台;移动优先手机网站支付。', + alipayGuideFaceToFaceTitle: '当面付 / 扫码支付', + alipayGuideFaceToFaceOpen: '需开通当面付或扫码支付能力。', + alipayGuideFaceToFaceCall: '桌面端下单时优先调用 alipay.trade.precreate,前台直接渲染二维码。', + alipayGuideFaceToFaceFallback: '接口不可用或返回失败时,自动降级到电脑网站支付。', + alipayGuidePagePayTitle: '电脑网站支付', + alipayGuidePagePayOpen: '需开通电脑网站支付。', + alipayGuidePagePayCall: '桌面端当面付不可用时调用 alipay.trade.page.pay,并继续把返回链接渲染成二维码。', + alipayGuidePagePayFallback: '同时保留打开收银台入口,用户可手动重新拉起支付页。', + alipayGuideWapTitle: '手机网站支付', + alipayGuideWapOpen: '需开通手机网站支付。', + alipayGuideWapCall: '移动端优先调用 alipay.trade.wap.pay,跳转支付宝收银台。', + alipayGuideWapFallback: '未开通或返回异常时,前端自动改走扫码支付并提示未开通移动支付。', + wxpayGuideSummary: '桌面优先 Native 扫码,移动端按浏览器环境走 JSAPI 或 H5。', + wxpayGuideNote: '当前表单默认共用一个 App ID,适合同主体下统一配置网页、移动和公众号场景。', + wxpayGuideNativeTitle: 'Native / 扫码支付', + wxpayGuideNativeOpen: '需开通 Native 或扫码支付能力。', + wxpayGuideNativeCall: '桌面端默认调用 Native,下发二维码内容给前台渲染。', + wxpayGuideNativeFallback: '移动端无法走 JSAPI 或 H5 时,也会自动回退到这里。', + wxpayGuideJsapiTitle: 'JSAPI / 公众号支付', + wxpayGuideJsapiOpen: '需开通公众号支付,并保证当前浏览器在微信内且能拿到 OpenID。', + wxpayGuideJsapiCall: '微信内浏览器完成授权后调用 JSAPI,直接拉起微信支付。', + wxpayGuideJsapiFallback: '未配置、Bridge 不可用或拉起失败时,自动改走扫码支付。', + wxpayGuideH5Title: 'H5 支付', + wxpayGuideH5Open: '需开通 H5 支付。', + wxpayGuideH5Call: '移动端非微信浏览器且有客户端 IP 时调用 H5 支付,跳转微信收银台。', + wxpayGuideH5Fallback: '未开通 H5 或下单失败时,自动改走扫码支付。', + noProviders: '暂无服务商实例', + supportedTypes: '支持的支付方式', + supportedTypesHint: '逗号分隔,如 alipay,wxpay', + refundEnabled: '允许退款', + allowUserRefund: '允许用户退款', + enableConflict: '{method} 已有启用中的服务商实例:{provider}。请先停用现有实例后再启用或切换。', + }, + balanceNotify: { + title: '余额不足提醒', + description: '当用户余额低于阈值时发送邮件提醒', + enabled: '启用余额不足提醒', + threshold: '默认提醒阈值', + thresholdHint: '用户未自定义时使用此值', + thresholdPlaceholder: '输入金额', + rechargeUrl: '充值页面 URL', + rechargeUrlPlaceholder: 'https://example.com/payment', + rechargeUrlHint: '设置后邮件中将包含充值链接按钮', + }, + quotaNotify: { + title: '账号限额通知', + description: '当账号配额用量达到告警阈值时通知管理员', + enabled: '启用账号限额通知', + emails: '通知邮箱', + emailsHint: '留空则不发送通知', + addEmail: '添加邮箱', + emailPlaceholder: '输入邮箱地址', + }, + subscriptionExpiryNotify: { + title: '订阅到期提醒', + description: '控制是否向用户发送订阅即将到期的邮件提醒。', + enabled: '启用订阅到期提醒', + enabledHint: '开启后,系统会在订阅到期前 7 天、3 天、1 天各发送一次提醒。' + }, + smtp: { + title: 'SMTP 设置', + description: '配置用于发送验证码的邮件服务', + testConnection: '测试连接', + testing: '测试中...', + host: 'SMTP 主机', + hostPlaceholder: 'smtp.gmail.com', + port: 'SMTP 端口', + portPlaceholder: '587', + username: 'SMTP 用户名', + usernamePlaceholder: "your-email{'@'}gmail.com", + password: 'SMTP 密码', + passwordPlaceholder: '********', + passwordHint: '留空以保留现有密码', + passwordConfiguredPlaceholder: '********', + passwordConfiguredHint: '密码已配置,留空以保留当前值。', + fromEmail: '发件人邮箱', + fromEmailPlaceholder: "noreply{'@'}example.com", + fromName: '发件人名称', + fromNamePlaceholder: 'Sub2API', + useTls: '使用 TLS', + useTlsHint: '为 SMTP 连接启用 TLS 加密' + }, + testEmail: { + title: '发送测试邮件', + description: '发送测试邮件以验证 SMTP 配置', + recipientEmail: '收件人邮箱', + recipientEmailPlaceholder: "test{'@'}example.com", + sendTestEmail: '发送测试邮件', + sending: '发送中...', + enterRecipientHint: '请输入收件人邮箱地址' + }, + emailTemplates: { + title: '邮件模板', + description: '按事件和语言自定义通知邮件主题与 HTML 内容。', + event: '事件', + locale: '语言', + localeEn: '英文', + localeZh: '中文', + subject: '主题', + subjectPlaceholder: '输入邮件主题', + html: 'HTML 模板', + htmlPlaceholder: '编辑邮件 HTML 模板', + placeholders: '可用占位符', + placeholdersHelp: '点击占位符可复制。后端发送邮件时会替换这些值。', + livePreview: '实时预览', + previewSecurityHint: '预览 HTML 由后端预览接口生成,并在禁用脚本的沙盒 iframe 中展示。', + preview: '预览 / 刷新', + previewing: '预览中...', + save: '保存模板', + saving: '保存中...', + restoreOfficial: '恢复官方模板', + restoring: '恢复中...', + restoreConfirm: '确定恢复此事件和语言的官方模板吗?当前自定义版本将被替换。', + restoreSuccess: '已恢复官方模板', + saveSuccess: '邮件模板已保存', + placeholderCopied: '占位符已复制', + validationRequired: '主题和 HTML 模板不能为空', + empty: '暂无可用的邮件模板事件或语言。', + noPreview: '刷新预览后查看渲染后的邮件主题。', + customized: '已自定义' + }, + opsMonitoring: { + title: '运维监控', + description: '启用运维监控模块,用于排障与健康可视化', + disabled: '运维监控已关闭', + enabled: '启用运维监控', + enabledHint: '启用运维监控模块(仅管理员可见)', + realtimeEnabled: '启用实时监控', + realtimeEnabledHint: '启用实时请求速率和指标推送(WebSocket)', + queryMode: '默认查询模式', + queryModeHint: '运维监控默认查询模式(自动/原始/预聚合)', + queryModeAuto: '自动(推荐)', + queryModeRaw: '原始(最准确,但较慢)', + queryModePreagg: '预聚合(最快,需预聚合)', + metricsInterval: '采集频率(秒)', + metricsIntervalHint: '系统/请求指标采集频率(60-3600 秒)' + }, + adminApiKey: { + title: '管理员 API Key', + description: '用于外部系统集成的全局 API Key,拥有完整的管理员权限', + notConfigured: '尚未配置管理员 API Key', + configured: '管理员 API Key 已启用', + currentKey: '当前密钥', + regenerate: '重新生成', + regenerating: '生成中...', + delete: '删除', + deleting: '删除中...', + create: '创建密钥', + creating: '创建中...', + regenerateConfirm: '确定要重新生成吗?当前密钥将立即失效。', + deleteConfirm: '确定要删除管理员 API Key 吗?外部集成将停止工作。', + keyGenerated: '新的管理员 API Key 已生成', + keyDeleted: '管理员 API Key 已删除', + copyKey: '复制密钥', + keyCopied: '密钥已复制到剪贴板', + keyWarning: '此密钥仅显示一次,请立即复制保存。', + securityWarning: '警告:此密钥拥有完整的管理员权限,请妥善保管。', + usage: '使用方法:在请求头中添加 x-api-key: ' + }, + soraS3: { + title: 'Sora 存储配置', + description: '以多配置列表管理 Sora 媒体存储,支持 S3 和 Google Drive', + newProfile: '新建配置', + reloadProfiles: '刷新列表', + empty: '暂无存储配置,请先创建', + createTitle: '新建存储配置', + editTitle: '编辑存储配置', + selectProvider: '选择存储类型', + providerS3Desc: 'S3 兼容对象存储', + providerGDriveDesc: 'Google Drive 云盘', + profileID: '配置 ID', + profileName: '配置名称', + setActive: '创建后设为生效', + saveProfile: '保存配置', + activateProfile: '设为生效', + profileCreated: '存储配置创建成功', + profileSaved: '存储配置保存成功', + profileDeleted: '存储配置删除成功', + profileActivated: '生效配置已切换', + profileIDRequired: '请填写配置 ID', + profileNameRequired: '请填写配置名称', + profileSelectRequired: '请先选择配置', + endpointRequired: '启用时必须填写 S3 端点', + bucketRequired: '启用时必须填写存储桶', + accessKeyRequired: '启用时必须填写 Access Key ID', + deleteConfirm: '确定删除存储配置 {profileID} 吗?', + columns: { + profile: '配置', + profileId: 'Profile ID', + name: '名称', + provider: '存储类型', + active: '生效状态', + endpoint: '端点', + bucket: '存储桶', + storagePath: '存储路径', + capacityUsage: '容量 / 已用', + capacityUnlimited: '无限制', + videoCount: '视频数', + videoCompleted: '完成', + videoInProgress: '进行中', + quota: '默认配额', + updatedAt: '更新时间', + actions: '操作', + rootFolder: '根目录', + testInTable: '测试', + testingInTable: '测试中...', + testTimeout: '测试超时(15秒)' + }, + enabled: '启用存储', + enabledHint: '启用后,Sora 生成的媒体文件将自动上传到存储', + endpoint: 'S3 端点', + region: '区域', + bucket: '存储桶', + prefix: '对象前缀', + accessKeyId: 'Access Key ID', + secretAccessKey: 'Secret Access Key', + secretConfigured: '(已配置,留空保持不变)', + cdnUrl: 'CDN URL', + cdnUrlHint: '可选,配置后使用 CDN URL 访问文件', + forcePathStyle: '强制路径风格(Path Style)', + defaultQuota: '默认存储配额', + defaultQuotaHint: '未在用户或分组级别指定配额时的默认值,0 表示无限制', + testConnection: '测试连接', + testing: '测试中...', + testSuccess: '连接测试成功', + testFailed: '连接测试失败', + saved: '存储设置保存成功', + saveFailed: '保存存储设置失败', + gdrive: { + authType: '认证方式', + serviceAccount: '服务账号', + clientId: 'Client ID', + clientSecret: 'Client Secret', + clientSecretConfigured: '(已配置,留空保持不变)', + refreshToken: 'Refresh Token', + refreshTokenConfigured: '(已配置,留空保持不变)', + serviceAccountJson: '服务账号 JSON', + serviceAccountConfigured: '(已配置,留空保持不变)', + folderId: 'Folder ID(可选)', + authorize: '授权 Google Drive', + authorizeHint: '通过 OAuth2 获取 Refresh Token', + oauthFieldsRequired: '请先填写 Client ID 和 Client Secret', + oauthSuccess: 'Google Drive 授权成功', + oauthFailed: 'Google Drive 授权失败', + closeWindow: '此窗口将自动关闭', + processing: '正在处理授权...', + testStorage: '测试存储', + testSuccess: 'Google Drive 存储测试成功(上传、访问、删除均正常)', + testFailed: 'Google Drive 存储测试失败' + } + }, + overloadCooldown: { + title: '529 过载冷却', + description: '配置上游返回 529(过载)时的账号调度暂停策略', + enabled: '启用过载冷却', + enabledHint: '收到 529 错误时暂停该账号的调度,冷却后自动恢复', + cooldownMinutes: '冷却时长(分钟)', + cooldownMinutesHint: '账号暂停调度的持续时间(1-120 分钟)', + saved: '过载冷却设置保存成功', + saveFailed: '保存过载冷却设置失败' + }, + rateLimit429Cooldown: { + title: '429 默认回避', + description: '配置上游返回 429 且没有明确重置时间时的默认账号回避策略', + enabled: '启用 429 默认回避', + enabledHint: '收到无重置时间的 429 时暂停该账号调度,冷却后自动恢复', + cooldownSeconds: '回避时长(秒)', + cooldownSecondsHint: '默认回避持续时间(1-7200 秒);上游返回明确 reset 时仍优先使用上游时间', + saved: '429 默认回避设置保存成功', + saveFailed: '保存 429 默认回避设置失败' + }, + streamTimeout: { + title: '流超时处理', + description: '配置上游响应超时时的账户处理策略,避免问题账户持续被选中', + enabled: '启用流超时处理', + enabledHint: '当上游响应超时时,自动处理问题账户', + timeoutSeconds: '超时阈值(秒)', + timeoutSecondsHint: '流数据间隔超过此时间视为超时(30-300秒)', + action: '处理方式', + actionTempUnsched: '临时不可调度', + actionError: '标记为错误状态', + actionNone: '不处理', + actionHint: '超时后对账户执行的操作', + tempUnschedMinutes: '暂停时长(分钟)', + tempUnschedMinutesHint: '临时不可调度的持续时间(1-60分钟)', + thresholdCount: '触发阈值(次数)', + thresholdCountHint: '累计超时多少次后触发处理(1-10次)', + thresholdWindowMinutes: '阈值窗口(分钟)', + thresholdWindowMinutesHint: '超时计数的时间窗口(1-60分钟)', + saved: '流超时设置保存成功', + saveFailed: '保存流超时设置失败' + }, + rectifier: { + title: '请求整流器', + description: '当上游返回特定错误时,自动修正请求参数并重试,提高请求成功率', + enabled: '启用请求整流器', + enabledHint: '总开关,关闭后所有整流功能均不生效', + thinkingSignature: 'Thinking 签名整流', + thinkingSignatureHint: '当上游返回 thinking block 签名校验错误时,自动去除签名并重试', + thinkingBudget: 'Thinking Budget 整流', + thinkingBudgetHint: '当上游返回 budget_tokens 约束错误(≥1024)时,自动将 budget 设为 32000 并重试', + apikeySignature: 'API Key 签名整流', + apikeySignatureHint: + '当 API Key 账号的上游返回签名相关错误时,自动去除签名并重试(内置规则始终生效)', + apikeyPatterns: '自定义匹配关键词', + apikeyPatternsHint: + '额外的关键词,匹配响应体中的内容(不区分大小写)。内置规则始终生效,此处用于补充额外匹配。', + apikeyPatternPlaceholder: '例如:thinking_error 或 签名无效', + addPattern: '添加关键词', + saved: '整流器设置保存成功', + saveFailed: '保存整流器设置失败' + }, + betaPolicy: { + title: 'Beta 策略', + description: '配置转发 Anthropic API 请求时如何处理 Beta 特性。仅适用于 /v1/messages 接口。', + action: '处理方式', + actionPass: '透传(不处理)', + actionFilter: '过滤(移除)', + actionBlock: '拦截(拒绝请求)', + scope: '生效范围', + scopeAll: '全部账号', + scopeOAuth: '仅 OAuth 账号', + scopeAPIKey: '仅 API Key 账号', + scopeBedrock: '仅 Bedrock 账号', + errorMessage: '错误消息', + errorMessagePlaceholder: '拦截时返回的自定义错误消息', + errorMessageHint: '留空则使用默认错误消息', + saved: 'Beta 策略设置保存成功', + saveFailed: '保存 Beta 策略设置失败', + modelWhitelist: '模型白名单', + modelWhitelistHint: '留空则对所有模型生效。支持精确匹配和通配符前缀(如 claude-opus-*)', + modelPatternPlaceholder: '例如: claude-opus-* 或 claude-opus-4-6', + addModelPattern: '添加模型规则', + removePattern: '移除', + fallbackAction: '未匹配模型处理方式', + fallbackActionHint: '当请求模型不在白名单中时的处理方式', + fallbackErrorMessagePlaceholder: '未匹配模型被拦截时返回的自定义错误消息', + quickPresets: '快捷预设', + presetOpusOnly: '仅 Opus 允许 1M', + presetOpusOnlyDesc: 'Opus 透传,其他模型过滤', + commonPatterns: '常用模式' + }, + openaiFastPolicy: { + title: 'OpenAI Fast/Flex 策略', + description: '基于请求体 service_tier 字段拦截/过滤/透传 OpenAI fast(priority) 与 flex 请求;仅作用于 OpenAI 网关。', + empty: '尚未配置任何规则。点击下方按钮新增。', + ruleHeader: '规则 #{index}', + removeRule: '删除规则', + addRule: '新增规则', + saveHint: '保存时随系统设置一起提交(点击页面底部「保存」按钮)。', + serviceTier: 'service_tier 匹配', + tierAll: '全部 tier', + tierPriority: 'priority(fast)', + tierFlex: 'flex', + action: '处理方式', + actionPass: '透传(保留 service_tier)', + actionFilter: '过滤(移除 service_tier)', + actionForcePriority: '强制设置 priority(fast)', + actionBlock: '拦截(拒绝请求)', + scope: '生效范围', + scopeAll: '全部账号', + scopeOAuth: '仅 OAuth 账号', + scopeAPIKey: '仅 API Key 账号', + scopeBedrock: '仅 Bedrock 账号', + errorMessage: '错误消息', + errorMessagePlaceholder: '拦截时返回的自定义错误消息', + errorMessageHint: '留空则使用默认错误消息。', + modelWhitelist: '模型白名单', + modelWhitelistHint: '留空表示对所有模型生效;支持精确匹配与通配符(如 gpt-5.5*)。', + modelPatternPlaceholder: '例如: gpt-5.5 或 gpt-5.5*', + addModelPattern: '添加模型规则', + fallbackAction: '未匹配模型处理方式', + fallbackActionHint: '当请求模型不在白名单中时的处理方式。', + fallbackErrorMessagePlaceholder: '未匹配模型被拦截时返回的自定义错误消息' + }, + wechatConnect: { + title: '微信登录', + description: '用于微信开放平台或公众号/小程序的第三方登录配置。', + enabledLabel: '启用微信登录', + enabledHint: '开启后可使用微信第三方登录回调与授权配置。', + appIdLabel: 'AppID', + appIdPlaceholder: '微信开放平台 AppID', + appSecretLabel: 'AppSecret', + appSecretConfiguredPlaceholder: '密钥已配置,留空以保留当前值。', + appSecretPlaceholder: '微信开放平台 AppSecret', + appSecretConfiguredHint: '密钥已配置,留空以保留当前值。', + appSecretHint: '填写后会覆盖当前微信密钥。', + modeLabel: '模式', + openModeLabel: '非微信环境使用开放平台', + openModeHint: '浏览器不在微信内时,自动走开放平台扫码授权。', + mpModeLabel: '微信环境使用公众号', + mpModeHint: '浏览器在微信内时,自动走公众号授权。', + redirectUrlLabel: '回调地址', + redirectUrlPlaceholder: 'https://your-site.com/api/v1/auth/oauth/wechat/callback', + generateAndCopy: '使用当前站点生成并复制', + redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板', + frontendRedirectUrlLabel: '前端回调地址', + frontendRedirectUrlPlaceholder: '/auth/wechat/callback', + frontendRedirectUrlHint: '通常用于前端路由回调地址,需与后端配置保持一致。' + }, + authSourceDefaults: { + title: '认证来源默认值', + description: '按注册来源配置新用户默认余额、并发、订阅与授权策略。', + requireEmailLabel: '第三方注册强制补充邮箱', + requireEmailHint: '启用后,Linux DO、OIDC、微信注册缺少邮箱时必须先补充邮箱地址。', + enabledHint: '以下默认值会在该来源注册新用户时发放;首次绑定时授权仅作用于已有账号绑定该来源。', + sources: { + email: { + title: '邮箱注册', + description: '适用于邮箱密码注册的新用户默认配额。' + }, + linuxdo: { + title: 'Linux DO 登录', + description: '适用于 Linux DO 第三方注册的新用户默认配额。' + }, + oidc: { + title: 'OIDC 登录', + description: '适用于 OIDC 第三方注册的新用户默认配额。' + }, + wechat: { + title: '微信登录', + description: '适用于微信第三方注册的新用户默认配额。' + } + }, + grantOnFirstBindLabel: '首次绑定时授权', + grantOnFirstBindHint: '已有账号首次绑定该来源时发放默认权益。', + defaultSubscriptionsLabel: '默认订阅', + defaultSubscriptionsHint: '仅对当前认证来源生效,未配置时不追加来源专属订阅。', + noSourceSubscriptions: '当前来源未配置专属默认订阅。', + platformQuotasOverride: '平台限额覆盖', + platformQuotasOverrideHint: '留空的字段继承「系统默认平台限额」;填 0 表示禁止该窗口使用。', + }, + paymentVisibleMethods: { + methodLabel: '{title} 可见方式', + methodHint: '控制前台结算页是否展示该方式,以及展示时使用的来源键。', + sourceLabel: '支付来源', + sourceHint: '启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。', + sourceRequiredError: '{title} 已启用,请先选择支付来源。' + }, + openaiExperimentalScheduler: { + title: 'OpenAI 实验调度策略', + description: '默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。', + stickyWeightedTitle: '粘性加权', + stickyWeightedDescription: '开启后 previous_response_id 和 session_hash 粘性进入高级调度打分;关闭时仍按旧逻辑硬命中粘性账号。', + subscriptionPriorityTitle: '订阅优先', + subscriptionPriorityDescription: '开启后先在 ChatGPT 订阅账号池中按权值选取;订阅池拿不到席位时再回退到非订阅账号池。', + weightsTitle: '调度权值覆盖', + weightsDescription: '留空时使用配置/环境变量值;配置未设置时使用内置默认值。页面非空设置优先。', + defaultPlaceholder: '配置/默认:{value}', + topKLabel: 'TopK', + priorityWeight: '优先级', + loadWeight: '负载', + queueWeight: '排队', + errorRateWeight: '错误率', + ttftWeight: '首包延迟', + resetWeight: '重置窗口', + quotaHeadroomWeight: '额度余量', + previousResponseWeight: 'previous_response 粘性', + sessionStickyWeight: 'session_hash 粘性' + }, + usageRecords: { + title: '使用记录', + description: '与终端用户可见的用量及失败请求记录相关的设置。', + }, + user_error_view: { + label: '允许用户查看自己的错误请求', + description: '开启后,用户可在用量页查看自己失败请求的精简信息(不含内部/上游错误细节)。需运维监控开启才有数据。', + }, + saveSettings: '保存设置', + saving: '保存中...', + settingsSaved: '设置保存成功', + smtpConnectionSuccess: 'SMTP 连接成功', + testEmailSent: '测试邮件发送成功', + failedToLoad: '加载设置失败', + failedToSave: '保存设置失败', + failedToTestSmtp: 'SMTP 连接测试失败', + failedToSendTestEmail: '发送测试邮件失败' + }, + + // Error Passthrough Rules + errorPassthrough: { + title: '错误透传规则', + description: '配置上游错误如何返回给客户端', + createRule: '创建规则', + editRule: '编辑规则', + deleteRule: '删除规则', + noRules: '暂无规则', + createFirstRule: '创建第一条错误透传规则', + allPlatforms: '所有平台', + passthrough: '透传', + custom: '自定义', + code: '状态码', + body: '消息体', + skipMonitoring: '跳过监控', + + // Columns + columns: { + priority: '优先级', + name: '名称', + conditions: '匹配条件', + platforms: '平台', + behavior: '响应行为', + status: '状态', + actions: '操作' + }, + + // Match Mode + matchMode: { + any: '错误码 或 关键词', + all: '错误码 且 关键词', + anyHint: '状态码匹配任一错误码,或消息包含任一关键词', + allHint: '状态码匹配任一错误码,且消息包含任一关键词' + }, + + // Form + form: { + name: '规则名称', + namePlaceholder: '例如:上下文超限透传', + priority: '优先级', + priorityHint: '数值越小优先级越高,优先匹配', + description: '规则描述', + descriptionPlaceholder: '描述此规则的用途...', + matchConditions: '匹配条件', + errorCodes: '错误码', + errorCodesPlaceholder: '422, 400, 429', + errorCodesHint: '多个错误码用逗号分隔', + keywords: '关键词', + keywordsPlaceholder: '每行一个关键词\ncontext limit\nmodel not supported', + keywordsHint: '每行一个关键词,不区分大小写', + matchMode: '匹配模式', + platforms: '适用平台', + platformsHint: '不选择表示适用于所有平台', + responseBehavior: '响应行为', + passthroughCode: '透传上游状态码', + responseCode: '自定义状态码', + passthroughBody: '透传上游错误信息', + customMessage: '自定义错误信息', + customMessagePlaceholder: '返回给客户端的错误信息...', + skipMonitoring: '跳过运维监控记录', + skipMonitoringHint: '开启后,匹配此规则的错误不会被记录到运维监控中', + enabled: '启用此规则' + }, + + // Messages + nameRequired: '请输入规则名称', + conditionsRequired: '请至少配置一个错误码或关键词', + ruleCreated: '规则创建成功', + ruleUpdated: '规则更新成功', + ruleDeleted: '规则删除成功', + deleteConfirm: '确定要删除规则 "{name}" 吗?', + failedToLoad: '加载规则失败', + failedToSave: '保存规则失败', + failedToDelete: '删除规则失败', + failedToToggle: '切换状态失败' + }, + + // TLS 指纹模板 + tlsFingerprintProfiles: { + title: 'TLS 指纹模板', + description: '管理 TLS 指纹模板,用于模拟特定客户端的 TLS 握手特征', + createProfile: '创建模板', + editProfile: '编辑模板', + deleteProfile: '删除模板', + noProfiles: '暂无模板', + createFirstProfile: '创建你的第一个 TLS 指纹模板', + + columns: { + name: '名称', + description: '描述', + grease: 'GREASE', + alpn: 'ALPN', + actions: '操作' + }, + + form: { + pasteYaml: '粘贴 YAML 配置', + pasteYamlPlaceholder: '将 TLS 指纹采集器复制的 YAML 粘贴到这里...', + pasteYamlHint: '粘贴从 TLS 指纹采集器复制的 YAML 配置,自动填充所有字段。', + openCollector: '打开采集器', + parseYaml: '解析 YAML', + yamlParsed: 'YAML 解析成功,字段已自动填充', + yamlParseFailed: 'YAML 解析失败:未找到 name 字段', + name: '模板名称', + namePlaceholder: '例如 macOS Node.js v24', + description: '描述', + descriptionPlaceholder: '可选的模板描述', + enableGrease: '启用 GREASE', + enableGreaseHint: '在 TLS ClientHello 扩展中插入 GREASE 值', + cipherSuites: '密码套件', + cipherSuitesHint: '逗号分隔的十六进制值,例如 0x1301, 0x1302, 0xc02c', + curves: '椭圆曲线', + curvesHint: '逗号分隔的曲线 ID', + pointFormats: '点格式', + signatureAlgorithms: '签名算法', + alpnProtocols: 'ALPN 协议', + alpnProtocolsHint: '逗号分隔,例如 h2, http/1.1', + supportedVersions: '支持的 TLS 版本', + keyShareGroups: '密钥共享组', + pskModes: 'PSK 模式', + extensions: '扩展' + }, + + deleteConfirm: '删除模板', + deleteConfirmMessage: '确定要删除模板 "{name}" 吗?使用此模板的账号将回退到内置默认值。', + createSuccess: '模板创建成功', + updateSuccess: '模板更新成功', + deleteSuccess: '模板删除成功', + loadFailed: '加载模板失败', + saveFailed: '保存模板失败', + deleteFailed: '删除模板失败' + } +} diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts new file mode 100644 index 0000000000..44fafef7a4 --- /dev/null +++ b/frontend/src/i18n/locales/zh/common.ts @@ -0,0 +1,416 @@ +export default { + common: { + loading: '加载中...', + submitting: '提交中...', + justNow: '刚刚', + peakRateTooltip: '高峰倍率:{window}', + peakRateImageNote: ';token 计费的图片 token 同样适用,图片按次计费不受高峰影响', + save: '保存', + saved: '保存成功', + deleted: '删除成功', + cancel: '取消', + delete: '删除', + edit: '编辑', + create: '创建', + update: '更新', + confirm: '确认', + reset: '重置', + search: '搜索', + filter: '筛选', + export: '导出', + import: '导入', + actions: '操作', + status: '状态', + name: '名称', + email: '邮箱', + password: '密码', + submit: '提交', + back: '返回', + next: '下一步', + yes: '是', + no: '否', + all: '全部', + none: '无', + selectAll: '全选', + noData: '暂无数据', + expand: '展开', + collapse: '收起', + success: '成功', + error: '错误', + critical: '严重', + warning: '警告', + info: '提示', + active: '启用', + inactive: '禁用', + more: '更多', + close: '关闭', + enabled: '已启用', + disabled: '已禁用', + total: '总计', + balance: '余额', + availableBalance: '可用余额', + frozenBalance: '冻结金额', + totalBalance: '总余额', + available: '可用', + copiedToClipboard: '已复制到剪贴板', + copied: '已复制', + copyFailed: '复制失败', + verifying: '验证中...', + processing: '处理中...', + contactSupport: '联系客服', + add: '添加', + invalidEmail: '请输入有效的邮箱地址', + optional: '可选', + selectOption: '请选择', + searchPlaceholder: '搜索...', + noOptionsFound: '无匹配选项', + noGroupsAvailable: '无可用分组', + unknownError: '发生未知错误', + saving: '保存中...', + selectedCount: '(已选 {count} 个)', + refresh: '刷新', + autoRefresh: { + title: '自动刷新', + enable: '启用自动刷新', + countdown: '自动刷新: {seconds}s', + seconds: '{n} 秒', + }, + view: '查看', + settings: '设置', + chooseFile: '选择文件', + copy: '复制', + notAvailable: '不可用', + now: '现在', + today: '今天', + tomorrow: '明天', + unknown: '未知', + minutes: '分钟', + time: { + never: '从未', + justNow: '刚刚', + minutesAgo: '{n}分钟前', + hoursAgo: '{n}小时前', + daysAgo: '{n}天前', + countdown: { + daysHours: '{d}d {h}h', + hoursMinutes: '{h}h {m}m', + minutes: '{m}m', + withSuffix: '{time} 后解除' + } + } + }, + + adminCompliance: { + title: '部署与运营合规确认', + blockingNotice: '继续使用控制台前,须完成部署与运营合规确认。', + riskNotice: '本确认用于以清晰、显著、可留痕的方式提示自部署实例的合规义务与运营风险。', + version: '协议版本', + openDocument: '在 GitHub 查看协议文件', + documentSource: '协议正文来自本项目仓库中的 Markdown 文件。修改协议内容时必须同步递增协议版本;已确认的旧版本将失效,控制台使用者须重新确认。', + inputLabel: '请逐字输入以下确认短语', + inputPlaceholder: '输入确认短语以继续', + inputMismatch: '确认短语不匹配,请逐字输入提示内容。', + legalNote: '本确认用于明确自部署实例与开源项目、著作权人、贡献者及维护者之间的非关联关系和责任边界;部署、运营或控制相关实例的主体应独立承担其适用义务。', + logout: '退出登录', + accept: '确认并继续', + accepted: '合规确认已记录', + acceptFailed: '提交确认失败' + }, + + legal: { + loadFailed: '文档加载失败', + retryLater: '请稍后刷新页面重试。', + notFound: '文档不存在', + notFoundDescription: '当前条款文档不存在或已被管理员移除。', + updatedAt: '更新日期:{date}', + empty: '暂无正文内容', + loginAgreement: '登录条款', + adminCompliance: '部署与运营合规承诺', + loginAgreementPrompt: { + checkboxPrefix: '我已阅读并同意', + documentSeparator: '、', + noticeTitle: '继续登录前需要先同意最新条款。', + noticeDescription: '未同意前,账号密码输入和快捷登录会保持禁用。', + viewTerms: '查看条款', + dialogTitle: '条款更新通知', + dialogDescription: '我们的服务条款已于 {date} 更新。在继续使用服务之前,请仔细阅读并同意以下条款。', + recently: '近期', + relatedDocuments: '相关文档', + reject: '拒绝', + accept: '同意并继续', + loginRejectedWarning: '未同意最新条款前,无法输入账号密码或使用快捷登录。', + loginRequiredWarning: '请先阅读并同意最新条款后再登录。', + registerRejectedWarning: '未同意最新条款前,无法注册或使用快捷登录。', + registerRequiredWarning: '请先阅读并同意最新条款后再注册。' + } + }, + + // Navigation + nav: { + dashboard: '仪表盘', + announcements: '公告', + apiKeys: 'API 密钥', + batchImage: '批量生图', + usage: '使用记录', + redeem: '兑换', + affiliate: '邀请返利', + affiliateManagement: '邀请返利', + affiliateInviteRecords: '邀请记录', + affiliateRebateRecords: '返利记录', + affiliateTransferRecords: '提取记录', + profile: '个人资料', + users: '用户管理', + groups: '分组管理', + channels: '渠道管理', + availableChannels: '可用渠道', + subscriptions: '订阅管理', + accounts: '账号管理', + proxies: 'IP管理', + redeemCodes: '兑换码', + ops: '运维监控', + promoCodes: '优惠码', + settings: '系统设置', + myAccount: '我的账户', + lightMode: '浅色模式', + darkMode: '深色模式', + collapse: '收起', + expand: '展开', + logout: '退出登录', + github: 'GitHub', + mySubscriptions: '我的订阅', + buySubscription: '充值/订阅', + docs: '文档', + myOrders: '我的订单', + orderManagement: '订单管理', + paymentDashboard: '支付概览', + paymentConfig: '支付配置', + paymentPlans: '订阅套餐', + channelManagement: '渠道管理', + channelPricing: '渠道定价', + channelMonitor: '渠道监控', + channelStatus: '渠道状态', + riskControl: '风控中心', + }, + + // Auth + auth: { + welcomeBack: '欢迎回来', + signInToAccount: '登录您的账户以继续', + signIn: '登录', + signingIn: '登录中...', + createAccount: '创建账户', + signUpToStart: '注册以开始使用 {siteName}', + signUp: '注册', + processing: '处理中...', + continue: '继续', + rememberMe: '记住我', + dontHaveAccount: '还没有账户?', + alreadyHaveAccount: '已有账户?', + registrationDisabled: '注册功能暂时关闭,请联系管理员。', + emailLabel: '邮箱', + emailPlaceholder: '请输入邮箱', + passwordLabel: '密码', + passwordPlaceholder: '请输入密码', + createPasswordPlaceholder: '创建一个安全的密码', + passwordHint: '至少 6 个字符', + emailRequired: '请输入邮箱', + invalidEmail: '请输入有效的邮箱地址', + passwordRequired: '请输入密码', + passwordMinLength: '密码至少需要 6 个字符', + loginFailed: '登录失败,请检查您的凭据后重试。', + errors: { + USER_NOT_ACTIVE: '账号已被禁用', + }, + registrationFailed: '注册失败,请重试。', + emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。', + emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}', + emailSuffixAllowedMore: '等 {count} 项', + loginSuccess: '登录成功!欢迎回来。', + accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。', + reloginRequired: '会话已过期,请重新登录。', + turnstileExpired: '验证已过期,请重试', + turnstileFailed: '验证失败,请重试', + completeVerification: '请完成验证', + verifyYourEmail: '验证您的邮箱', + sessionExpired: '会话已过期', + sessionExpiredDesc: '请返回注册页面重新开始。', + verificationCode: '验证码', + verificationCodeHint: '请输入发送到您邮箱的6位验证码', + sendingCode: '发送中...', + sendCode: '发送验证码', + clickToResend: '点击重新发送验证码', + resendCode: '重新发送验证码', + sendCodeDesc: '我们将发送验证码到', + codeSentSuccess: '验证码已发送!请查收您的邮箱。', + verifying: '验证中...', + verifyAndCreate: '验证并创建账户', + resendCountdown: '{countdown}秒后可重新发送', + backToRegistration: '返回注册', + sendCodeFailed: '发送验证码失败,请重试。', + verifyFailed: '验证失败,请重试。', + codeRequired: '请输入验证码', + invalidCode: '请输入有效的6位验证码', + promoCodeLabel: '优惠码', + promoCodePlaceholder: '输入优惠码(可选)', + promoCodeValid: '有效!注册后将获得 ${amount} 赠送余额', + promoCodeInvalid: '无效的优惠码', + promoCodeNotFound: '优惠码不存在', + promoCodeExpired: '此优惠码已过期', + promoCodeDisabled: '此优惠码已被禁用', + promoCodeMaxUsed: '此优惠码已达到使用上限', + promoCodeAlreadyUsed: '您已使用过此优惠码', + promoCodeValidating: '优惠码正在验证中,请稍候', + promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码', + invitationCodeLabel: '邀请码', + invitationCodePlaceholder: '请输入邀请码', + invitationCodeRequired: '请输入邀请码', + invitationCodeValid: '邀请码有效', + invitationCodeInvalid: '邀请码无效或已被使用', + invitationCodeValidating: '正在验证邀请码...', + invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试', + oauthOrContinue: '或使用其他继续', + linuxdo: { + signIn: '使用 Linux.do 登录', + orContinue: '或使用邮箱密码继续', + callbackTitle: '正在完成登录', + callbackProcessing: '正在验证登录信息,请稍候...', + callbackHint: '如果页面未自动跳转,请返回登录页重试。', + callbackMissingToken: '登录信息缺失,请返回重试。', + backToLogin: '返回登录', + invitationRequired: '该 Linux.do 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。', + invalidPendingToken: '注册凭证已失效,请重新使用 Linux.do 登录。', + completeRegistration: '完成注册', + completing: '正在完成注册...', + completeRegistrationFailed: '注册失败,请检查邀请码后重试。' + }, + dingtalk: { + signIn: '钉钉登录', + callbackTitle: '正在完成钉钉登录', + callbackProcessing: '正在验证钉钉登录信息,请稍候...', + callbackHint: '如果页面未自动跳转,请返回登录页重试。', + callbackMissingToken: '登录信息缺失,请返回重试。', + backToLogin: '返回登录', + invitationRequired: '该钉钉账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。', + invalidPendingToken: '注册凭证已失效,请重新使用钉钉登录。', + completeRegistration: '完成注册', + completing: '正在完成注册...', + completeRegistrationFailed: '注册失败,请检查邀请码后重试。', + createAccountTitle: '创建钉钉账户', + registrationDisabledRedirectToBind: '当前已禁止注册新账户,请使用已有账户邮箱和密码绑定钉钉登录', + error: { + title: '钉钉登录失败', + csrf: '登录会话已过期,请重新扫码登录', + corp_rejected: '您的钉钉账号不属于本企业,请联系管理员', + dingtalk_not_enabled: '钉钉登录暂未启用', + upstream_error: '钉钉服务暂时不可用,请稍后重试', + missing_browser_session: '浏览器会话丢失,请重新登录', + missing_params: '请求参数不完整', + invalid_state: '登录状态异常', + provider_error: '钉钉授权失败', + session_error: '会话创建失败,请重试', + retry: '重新登录' + } + }, + emailOAuth: { + signIn: '使用 {providerName} 登录' + }, + oidc: { + signIn: '使用 {providerName} 登录', + callbackTitle: '正在完成 {providerName} 登录', + callbackProcessing: '正在验证 {providerName} 登录信息,请稍候...', + callbackHint: '如果页面未自动跳转,请返回登录页重试。', + callbackMissingToken: '登录信息缺失,请返回重试。', + backToLogin: '返回登录', + invitationRequired: '该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。', + invalidPendingToken: '注册凭证已失效,请重新登录。', + completeRegistration: '完成注册', + completing: '正在完成注册...', + completeRegistrationFailed: '注册失败,请检查邀请码后重试。' + }, + oauthFlow: { + profileDetailsTitle: '使用 {providerName} 资料', + profileDetailsDescription: '选择是否将 {providerName} 的昵称或头像应用到当前账户。', + useDisplayName: '使用昵称', + useAvatar: '使用头像', + avatarAlt: '{providerName} 头像', + reviewProfileBeforeContinue: '请先确认 {providerName} 资料后再继续。', + chooseHowToContinue: '选择后续操作', + chooseAccountActionHint: '请选择绑定已有账户,或创建一个新账户。', + suggestedEmail: '建议邮箱:{email}', + bindExistingAccount: '绑定已有账户', + createNewAccount: '创建新账户', + createAccountHint: '请输入邮箱地址以创建账户并继续。', + bindLoginHint: '登录一个已有账户以绑定此次 {providerName} 登录。', + signInThenBindDescription: '请先登录已有账户,再将此次 {providerName} 登录绑定到该账户。', + bindSignInToExistingAccount: '将此次 {providerName} 登录绑定到已有账户。', + bindCurrentAccountTitle: '绑定当前账户', + bindCurrentAccountDescription: '将此次 {providerName} 登录绑定到当前浏览器已登录的账户。', + bindCurrentAccount: '绑定当前账户', + logInAndBind: '登录并绑定', + useDifferentEmail: '使用其他邮箱', + backToOptions: '返回选项', + yourAccount: '当前账户', + totpHint: '请输入 {account} 的 6 位验证码,以完成此次 {providerName} 登录绑定。', + verifyAndContinue: '验证并继续', + wechatAvailabilityUnknown: '暂时无法确认微信登录可用性,请刷新后重试。', + wechatSystemBrowserOnly: '当前微信登录流程仅支持在系统浏览器中继续。', + wechatBrowserOnly: '当前微信登录流程仅支持在微信内置浏览器中继续。', + wechatNotConfigured: '微信登录尚未配置。' + }, + linuxdoCallbackPageTitle: 'LinuxDo 登录回调', + dingtalkCallbackPageTitle: '钉钉登录回调', + dingtalkProviderName: '钉钉', + oidcCallbackPageTitle: 'OIDC 登录回调', + oauthCallbackPageTitle: 'OAuth 回调', + wechatProviderName: '微信', + wechatCallbackPageTitle: '微信登录回调', + wechatPaymentCallbackPageTitle: '微信支付回调', + wechatPayment: { + callbackTitle: '正在恢复微信支付', + callbackProcessing: '正在恢复微信支付...', + backToPayment: '返回支付页', + callbackMissingResumeToken: '微信支付回调缺少恢复令牌。' + }, + oauth: { + callbackTitle: 'OAuth 回调', + callbackHint: '按需将授权码和状态值复制回后台授权流程。', + invalidCallbackTitle: '无效的登录回调', + invalidCallbackHint: '当前页面缺少有效的授权结果,请返回登录页重新发起快捷登录。', + code: '授权码', + state: '状态', + fullUrl: '完整URL' + }, + // 忘记密码 + forgotPassword: '忘记密码?', + forgotPasswordTitle: '重置密码', + forgotPasswordHint: '输入您的邮箱地址,我们将向您发送密码重置链接。', + sendResetLink: '发送重置链接', + sendingResetLink: '发送中...', + sendResetLinkFailed: '发送重置链接失败,请重试。', + resetEmailSent: '重置链接已发送', + resetEmailSentHint: + '如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。', + backToLogin: '返回登录', + rememberedPassword: '想起密码了?', + // 重置密码 + resetPasswordTitle: '设置新密码', + resetPasswordHint: '请在下方输入您的新密码。', + newPassword: '新密码', + newPasswordPlaceholder: '输入新密码', + confirmPassword: '确认密码', + confirmPasswordPlaceholder: '再次输入新密码', + confirmPasswordRequired: '请确认您的密码', + passwordsDoNotMatch: '两次输入的密码不一致', + resetPassword: '重置密码', + resettingPassword: '重置中...', + resetPasswordFailed: '重置密码失败,请重试。', + passwordResetSuccess: '密码重置成功', + passwordResetSuccessHint: '您的密码已重置。现在可以使用新密码登录。', + invalidResetLink: '无效的重置链接', + invalidResetLinkHint: '此密码重置链接无效或已过期。请重新请求一个新链接。', + requestNewResetLink: '请求新的重置链接', + invalidOrExpiredToken: '密码重置链接无效或已过期。请重新请求一个新链接。' + }, + + // Dashboard +} diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts new file mode 100644 index 0000000000..2ce3eda5bb --- /dev/null +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -0,0 +1,816 @@ +export default { + dashboard: { + title: '仪表盘', + welcomeMessage: '欢迎回来!这是您账户的概览。', + balance: '余额', + apiKeys: 'API 密钥', + todayRequests: '今日请求', + todayCost: '今日消费', + todayTokens: '今日 Token', + totalTokens: '累计 Token', + cacheToday: '今日缓存', + performance: '性能指标', + avgResponse: '平均响应', + averageTime: '平均时间', + timeRange: '时间范围', + granularity: '粒度', + day: '按天', + hour: '按小时', + modelDistribution: '模型分布', + groupDistribution: '分组使用分布', + platformBreakdown: '按平台拆分', + platformBreakdownEmpty: '暂无平台用量', + platformCount: '{count} 个平台', + platformOther: '其他', + platformQuota: { + title: '配额用量', + daily: '日', + weekly: '周', + monthly: '月(近30天)', + resetsAt: '{time} 重置', + noLimit: '不限制', + disabled: '已禁用', + }, + tokenUsageTrend: 'Token 使用趋势', + noDataAvailable: '暂无数据', + model: '模型', + group: '分组', + noGroup: '无分组', + requests: '请求', + tokens: 'Token', + actual: '实际', + standard: '标准', + input: '输入', + output: '输出', + cache: '缓存', + recentUsage: '最近使用', + last7Days: '近 7 天', + noUsageRecords: '暂无使用记录', + startUsingApi: '开始使用 API 后,您的使用历史将显示在这里。', + viewAllUsage: '查看全部', + quickActions: '快捷操作', + createApiKey: '创建 API 密钥', + generateNewKey: '生成新的 API 密钥', + batchImageAgent: '批量生图助手', + batchImageAgentDesc: '复制给 Agent 的任务说明', + viewUsage: '查看使用记录', + checkDetailedLogs: '查看详细的使用日志', + redeemCode: '兑换码', + addBalanceWithCode: '使用兑换码充值' + }, + + // Groups (shared) + groups: { + subscription: '订阅' + }, + + // API Keys + keys: { + title: 'API 密钥', + description: '管理您的 API 密钥和访问令牌', + searchPlaceholder: '搜索名称或Key...', + endpoints: { + title: 'API 端点', + default: '默认', + copied: '已复制', + copiedHint: '已复制到剪贴板', + clickToCopy: '点击可复制此端点', + speedTest: '测速', + }, + allGroups: '全部分组', + allStatus: '全部状态', + columnSettings: '列设置', + columnAlwaysVisible: '该列固定显示,不可隐藏', + createKey: '创建密钥', + editKey: '编辑密钥', + deleteKey: '删除密钥', + deleteConfirmMessage: "确定要删除 '{name}' 吗?此操作无法撤销。", + apiKey: 'API 密钥', + group: '分组', + currentConcurrency: '当前并发', + noGroup: '无分组', + searchGroup: '搜索分组...', + noGroupFound: '未找到匹配的分组', + created: '创建时间', + copyToClipboard: '复制到剪贴板', + copied: '已复制!', + importToCcSwitch: '导入到 CCS', + enable: '启用', + disable: '禁用', + nameLabel: '名称', + namePlaceholder: '我的 API 密钥', + groupLabel: '分组', + selectGroup: '选择分组', + statusLabel: '状态', + selectStatus: '选择状态', + saving: '保存中...', + noKeysYet: '暂无 API 密钥', + createFirstKey: '创建您的第一个 API 密钥以开始使用 API。', + keyCreatedSuccess: 'API 密钥创建成功', + keyUpdatedSuccess: 'API 密钥更新成功', + keyDeletedSuccess: 'API 密钥删除成功', + keyEnabledSuccess: 'API 密钥已启用', + keyDisabledSuccess: 'API 密钥已禁用', + failedToLoad: '加载 API 密钥失败', + failedToSave: '保存 API 密钥失败', + failedToDelete: '删除 API 密钥失败', + failedToUpdateStatus: '更新 API 密钥状态失败', + clickToChangeGroup: '点击更换分组', + groupChangedSuccess: '分组更换成功', + failedToChangeGroup: '更换分组失败', + groupRequired: '请选择分组', + usage: '用量', + today: '今日', + total: '近30天', + quota: '额度', + lastUsedAt: '上次使用时间', + useKey: '使用密钥', + useKeyModal: { + title: '使用 API 密钥', + description: '将以下环境变量添加到您的终端配置文件或直接在终端中运行。', + copy: '复制', + copied: '已复制', + note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。', + noGroupTitle: '请先分配分组', + noGroupDescription: + '此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。', + openai: { + description: '将以下配置文件添加到 Codex CLI 配置目录中。', + configTomlHint: '请确保以下内容位于 config.toml 文件的开头部分', + note: '请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。', + noteWindows: + '按 Win+R,输入 %userprofile%\\.codex 打开配置目录。如目录不存在,请先手动创建。' + }, + cliTabs: { + claudeCode: 'Claude Code', + geminiCli: 'Gemini CLI', + codexCli: 'Codex CLI', + codexCliWs: 'Codex CLI (WebSocket)', + opencode: 'OpenCode' + }, + antigravity: { + description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。', + claudeCode: 'Claude Code', + geminiCli: 'Gemini CLI', + claudeNote: + '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。', + geminiNote: + '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。' + }, + gemini: { + description: + '将以下环境变量添加到您的终端配置文件或直接在终端中运行,以配置 Gemini CLI 访问。', + modelComment: '如果你有 Gemini 3 权限可以填:gemini-3-pro-preview', + note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。' + }, + opencode: { + title: 'OpenCode 配置示例', + subtitle: 'opencode.json', + hint: '配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。' + } + }, + customKeyLabel: '自定义密钥', + customKeyPlaceholder: '输入自定义密钥(至少16个字符)', + customKeyHint: '仅允许字母、数字、下划线和连字符,最少16个字符。', + customKeyTooShort: '自定义密钥至少需要16个字符', + customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符', + customKeyRequired: '请输入自定义密钥', + ipRestriction: 'IP 限制', + ipWhitelist: 'IP 白名单', + ipWhitelistPlaceholder: '192.168.1.100\n10.0.0.0/8', + ipWhitelistHint: '每行一个 IP 或 CIDR,设置后仅允许这些 IP 使用此密钥', + ipBlacklist: 'IP 黑名单', + ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16', + ipBlacklistHint: '每行一个 IP 或 CIDR,这些 IP 将被禁止使用此密钥', + ipRestrictionEnabled: '已配置 IP 限制', + ccSwitchNotInstalled: + 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。', + ccsClientSelect: { + title: '选择客户端', + description: '请选择您要导入到 CC-Switch 的客户端类型:', + claudeCode: 'Claude Code', + claudeCodeDesc: '导入为 Claude Code 配置', + geminiCli: 'Gemini CLI', + geminiCliDesc: '导入为 Gemini CLI 配置' + }, + // 配额和有效期 + quotaLimit: '额度限制', + quotaAmount: '额度金额 (USD)', + quotaAmountPlaceholder: '输入 USD 额度限制', + quotaAmountHint: '设置此密钥可消费的最大金额。0 = 无限制。', + quotaUsed: '已用额度', + reset: '重置', + resetQuotaUsed: '将已用额度重置为 0', + resetQuotaTitle: '确认重置额度', + resetQuotaConfirmMessage: '确定要将密钥 "{name}" 的已用额度(${used})重置为 0 吗?此操作不可撤销。', + quotaResetSuccess: '额度重置成功', + failedToResetQuota: '重置额度失败', + rateLimitColumn: '速率限制', + rateLimitSection: '速率限制', + resetUsage: '重置', + rateLimit5h: '5小时限额 (USD)', + rateLimit1d: '日限额 (USD)', + rateLimit7d: '7天限额 (USD)', + rateLimitHint: '设置此密钥在指定时间窗口内的最大消费额。0 = 无限制。', + rateLimitUsage: '速率限制用量', + resetRateLimitUsage: '重置速率限制用量', + resetRateLimitTitle: '确认重置速率限制', + resetRateLimitConfirmMessage: '确定要重置密钥 "{name}" 的速率限制用量吗?所有时间窗口的已用额度将归零。此操作不可撤销。', + rateLimitResetSuccess: '速率限制已重置', + failedToResetRateLimit: '重置速率限制失败', + resetNow: '即将重置', + expiration: '密钥有效期', + expiresInDays: '{days} 天', + extendDays: '+{days} 天', + customDate: '自定义', + expirationDate: '过期时间', + expirationDateHint: '选择此 API 密钥的过期时间。', + currentExpiration: '当前过期时间', + expiresAt: '过期时间', + noExpiration: '永久有效', + status: { + active: '活跃', + inactive: '已停用', + quota_exhausted: '额度耗尽', + expired: '已过期' + } + }, + + // Usage + usage: { + title: '使用记录', + description: '查看和分析您的 API 使用历史', + costDetails: '费用明细', + tokenDetails: 'Token 明细', + cacheTtlOverriddenHint: '缓存 TTL Override 已启用', + cacheTtlOverriddenLabel: 'TTL 替换', + cacheTtlOverridden5m: '按 5m 计费', + cacheTtlOverridden1h: '按 1h 计费', + totalRequests: '总请求数', + totalTokens: '总 Token', + cacheTotal: '缓存', + cacheBreakdown: '缓存 Token 明细', + cacheCreationTokensLabel: '缓存创建', + cacheReadTokensLabel: '缓存读取', + totalCost: '总消费', + standardCost: '标准', + actualCost: '实际', + accountCost: '成本', + userBilled: '用户扣费', + accountBilled: '账号计费', + resetNow: '现在', + resetPending: '待刷新', + accountMultiplier: '账号倍率', + avgDuration: '平均耗时', + inSelectedRange: '所选范围内', + perRequest: '每次请求', + apiKeyFilter: 'API 密钥', + allApiKeys: '全部密钥', + timeRange: '时间范围', + exportCsv: '导出 CSV', + exportExcel: '导出 Excel', + exportingProgress: '正在导出数据...', + exportedCount: '已导出 {current}/{total} 条', + estimatedTime: '预计剩余时间:{time}', + cancelExport: '取消导出', + exportCancelled: '导出已取消', + exporting: '导出中...', + preparingExport: '正在准备导出...', + model: '模型', + requestedModel: '请求', + upstreamModel: '上游', + reasoningEffort: '推理强度', + endpoint: '端点', + endpointDistribution: '端点分布', + inbound: '入站', + upstream: '上游', + mapping: '映射', + path: '路径', + inboundEndpoint: '入站端点', + upstreamEndpoint: '上游端点', + type: '类型', + tokens: 'Token', + cost: '费用', + firstToken: '首 Token', + duration: '耗时', + time: '时间', + ws: 'WS', + stream: '流式', + sync: '同步', + cyber: '安全策略', + unknown: '未知', + in: '输入', + out: '输出', + cacheHit: '缓存命中', + cacheCreate: '缓存创建', + cacheHitRate: '缓存命中率', + inputTokenPrice: '输入单价', + outputTokenPrice: '输出单价', + perMillionTokens: '/ 1M Token', + unitPrice: '单次价格', + imageUnitPrice: '单张价格', + imageTotalPrice: '图片总价', + imageCount: '图片张数', + imageBillingSize: '计费尺寸', + imageInputSize: '输入尺寸', + imageOutputSize: '输出尺寸', + imageOutputTokens: '图片输出 Token', + imageOutputTokenPrice: '图片输出单价', + imageOutputCost: '图片输出费用', + imageSizeSource: '尺寸来源', + imageSizeBreakdown: '尺寸明细', + imageSizeSourceOutput: '上游输出', + imageSizeSourceInput: '请求输入', + imageSizeSourceDefault: '默认计费档位', + imageSizeSourceLegacy: '历史记录', + imageSizeSourceMissing: '未记录', + imageSizeNotRecorded: '未记录', + imageSizeLegacyUnstandardized: '历史非标准', + imageSizeUnknown: '未知', + cacheRead: '读取', + cacheWrite: '写入', + serviceTier: '服务档位', + serviceTierPriority: 'Fast', + serviceTierFlex: 'Flex', + serviceTierStandard: 'Standard', + rate: '倍率', + original: '原始', + billed: '计费', + noRecords: '未找到使用记录,请尝试调整筛选条件。', + failedToLoad: '加载使用记录失败', + noDataToExport: '没有可导出的数据', + exportSuccess: '使用数据导出成功', + exportFailed: '使用数据导出失败', + exportExcelSuccess: '使用数据导出成功(Excel格式)', + exportExcelFailed: '使用数据导出失败', + imageUnit: '张', + userAgent: 'User-Agent', + ipGeo: { + fetch: '获取地区', + fetching: '获取中...', + failed: '获取失败', + private: '内网地址', + refreshTitle: '刷新地区信息', + batchFetch: '批量获取地区', + batchFetching: '获取中...', + pending: '{count} 个 IP 待获取地区', + batchFailed: '批量获取地区信息失败', + detailOrg: '运营商', + detailTimezone: '时区', + detailAccuracy: '定位精度', + detailCoordinates: '坐标', + }, + tabs: { usage: '用量明细', errors: '错误请求' }, + errors: { + time: '时间', model: '模型', endpoint: '端点', status: '状态码', + category: '分类', platform: '平台', message: '错误信息', + keyName: 'Key 名称', keyDeleted: '已删除', allKeys: '全部 Key', + modelPlaceholder: '搜索模型', allCategories: '全部分类', allStatuses: '全部状态码', + empty: '暂无错误请求', failedToLoad: '加载错误请求失败', + categories: { + auth: '认证失败', rate_limit: '限流', quota: '余额/订阅', + invalid_request: '参数错误', service_unavailable: '服务暂时不可用', + upstream: '上游错误', internal: '平台错误', other: '其他', cyber: '安全策略', + }, + detail: { + title: '错误请求详情', + responseBody: '上游响应内容', + upstreamStatus: '上游状态码', + loadFailed: '加载详情失败,请稍后重试', + }, + }, + }, + + // Shared keys for channel monitor (admin + user views) + monitorCommon: { + status: { + operational: '正常', + degraded: '降级', + failed: '失败', + error: '错误', + unknown: '-' + }, + providers: { + openai: 'OpenAI', + anthropic: 'Anthropic', + gemini: 'Gemini' + }, + extraModelsHeader: '附加模型', + extraModelsEmpty: '无附加模型', + latencyEmpty: '-', + availabilityPrefix: '可用性', + dialogLatency: '对话延迟', + endpointPing: '端点 PING', + history60pts: '近 {n} 次记录', + nextUpdateIn: '{n}s 后刷新', + past: 'PAST', + now: 'NOW', + maintenancePaused: '维护中 · 已暂停时间线采集', + extraModelsCount: '+ {n} 模型', + pollEvery: '{n}s 轮询', + updatedAt: '更新于 {time}', + relativeSecondsAgo: '{n} 秒前', + relativeMinutesAgo: '{n} 分钟前', + relativeHoursAgo: '{n} 小时前', + relativeDaysAgo: '{n} 天前' + }, + + // Channel Status (user-facing read-only view) + channelStatus: { + title: '渠道状态', + description: '查看渠道可用性、延迟和近期状态', + searchPlaceholder: '搜索渠道...', + allProviders: '全部供应商', + loadError: '加载渠道状态失败', + detailLoadError: '加载渠道详情失败', + detailTitle: '渠道详情', + closeDetail: '关闭', + windowTab: { + '7d': '7 天', + '15d': '15 天', + '30d': '30 天' + }, + overall: { + operational: 'OPERATIONAL', + degraded: 'DEGRADED', + unavailable: 'UNAVAILABLE' + }, + columns: { + name: '名称', + provider: '供应商', + groupName: '分组', + primaryModel: '主模型', + availability7d: '7 天可用率', + latency: '延迟 (ms)' + }, + detailColumns: { + model: '模型', + latestStatus: '最新状态', + latestLatency: '最新延迟 (ms)', + availability7d: '7 天可用率', + availability15d: '15 天可用率', + availability30d: '30 天可用率', + avgLatency7d: '7 天平均延迟 (ms)' + }, + empty: { + title: '暂无可显示的渠道', + description: '管理员尚未配置可监控的渠道。' + } + }, + + // Available Channels (user-facing) + availableChannels: { + title: '可用渠道', + description: '查看您可访问的渠道与其支持的模型、定价', + searchPlaceholder: '搜索渠道或模型...', + empty: '暂无可用渠道', + noModels: '未配置模型', + noPricing: '未配置定价', + exclusive: '专属', + public: '公开', + exclusiveTooltip: '管理员授权给你的专属分组', + publicTooltip: '对所有用户公开的分组', + columns: { + name: '渠道名', + description: '描述', + platform: '平台', + groups: '我可访问的分组', + supportedModels: '支持模型' + }, + pricing: { + billingMode: '计费模式', + billingModeToken: '按 Token', + billingModePerRequest: '按次', + billingModeImage: '按图片', + inputPrice: '输入', + outputPrice: '输出', + cacheWritePrice: '缓存写入', + cacheReadPrice: '缓存读取', + imageOutputPrice: '图片输出', + perRequestPrice: '每次请求', + intervals: '阶梯定价', + unitPerMillion: '/ 1M token', + unitPerRequest: '/ 次' + } + }, + + affiliate: { + title: '邀请返利', + description: '邀请新用户注册,并将返利额度转入账户余额', + yourCode: '我的邀请码', + inviteLink: '邀请链接', + copyCode: '复制邀请码', + copyLink: '复制链接', + codeCopied: '邀请码已复制', + linkCopied: '邀请链接已复制', + loadFailed: '加载邀请返利数据失败', + transferFailed: '转入余额失败', + stats: { + rebateRate: '我的返利比例', + rebateRateHint: '被邀请用户每次充值后你可获得的返利比例', + invitedUsers: '邀请人数', + availableQuota: '可转返利额度', + frozenQuota: '冻结中', + frozenQuotaHint: '新产生的返利正在冻结期中', + totalQuota: '历史返利额度' + }, + transfer: { + title: '返利额度转余额', + description: '将当前可用返利额度一键转入账户余额', + button: '转入余额', + transferring: '转入中...', + empty: '当前没有可转入额度', + success: '已转入余额:{amount}' + }, + invitees: { + title: '已邀请用户', + empty: '暂无邀请记录', + columns: { + email: '邮箱', + username: '用户名', + rebate: '返利明细', + joinedAt: '注册时间' + } + }, + tips: { + title: '使用说明', + line1: '将邀请码或邀请链接分享给新用户。', + line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。', + line3: '返利额度可随时转入账户余额。', + line4: '新产生的返利需要经过冻结期后才能提现。' + } + }, + + // Redeem + redeem: { + title: '兑换码', + description: '输入兑换码以充值余额或增加并发数', + currentBalance: '当前余额', + concurrency: '并发数', + requests: '请求', + redeemCodeLabel: '兑换码', + redeemCodePlaceholder: '请输入兑换码', + redeemCodeHint: '兑换码区分大小写', + redeeming: '兑换中...', + redeemButton: '兑换', + redeemSuccess: '兑换成功!', + redeemFailed: '兑换失败', + added: '已添加', + concurrentRequests: '并发请求', + newBalance: '新余额', + newConcurrency: '新并发数', + aboutCodes: '关于兑换码', + codeRule1: '每个兑换码只能使用一次', + codeRule2: '兑换码可以增加余额、并发数或试用权限', + codeRule3: '如有兑换问题,请联系客服', + codeRule4: '余额和并发数即时更新', + recentActivity: '最近活动', + historyWillAppear: '您的兑换历史将显示在这里', + balanceAddedRedeem: '余额充值(兑换)', + balanceAddedAffiliate: '余额充值(返利转入)', + balanceAddedAdmin: '余额充值(管理员)', + balanceDeductedAdmin: '余额扣除(管理员)', + concurrencyAddedRedeem: '并发增加(兑换)', + concurrencyAddedAdmin: '并发增加(管理员)', + concurrencyReducedAdmin: '并发减少(管理员)', + adminAdjustment: '管理员调整', + subscriptionAssigned: '订阅已分配', + subscriptionAssignedDesc: '您已获得 {groupName} 的访问权限', + subscriptionDays: '{days} 天', + days: '天', + codeRedeemSuccess: '兑换成功!', + failedToRedeem: '兑换失败,请检查兑换码后重试。', + subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。', + pleaseEnterCode: '请输入兑换码' + }, + + // Profile + profile: { + title: '个人设置', + description: '管理您的账户信息和设置', + accountBalance: '账户余额', + concurrencyLimit: '并发限制', + rpmLimit: 'RPM 限制', + rpmUnlimited: '不限制', + memberSince: '注册时间', + overviewTitle: '账户总览', + overviewDescription: '快速查看账号状态、资料来源与常用设置。', + basicsTitle: '资料与头像', + basicsDescription: '维护公开展示信息,并保持头像与昵称风格一致。', + linkedProfileSources: '资料来源', + linkedProfileSourcesDescription: '部分头像和昵称可能同步自第三方登录方式。', + securityTitle: '安全设置', + securityDescription: '密码、双因素认证和通知提醒集中放在右侧。', + administrator: '管理员', + user: '用户', + username: '用户名', + email: '邮箱', + status: '状态', + role: '角色', + enterUsername: '输入用户名', + editProfile: '编辑个人资料', + updateProfile: '更新资料', + updating: '更新中...', + updateSuccess: '资料更新成功', + updateFailed: '资料更新失败', + usernameRequired: '用户名不能为空', + changePassword: '修改密码', + currentPassword: '当前密码', + newPassword: '新密码', + confirmNewPassword: '确认新密码', + passwordHint: '密码至少需要 8 个字符', + changingPassword: '修改中...', + changePasswordButton: '修改密码', + passwordsNotMatch: '两次输入的密码不一致', + passwordTooShort: '密码至少需要 8 个字符', + passwordChangeSuccess: '密码修改成功', + passwordChangeFailed: '密码修改失败', + // TOTP 2FA + totp: { + title: '双因素认证 (2FA)', + description: '使用 Google Authenticator 等应用增强账户安全', + enabled: '已启用', + enabledAt: '启用时间', + notEnabled: '未启用', + notEnabledHint: '启用双因素认证可以增强账户安全性', + enable: '启用', + disable: '禁用', + featureDisabled: '功能未开放', + featureDisabledHint: '管理员尚未开放双因素认证功能', + setupTitle: '设置双因素认证', + setupStep1: '使用认证器应用扫描下方二维码', + setupStep2: '输入应用显示的 6 位验证码', + manualEntry: '无法扫码?手动输入密钥:', + enterCode: '输入 6 位验证码', + verify: '验证', + setupFailed: '获取设置信息失败', + verifyFailed: '验证码错误,请重试', + enableSuccess: '双因素认证已启用', + disableTitle: '禁用双因素认证', + disableWarning: '禁用后,登录时将不再需要验证码。这可能会降低您的账户安全性。', + enterPassword: '请输入当前密码确认', + confirmDisable: '确认禁用', + disableSuccess: '双因素认证已禁用', + disableFailed: '禁用失败,请检查密码是否正确', + loginTitle: '双因素认证', + loginHint: '请输入您认证器应用显示的 6 位验证码', + loginFailed: '验证失败,请重试', + // New translations for email verification + verifyEmailFirst: '请先验证您的邮箱', + verifyPasswordFirst: '请先验证您的身份', + emailCode: '邮箱验证码', + enterEmailCode: '请输入 6 位验证码', + sendCode: '发送验证码', + codeSent: '验证码已发送到您的邮箱', + sendCodeFailed: '发送验证码失败' + }, + balanceNotify: { + title: '余额不足提醒', + description: '当账户余额低于阈值时发送邮件提醒', + enabled: '启用余额不足提醒', + threshold: '自定义提醒阈值', + thresholdHint: '留空使用系统默认值', + thresholdPlaceholder: '输入金额', + systemDefault: '系统默认值', + extraEmails: '通知邮箱', + extraEmailsHint: '必须添加并验证邮箱后,余额不足时才能收到提醒邮件', + primaryEmail: '主邮箱', + noExtraEmails: '暂无额外通知邮箱', + enterEmail: '输入邮箱地址', + addEmail: '添加邮箱', + emailPlaceholder: '输入邮箱地址', + sendCode: '发送验证码', + resend: '重发', + codeSent: '验证码已发送', + codeSentTo: '验证码已发送到 {email}', + enterCode: '输入验证码', + codePlaceholder: '6位验证码', + verify: '验证', + emailAdded: '邮箱已添加', + emailRemoved: '邮箱已移除', + verifySuccess: '邮箱添加成功', + removeEmail: '移除', + removeSuccess: '邮箱已移除', + emailDuplicate: '该邮箱已存在', + maxEmailsReached: '已达到通知邮箱数量上限', + unverified: '未验证', + verified: '已验证', + }, + avatar: { + title: '资料头像', + description: '仅支持上传头像图片;静态图片会自动压缩到 20KB 以内后再保存。', + uploadAction: '上传图片', + uploadHint: '上传图片时会自动压缩静态图片到 20KB 以内,GIF 需自行控制在 20KB 以内', + uploadRequired: '请先上传头像图片', + saveSuccess: '头像已更新', + deleteSuccess: '头像已删除', + invalidType: '请选择图片文件', + gifTooLarge: 'GIF 头像必须在 20KB 以内', + compressTooLarge: '无法将图片压缩到 20KB 以内,请换一张更小的图片', + compressFailed: '压缩所选图片失败', + readFailed: '读取所选图片失败', + emptyDeleteHint: '当前没有可删除的头像', + }, + authBindings: { + title: '登录方式绑定', + description: '查看当前绑定状态,并将更多第三方登录方式关联到这个账号。', + bindAction: '绑定 {providerName}', + bindSuccess: '账号绑定成功', + emailPlaceholder: '输入邮箱地址', + codePlaceholder: '输入验证码', + passwordPlaceholder: '设置登录密码', + replaceEmailPasswordPlaceholder: '输入当前密码', + sendCodeAction: '发送验证码', + manageEmailAction: '管理邮箱', + hideEmailFormAction: '收起邮箱表单', + confirmEmailBindAction: '绑定邮箱', + confirmEmailReplaceAction: '更换主邮箱', + codeSentTo: '验证码已发送到 {email}', + replaceSuccess: '主邮箱已更新', + unbindAction: '解绑', + unbindSuccess: '{providerName} 已解绑', + boundCount: '已关联 {count} 条记录', + status: { + bound: '已绑定', + notBound: '未绑定', + }, + providers: { + email: '邮箱', + linuxdo: 'LinuxDo', + dingtalk: '钉钉', + oidc: '{providerName}', + wechat: '微信', + }, + notes: { + emailManagedFromProfile: '主邮箱在资料表单中管理', + canUnbind: '你可以解绑这个登录方式。', + bindAnotherBeforeUnbind: '请先绑定其他登录方式,再解除当前绑定。', + }, + source: { + avatar: '头像当前来自 {providerName}', + username: '昵称当前来自 {providerName}', + }, + } + }, + + // Empty States + empty: { + noData: '暂无数据' + }, + + // Table + table: { + expandActions: '展开更多操作', + collapseActions: '收起操作' + }, + + // Pagination + pagination: { + showing: '显示', + to: '至', + of: '共', + results: '条结果', + page: '页', + pageOf: '第 {page} / {total} 页', + previous: '上一页', + next: '下一页', + perPage: '每页', + goToPage: '跳转到第 {page} 页', + jumpTo: '跳转页', + jumpPlaceholder: '页码', + jumpAction: '跳转' + }, + + // Errors + errors: { + somethingWentWrong: '出错了', + pageNotFound: '页面未找到', + unauthorized: '未授权', + forbidden: '禁止访问', + serverError: '服务器错误', + networkError: '网络错误', + timeout: '请求超时', + tryAgain: '请重试' + }, + + // Dates + dates: { + today: '今天', + yesterday: '昨天', + thisWeek: '本周', + lastWeek: '上周', + thisMonth: '本月', + lastMonth: '上月', + last24Hours: '近24小时', + last7Days: '近 7 天', + last14Days: '近 14 天', + last30Days: '近 30 天', + custom: '自定义', + startDate: '开始日期', + endDate: '结束日期', + apply: '应用', + selectDateRange: '选择日期范围' + }, + + // Admin +} diff --git a/frontend/src/i18n/locales/zh/index.ts b/frontend/src/i18n/locales/zh/index.ts new file mode 100644 index 0000000000..377c67aec7 --- /dev/null +++ b/frontend/src/i18n/locales/zh/index.ts @@ -0,0 +1,13 @@ +import landing from './landing' +import common from './common' +import dashboard from './dashboard' +import admin from './admin' +import misc from './misc' + +export default { + ...landing, + ...common, + ...dashboard, + admin, + ...misc, +} diff --git a/frontend/src/i18n/locales/zh/landing.ts b/frontend/src/i18n/locales/zh/landing.ts new file mode 100644 index 0000000000..85b7ef48c9 --- /dev/null +++ b/frontend/src/i18n/locales/zh/landing.ts @@ -0,0 +1,255 @@ +export default { + batchImageGuide: { + title: '图片批量生成', + description: '一次提交多条提示词,任务完成后可统一下载图片结果' + }, + // Home Page + home: { + viewOnGithub: '在 GitHub 上查看', + viewDocs: '查看文档', + docs: '文档', + switchToLight: '切换到浅色模式', + switchToDark: '切换到深色模式', + dashboard: '控制台', + login: '登录', + getStarted: '立即开始', + goToDashboard: '进入控制台', + // 新增:面向用户的价值主张 + heroSubtitle: '一个密钥,畅用多个 AI 模型', + heroDescription: '无需管理多个订阅账号,一站式接入 Claude、GPT、Gemini 等主流 AI 服务', + tags: { + subscriptionToApi: '订阅转 API', + stickySession: '会话保持', + realtimeBilling: '按量计费' + }, + // 用户痛点区块 + painPoints: { + title: '你是否也遇到这些问题?', + items: { + expensive: { + title: '订阅费用高', + desc: '每个 AI 服务都要单独订阅,每月支出越来越多' + }, + complex: { + title: '多账号难管理', + desc: '不同平台的账号、密钥分散各处,管理起来很麻烦' + }, + unstable: { + title: '服务不稳定', + desc: '单一账号容易触发限制,影响正常使用' + }, + noControl: { + title: '用量无法控制', + desc: '不知道钱花在哪了,也无法限制团队成员的使用' + } + } + }, + // 解决方案区块 + solutions: { + title: '我们帮你解决', + subtitle: '简单三步,开始省心使用 AI' + }, + features: { + unifiedGateway: '一键接入', + unifiedGatewayDesc: '获取一个 API 密钥,即可调用所有已接入的 AI 模型,无需分别申请。', + multiAccount: '稳定可靠', + multiAccountDesc: '智能调度多个上游账号,自动切换和负载均衡,告别频繁报错。', + balanceQuota: '用多少付多少', + balanceQuotaDesc: '按实际使用量计费,支持设置配额上限,团队用量一目了然。' + }, + // 优势对比 + comparison: { + title: '为什么选择我们?', + headers: { + feature: '对比项', + official: '官方订阅', + us: '本平台' + }, + items: { + pricing: { + feature: '付费方式', + official: '固定月费,用不完也付', + us: '按量付费,用多少付多少' + }, + models: { + feature: '模型选择', + official: '单一服务商', + us: '多模型随意切换' + }, + management: { + feature: '账号管理', + official: '每个服务单独管理', + us: '统一密钥,一站管理' + }, + stability: { + feature: '服务稳定性', + official: '单账号易触发限制', + us: '多账号池,自动切换' + }, + control: { + feature: '用量控制', + official: '无法限制', + us: '可设配额、查明细' + } + } + }, + providers: { + title: '已支持的 AI 模型', + description: '一个 API,多种选择', + supported: '已支持', + soon: '即将推出', + claude: 'Claude', + gemini: 'Gemini', + antigravity: 'Antigravity', + more: '更多' + }, + // CTA 区块 + cta: { + title: '准备好开始了吗?', + description: '注册即可获得免费试用额度,体验一站式 AI 服务', + button: '免费注册' + }, + footer: { + allRightsReserved: '保留所有权利。' + } + }, + + // Key Usage Query Page + keyUsage: { + title: 'API Key 用量查询', + subtitle: '输入您的 API Key 以查看实时消费金额与使用状态', + placeholder: 'sk-ant-mirror-xxxxxxxxxxxx', + query: '查询', + querying: '查询中...', + privacyNote: '您的 Key 仅在浏览器本地处理,不会被存储', + dateRange: '统计范围:', + dateRangeToday: '今日', + dateRange7d: '7 天', + dateRange30d: '30 天', + dateRange90d: '90 天', + dateRangeCustom: '自定义', + apply: '应用', + used: '已使用', + detailInfo: '详细信息', + tokenStats: 'Token 统计', + dailyDetail: '按日明细', + modelStats: '模型用量统计', + // Table headers + date: '日期', + model: '模型', + requests: '请求数', + inputTokens: '输入 Tokens', + outputTokens: '输出 Tokens', + cacheCreationTokens: '缓存创建', + cacheReadTokens: '缓存读取', + cacheWriteTokens: '缓存写入', + totalTokens: '总 Tokens', + cost: '费用', + // Status + quotaMode: 'Key 限额模式', + walletBalance: '钱包余额', + // Ring card titles + totalQuota: '总额度', + limit5h: '5 小时限额', + limitDaily: '日限额', + limit7d: '7 天限额', + limitWeekly: '周限额', + limitMonthly: '月限额', + // Detail rows + remainingQuota: '剩余额度', + expiresAt: '过期时间', + todayExpires: '(今日到期)', + daysLeft: '({days} 天)', + usedQuota: '已用额度', + resetNow: '即将重置', + subscriptionType: '订阅类型', + subscriptionExpires: '订阅到期', + // Usage stat cells + todayRequests: '今日请求', + todayInputTokens: '今日输入', + todayOutputTokens: '今日输出', + todayTokens: '今日 Tokens', + todayCacheCreation: '今日缓存创建', + todayCacheRead: '今日缓存读取', + todayCost: '今日费用', + rpmTpm: 'RPM / TPM', + totalRequests: '累计请求', + totalInputTokens: '累计输入', + totalOutputTokens: '累计输出', + totalTokensLabel: '累计 Tokens', + totalCacheCreation: '累计缓存创建', + totalCacheRead: '累计缓存读取', + totalCost: '累计费用', + avgDuration: '平均耗时', + // Messages + enterApiKey: '请输入 API Key', + querySuccess: '查询成功', + queryFailed: '查询失败', + queryFailedRetry: '查询失败,请稍后重试', + noDailyUsage: '暂无按日用量数据', + }, + + // Setup Wizard + setup: { + title: 'Sub2API 安装向导', + description: '配置您的 Sub2API 实例', + database: { + title: '数据库配置', + description: '连接到您的 PostgreSQL 数据库', + host: '主机', + port: '端口', + username: '用户名', + password: '密码', + databaseName: '数据库名称', + sslMode: 'SSL 模式', + passwordPlaceholder: '密码', + ssl: { + disable: '禁用', + require: '要求', + verifyCa: '验证 CA', + verifyFull: '完全验证' + } + }, + redis: { + title: 'Redis 配置', + description: '连接到您的 Redis 服务器', + host: '主机', + port: '端口', + password: '密码(可选)', + database: '数据库', + passwordPlaceholder: '密码', + enableTls: '启用 TLS', + enableTlsHint: '连接 Redis 时使用 TLS(公共 CA 证书)' + }, + admin: { + title: '管理员账户', + description: '创建您的管理员账户', + email: '邮箱', + password: '密码', + confirmPassword: '确认密码', + passwordPlaceholder: '至少 8 个字符', + confirmPasswordPlaceholder: '确认密码', + passwordMismatch: '密码不匹配' + }, + ready: { + title: '准备安装', + description: '检查您的配置并完成安装', + database: '数据库', + redis: 'Redis', + adminEmail: '管理员邮箱' + }, + status: { + testing: '测试中...', + success: '连接成功', + testConnection: '测试连接', + installing: '安装中...', + completeInstallation: '完成安装', + completed: '安装完成!', + redirecting: '正在跳转到登录页面...', + restarting: '服务正在重启,请稍候...', + timeout: '服务重启时间超出预期,请手动刷新页面。' + } + }, + + // Common +} diff --git a/frontend/src/i18n/locales/zh/misc.ts b/frontend/src/i18n/locales/zh/misc.ts new file mode 100644 index 0000000000..67dffc92bd --- /dev/null +++ b/frontend/src/i18n/locales/zh/misc.ts @@ -0,0 +1,615 @@ +export default { + + // Subscription Progress (Header component) + subscriptionProgress: { + title: '我的订阅', + viewDetails: '查看订阅详情', + activeCount: '{count} 个有效订阅', + daily: '每日', + weekly: '每周', + monthly: '每月', + daysRemaining: '剩余 {days} 天', + expired: '已过期', + expiresToday: '今天到期', + expiresTomorrow: '明天到期', + viewAll: '查看全部订阅', + noSubscriptions: '暂无有效订阅', + unlimited: '无限制' + }, + + // Version Badge + version: { + currentVersion: '当前版本', + latestVersion: '最新版本', + upToDate: '已是最新版本', + updateAvailable: '有新版本可用!', + releaseNotes: '更新日志', + noReleaseNotes: '暂无更新日志', + viewUpdate: '查看更新', + viewRelease: '查看发布', + viewChangelog: '查看更新日志', + refresh: '刷新', + sourceMode: '源码构建', + sourceModeHint: '源码构建请使用 git pull 更新', + updateNow: '立即更新', + updating: '正在更新...', + updateComplete: '更新完成', + updateFailed: '更新失败', + restartRequired: '请重启服务以应用更新', + restartNow: '立即重启', + restarting: '正在重启...', + retry: '重试' + }, + + // Recharge / Subscription Page + purchase: { + title: '充值/订阅', + description: '通过内嵌页面完成充值/订阅', + openInNewTab: '新窗口打开', + notEnabledTitle: '该功能未开启', + notEnabledDesc: '管理员暂未开启充值/订阅入口,请联系管理员。', + notConfiguredTitle: '充值/订阅链接未配置', + notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。' + }, + + // Custom Page (iframe embed) + customPage: { + title: '自定义页面', + openInNewTab: '新窗口打开', + notFoundTitle: '页面不存在', + notFoundDesc: '该自定义页面不存在或已被删除。', + notConfiguredTitle: '页面链接未配置', + notConfiguredDesc: '该自定义页面的 URL 未正确配置。', + tableOfContents: '目录', + copyCode: '复制', + copiedCode: '已复制', + copyCodeFailed: '失败' + }, + + // Announcements Page + announcements: { + title: '公告', + description: '查看系统公告', + unreadOnly: '仅显示未读', + markRead: '标记已读', + markAllRead: '全部已读', + viewAll: '查看全部公告', + markedAsRead: '已标记为已读', + allMarkedAsRead: '所有公告已标记为已读', + newCount: '有 {count} 条新公告', + readAt: '已读时间', + read: '已读', + unread: '未读', + startsAt: '开始时间', + endsAt: '结束时间', + empty: '暂无公告', + emptyUnread: '暂无未读公告', + total: '条公告', + emptyDescription: '暂时没有任何系统公告', + readStatus: '您已阅读此公告', + markReadHint: '点击"已读"标记此公告' + }, + + // User Subscriptions Page + userSubscriptions: { + title: '我的订阅', + description: '查看您的订阅计划和用量', + noActiveSubscriptions: '暂无有效订阅', + noActiveSubscriptionsDesc: '您没有任何有效订阅。请联系管理员获取订阅。', + failedToLoad: '加载订阅失败', + status: { + active: '有效', + expired: '已过期', + revoked: '已撤销' + }, + usage: '用量', + expires: '到期时间', + noExpiration: '无到期时间', + unlimited: '无限制', + unlimitedDesc: '该订阅无用量限制', + daily: '每日', + weekly: '每周', + monthly: '每月', + daysRemaining: '剩余 {days} 天', + expiresOn: '{date} 到期', + resetIn: '{time} 后重置', + quotaEndsIn: '额度将在 {time} 后结束', + windowNotActive: '等待首次使用', + usageOf: '已用 {used} / {limit}' + }, + + // Onboarding Tour + onboarding: { + restartTour: '重新查看新手引导', + dontShowAgain: '不再提示', + dontShowAgainTitle: '永久关闭新手引导', + confirmDontShow: '确定不再显示新手引导吗?\n\n您可以随时在右上角头像菜单中重新开启。', + confirmExit: '确定要退出新手引导吗?您可以随时在右上角菜单重新开始。', + interactiveHint: '按 Enter 或点击继续', + navigation: { + flipPage: '翻页', + exit: '退出' + }, + // Admin tour steps + admin: { + welcome: { + title: '👋 欢迎使用 Sub2API', + description: + '

Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。

🎯 核心功能:

  • 📦 分组管理 - 创建不同的服务套餐(VIP、免费试用等)
  • 🔗 账号池 - 连接多个上游 AI 服务商账号
  • 🔑 密钥分发 - 为用户生成独立的 API Key
  • 💰 计费管理 - 灵活的费率和配额控制

接下来,我们将用 3 分钟带您完成首次配置 →

', + nextBtn: '开始配置 🚀', + prevBtn: '跳过' + }, + groupManage: { + title: '📦 第一步:分组管理', + description: + '

什么是分组?

分组是 Sub2API 的核心概念,它就像一个"服务套餐":

  • 🎯 每个分组可以包含多个上游账号
  • 💰 每个分组有独立的计费倍率
  • 👥 可以设置为公开或专属分组

💡 示例:您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组

👉 点击左侧的"分组管理"开始

' + }, + createGroup: { + title: '➕ 创建新分组', + description: + '

现在让我们创建第一个分组。

📝 提示:建议先创建一个测试分组,熟悉流程后再创建正式分组

👉 点击"创建分组"按钮

' + }, + groupName: { + title: '✏️ 1. 分组名称', + description: + '

为您的分组起一个易于识别的名称。

💡 命名建议:
  • "测试分组" - 用于测试
  • "VIP专线" - 高质量服务
  • "免费试用" - 体验版

填写完成后点击"下一步"继续

', + nextBtn: '下一步' + }, + groupPlatform: { + title: '🤖 2. 选择平台', + description: + '

选择该分组支持的 AI 平台。

📌 平台说明:
  • Anthropic - Claude 系列模型
  • OpenAI - GPT 系列模型
  • Google - Gemini 系列模型

一个分组只能选择一个平台

', + nextBtn: '下一步' + }, + groupMultiplier: { + title: '💰 3. 费率倍数', + description: + '

设置该分组的计费倍率,控制用户的实际扣费。

⚙️ 计费规则:
  • 1.0 - 原价计费(成本价)
  • 1.5 - 用户消耗 $1,扣除 $1.5
  • 2.0 - 用户消耗 $1,扣除 $2
  • 0.8 - 补贴模式(亏本运营)

建议测试分组设置为 1.0

', + nextBtn: '下一步' + }, + groupExclusive: { + title: '🔒 4. 专属分组(可选)', + description: + '

控制分组的可见性和访问权限。

🔐 权限说明:
  • 关闭 - 公开分组,所有用户可见
  • 开启 - 专属分组,仅指定用户可见

💡 使用场景:VIP 用户专属、内部测试、特殊客户等

', + nextBtn: '下一步' + }, + groupSubmit: { + title: '✅ 保存分组', + description: + '

确认信息无误后,点击创建按钮保存分组。

⚠️ 注意:分组创建后,平台类型不可修改,其他信息可以随时编辑

📌 下一步:创建成功后,我们将添加上游账号到这个分组

👉 点击"创建"按钮

' + }, + accountManage: { + title: '🔗 第二步:添加账号', + description: + '

太棒了!分组已创建成功 🎉

现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。

🔑 账号的作用:
  • 连接到上游 AI 服务(Claude、GPT 等)
  • 一个分组可以包含多个账号(负载均衡)
  • 支持 OAuth 和 Session Key 两种方式

👉 点击左侧的"账号管理"

' + }, + createAccount: { + title: '➕ 添加新账号', + description: + '

点击按钮开始添加您的第一个上游账号。

💡 提示:建议使用 OAuth 方式,更安全且无需手动提取密钥

👉 点击"添加账号"按钮

' + }, + accountName: { + title: '✏️ 1. 账号名称', + description: + '

为账号设置一个便于识别的名称。

💡 命名建议:"Claude主账号"、"GPT备用1"、"测试账号" 等

', + nextBtn: '下一步' + }, + accountPlatform: { + title: '🤖 2. 选择平台', + description: + '

选择该账号对应的服务商平台。

⚠️ 重要:平台必须与刚才创建的分组平台一致

', + nextBtn: '下一步' + }, + accountType: { + title: '🔐 3. 授权方式', + description: + '

选择账号的授权方式。

✅ 推荐:OAuth 方式
  • 无需手动提取密钥
  • 更安全,支持自动刷新
  • 适用于 Claude Code、ChatGPT OAuth
📌 Session Key 方式
  • 需要手动从浏览器提取
  • 可能需要定期更新
  • 适用于不支持 OAuth 的平台
', + nextBtn: '下一步' + }, + accountPriority: { + title: '⚖️ 4. 优先级(可选)', + description: + '

设置账号的调用优先级。

📊 优先级规则:
  • 数字越小,优先级越高
  • 系统优先使用低数值账号
  • 相同优先级则随机选择

💡 使用场景:主账号设置低数值,备用账号设置高数值

', + nextBtn: '下一步' + }, + accountGroups: { + title: '🎯 5. 分配分组', + description: + '

关键步骤!将账号分配到刚才创建的分组。

⚠️ 重要提醒:
  • 必须勾选至少一个分组
  • 未分配分组的账号无法使用
  • 一个账号可以分配给多个分组

💡 提示:请勾选刚才创建的测试分组

', + nextBtn: '下一步' + }, + accountSubmit: { + title: '✅ 保存账号', + description: + '

确认信息无误后,点击保存按钮。

📌 OAuth 授权流程:
  • 点击保存后会跳转到服务商页面
  • 在服务商页面完成登录授权
  • 授权成功后自动返回

📌 下一步:账号添加成功后,我们将创建 API 密钥

👉 点击"保存"按钮

' + }, + keyManage: { + title: '🔑 第三步:生成密钥', + description: + '

恭喜!账号配置完成 🎉

最后一步,生成 API Key 来测试服务是否正常工作。

🔑 API Key 的作用:
  • 用于调用 AI 服务的凭证
  • 每个 Key 绑定一个分组
  • 可以设置配额和有效期
  • 支持独立的使用统计

👉 点击左侧的"API 密钥"

' + }, + createKey: { + title: '➕ 创建密钥', + description: + '

点击按钮创建您的第一个 API Key。

💡 提示:创建后请立即复制保存,密钥只显示一次

👉 点击"创建密钥"按钮

' + }, + keyName: { + title: '✏️ 1. 密钥名称', + description: + '

为密钥设置一个便于管理的名称。

💡 命名建议:"测试密钥"、"生产环境"、"移动端" 等

', + nextBtn: '下一步' + }, + keyGroup: { + title: '🎯 2. 选择分组', + description: + '

选择刚才配置好的分组。

📌 分组决定:
  • 该密钥可以使用哪些账号
  • 计费倍率是多少
  • 是否为专属密钥

💡 提示:选择刚才创建的测试分组

', + nextBtn: '下一步' + }, + keySubmit: { + title: '🎉 生成并复制', + description: + '

点击创建后,系统会生成完整的 API Key。

⚠️ 重要提醒:
  • 密钥只显示一次,请立即复制
  • 丢失后需要重新生成
  • 妥善保管,不要泄露给他人
🚀 下一步:
  • 复制生成的 sk-xxx 密钥
  • 在支持 OpenAI 接口的客户端中使用
  • 开始体验 AI 服务!

👉 点击"创建"按钮

' + } + }, + // User tour steps + user: { + welcome: { + title: '👋 欢迎使用 Sub2API', + description: + '

您好!欢迎来到 Sub2API AI 服务平台。

🎯 快速开始:

  • 🔑 创建 API 密钥
  • 📋 复制密钥到您的应用
  • 🚀 开始使用 AI 服务

只需 1 分钟,让我们开始吧 →

', + nextBtn: '开始 🚀', + prevBtn: '跳过' + }, + keyManage: { + title: '🔑 API 密钥管理', + description: + '

在这里管理您的所有 API 访问密钥。

📌 什么是 API 密钥?
API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。

👉 点击进入密钥页面

' + }, + createKey: { + title: '➕ 创建新密钥', + description: + '

点击按钮创建您的第一个 API 密钥。

💡 提示:创建后密钥只显示一次,请务必复制保存

👉 点击"创建密钥"

' + }, + keyName: { + title: '✏️ 密钥名称', + description: + '

为密钥起一个便于识别的名称。

💡 示例:"我的第一个密钥"、"测试用" 等

', + nextBtn: '下一步' + }, + keyGroup: { + title: '🎯 选择分组', + description: + '

选择管理员为您分配的服务分组。

📌 分组说明:
不同分组可能有不同的服务质量和计费标准,请根据需要选择。

', + nextBtn: '下一步' + }, + keySubmit: { + title: '🎉 完成创建', + description: + '

点击确认创建您的 API 密钥。

⚠️ 重要:
  • 创建后请立即复制密钥(sk-xxx)
  • 密钥只显示一次,丢失需重新生成

🚀 如何使用:
将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!

👉 点击"创建"按钮

' + } + } + }, + + // Payment System + payment: { + title: '充值/订阅', + amountLabel: '充值金额', + paymentAmount: '支付金额', + creditedBalance: '到账余额', + quickAmounts: '快捷金额', + customAmount: '自定义金额', + enterAmount: '输入金额', + paymentMethod: '支付方式', + fee: '手续费', + actualPay: '实付金额', + createOrder: '确认支付', + methods: { + easypay: '易支付', + alipay: '支付宝', + wxpay: '微信支付', + stripe: 'Stripe', + airwallex: 'Airwallex', + card: '银行卡', + link: 'Link', + alipay_direct: '支付宝(直连)', + wxpay_direct: '微信支付(直连)', + }, + status: { + pending: '待支付', + paid: '已支付', + recharging: '充值中', + completed: '已完成', + expired: '已过期', + cancelled: '已取消', + failed: '失败', + refund_requested: '退款申请中', + refunding: '退款中', + refund_pending: '退款处理中', + refunded: '已退款', + partially_refunded: '部分退款', + refund_failed: '退款失败', + }, + qr: { + scanToPay: '请扫码支付', + scanAlipay: '支付宝扫码支付', + scanWxpay: '微信扫码支付', + scanAlipayHint: '请使用手机打开支付宝,扫描二维码完成支付', + scanWxpayHint: '请使用手机打开微信,扫描二维码完成支付', + payInNewWindow: '请在新窗口中完成支付', + payInNewWindowHint: '支付页面已在新窗口打开,请在新窗口中完成支付后返回此页面', + openPayWindow: '重新打开支付页面', + expiresIn: '剩余支付时间', + expired: '订单已过期', + expiredDesc: '订单已超时,请重新创建订单', + cancelled: '订单已取消', + cancelledDesc: '您已取消本次支付', + waitingPayment: '等待支付...', + cancelOrder: '取消订单', + }, + orders: { + title: '我的订单', + empty: '暂无订单', + orderId: '订单 ID', + orderNo: '订单编号', + amount: '金额', + payAmount: '实付', + creditedAmount: '到账金额', + fee: '手续费', + baseAmount: '充值金额', + includedInPayAmount: '已含在实付金额中', + status: '状态', + paymentMethod: '支付方式', + createdAt: '创建时间', + cancel: '取消订单', + userId: '用户 ID', + orderType: '订单类型', + actions: '操作', + requestRefund: '申请退款', + }, + result: { + success: '支付成功', + subscriptionSuccess: '订阅成功', + processing: '支付处理中', + processingHint: '支付结果仍在确认中,页面会自动刷新。', + failed: '支付失败', + backToRecharge: '返回充值', + viewOrders: '查看订单', + }, + currentBalance: '当前余额', + groupFallback: '分组 #{id}', + rechargeAccount: '充值账户', + activeSubscription: '当前订阅', + noActiveSubscription: '暂无有效订阅', + tabTopUp: '充值', + tabSubscribe: '订阅', + noPlans: '暂无可用订阅套餐', + notAvailable: '充值功能暂未开放', + confirmSubscription: '确认订阅', + confirmCancel: '确定要取消此订单吗?', + amountTooLow: '最低金额为 {min}', + amountTooHigh: '最高金额为 {max}', + amountNoMethod: '该金额没有可用的支付方式', + rechargeRatePreview: '当前倍率:1 CNY = {usd} USD', + refundReason: '退款原因', + refundReasonPlaceholder: '请描述您的退款原因', + stripeLoadFailed: '支付组件加载失败,请刷新页面重试', + stripeMissingParams: '缺少订单ID或支付密钥', + stripeNotConfigured: 'Stripe 未配置', + airwallexLoadFailed: 'Airwallex 支付组件加载失败,请刷新页面重试', + airwallexMissingParams: '缺少 Airwallex 支付参数', + errors: { + tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单', + cancelRateLimited: '取消订单过于频繁,请稍后再试', + wechatH5NotAuthorized: '当前商户未开通微信 H5 支付,请在微信中打开当前页面继续支付。', + wechatPaymentMpNotConfigured: '当前站点未完成公众号/JSAPI 支付配置,暂时无法在微信内直接拉起支付。', + wechatJsapiUnavailable: '当前环境未能拉起微信支付,请确认正在微信内打开本页后重试。', + wechatJsapiFailed: '微信支付未完成,请重新拉起支付或改用扫码支付。', + wechatUnavailable: '当前微信支付暂不可用,请稍后重试。', + wechatOpenInWeChatHint: '请复制当前页面链接到微信内打开,或直接改用电脑端微信扫码支付。', + wechatScanOnDesktopHint: '电脑端请直接使用微信扫一扫完成支付;移动端请在微信内打开当前页面。', + wechatSwitchBrowserHint: '请改用电脑端微信扫码,或在外部浏览器重新打开本页后再试。', + mobilePaymentFallbackToQr: '当前商户未开通移动支付,已自动切换为扫码支付。', + alipayDesktopUnavailable: '当前支付宝桌面支付未成功生成二维码。', + alipayDesktopQrHint: '电脑端支付宝应展示扫码单,请刷新后重试,或确认浏览器未拦截当前支付页。', + alipayMobileUnavailable: '当前页面未成功跳转到支付宝。', + alipayMobileOpenHint: '请允许当前页面打开支付宝 App,或改用系统浏览器重新发起支付。', + // Structured error codes (reason strings from backend ApplicationError) + PAYMENT_DISABLED: '支付系统已关闭', + USER_INACTIVE: '账号已被禁用', + BALANCE_PAYMENT_DISABLED: '余额充值功能已关闭', + INVALID_AMOUNT: '金额无效', + INVALID_INPUT: '参数有误', + PLAN_NOT_AVAILABLE: '套餐不存在或已下架', + GROUP_NOT_FOUND: '订阅分组不可用', + GROUP_TYPE_MISMATCH: '分组类型不是订阅类型', + TOO_MANY_PENDING: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单', + DAILY_LIMIT_EXCEEDED: '今日充值已达上限,剩余额度 {remaining}', + PAYMENT_GATEWAY_ERROR: '支付方式不可用', + NO_AVAILABLE_INSTANCE: '暂无可用的支付通道', + PAYMENT_PROVIDER_MISCONFIGURED: '支付通道配置错误,请联系管理员', + WXPAY_CONFIG_MISSING_KEY: '微信支付配置缺少必填项:{key}', + WXPAY_CONFIG_INVALID_KEY_LENGTH: '微信支付 {key} 长度错误,应为 {expected} 字节(实际 {actual})', + WXPAY_CONFIG_INVALID_KEY: '微信支付 {key} 格式错误,请确认复制了完整的 PEM 内容', + PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作', + PAYMENT_PROVIDER_CONFLICT: '该支付方式已有其他启用中的服务商实例,请先停用后再继续。', + CANCEL_RATE_LIMITED: '取消订单过于频繁,请稍后再试', + NOT_FOUND: '订单不存在', + FORBIDDEN: '无权限操作此订单', + CONFLICT: '订单状态已变更,请刷新', + INVALID_ORDER_TYPE: '仅余额订单可申请退款', + INVALID_STATUS: '当前订单状态不允许此操作', + BALANCE_NOT_ENOUGH: '退款金额超过余额', + REFUND_AMOUNT_EXCEEDED: '退款金额超过充值金额', + REFUND_FAILED: '退款失败', + }, + airwallexPay: 'Airwallex 支付', + stripePay: '立即支付', + stripeSuccessProcessing: '支付成功,正在处理订单...', + stripePopup: { + redirecting: '正在跳转到支付页面...', + loadingQr: '正在获取微信支付二维码...', + timeout: '等待支付凭证超时,请重试', + qrFailed: '未能获取微信支付二维码', + }, + subscribeNow: '立即开通', + renewNow: '续费', + selectPlan: '选择套餐', + planFeatures: '功能特性', + planCard: { + rate: '倍率', + peakRate: '高峰倍率', + dailyLimit: '日限额', + weeklyLimit: '周限额', + monthlyLimit: '月限额', + quota: '配额', + unlimited: '无限制', + models: '模型', + }, + days: '天', + months: '个月', + years: '年', + oneMonth: '1 个月', + oneYear: '1 年', + perMonth: '月', + perYear: '年', + admin: { + tabs: { + overview: '概览', + orders: '订单管理', + channels: '支付渠道', + plans: '订阅套餐', + }, + todayRevenue: '今日收入', + totalRevenue: '总收入', + todayOrders: '今日订单', + orderCount: '订单数', + avgAmount: '平均金额', + revenue: '收入', + dailyRevenue: '每日收入', + paymentDistribution: '支付方式分布', + colUser: '用户', + topUsers: '消费排行', + noData: '暂无数据', + days: '天', + weeks: '周', + months: '月', + searchOrders: '搜索订单...', + allStatuses: '全部状态', + allPaymentTypes: '全部支付方式', + allOrderTypes: '全部订单类型', + orderDetail: '订单详情', + orderType: '订单类型', + orders: '订单', + balanceOrder: '余额充值', + subscriptionOrder: '订阅', + paidAt: '支付时间', + completedAt: '完成时间', + expiresAt: '过期时间', + feeRate: '手续费率', + refund: '退款', + refundOrder: '退款订单', + refundAmount: '退款金额', + maxRefundable: '最大可退金额', + refundReason: '退款原因', + refundReasonPlaceholder: '请输入退款原因', + confirmRefund: '确认退款', + refundSuccess: '退款成功', + refundPending: '退款处理中,待网关确认', + queryRefundStatus: '查询退款状态', + refundInfo: '退款信息', + refundEnabled: '允许退款', + alreadyRefunded: '已退款', + deductBalance: '扣除余额', + deductBalanceHint: '从用户余额中扣回充值金额', + userBalance: '用户余额', + orderAmount: '订单金额', + insufficientBalance: '余额不足,将扣至 $0', + noDeduction: '将不扣除用户余额', + forceRefund: '强制退款(忽略余额检查)', + orderCancelled: '订单已取消', + retry: '重试', + retrySuccess: '重试成功', + approveRefund: '批准退款', + retryRefund: '重试退款', + refundRequestInfo: '退款申请信息', + refundRequestedAt: '申请时间', + refundRequestedBy: '申请人', + refundRequestReason: '申请原因', + auditLogs: '操作日志', + operator: '操作人', + channelName: '渠道名称', + channelDescription: '渠道描述', + createChannel: '创建渠道', + editChannel: '编辑渠道', + deleteChannel: '删除渠道', + deleteChannelConfirm: '确定要删除此渠道吗?', + planName: '套餐名称', + planDescription: '套餐描述', + createPlan: '创建套餐', + editPlan: '编辑套餐', + deletePlan: '删除套餐', + deletePlanConfirm: '确定要删除此套餐吗?', + originalPrice: '原价', + price: '价格', + subscriptionCnyPayPreview: 'CNY 通道实扣预览:{amount}', + subscriptionCnyPayPreviewWithFee: '(含 {feeRate}% 手续费:{total})', + validityDays: '有效期(天)', + validityUnit: '有效期单位', + sortOrder: '排序', + forSale: '上架状态', + onSale: '上架', + offSale: '下架', + group: '分组', + groupId: '分组 ID', + features: '功能特性', + featuresHint: '每行一个特性', + featuresPlaceholder: '输入套餐特性...', + providerManagement: '服务商管理', + providerManagementDesc: '管理支付服务商实例', + createProvider: '创建服务商', + editProvider: '编辑服务商', + deleteProvider: '删除服务商', + deleteProviderConfirm: '确定要删除此服务商吗?', + providerName: '服务商名称', + providerKey: '服务商标识', + selectProviderKey: '选择服务商标识', + providerConfig: '服务商配置', + noProviders: '暂无服务商', + noProvidersHint: '创建一个服务商实例以开始接受支付', + supportedTypes: '支持的支付方式', + supportedTypesHint: '选择此服务商支持的支付方式', + rateMultiplier: '费率倍数', + dashboardTitle: '支付概览', + dashboardDesc: '充值订单统计与分析', + daySuffix: '天', + paymentConfigTitle: '支付配置', + paymentConfigDesc: '管理支付服务商与相关设置', + plansPageTitle: '订阅套餐管理', + plansPageDesc: '管理订阅套餐配置', + tabPlanConfig: '套餐配置', + tabUserSubs: '用户订阅', + selectGroup: '请选择分组', + groupRequired: '请选择订阅分组', + priceRequired: '价格必须大于 0', + validityDaysRequired: '有效期天数必须大于 0', + groupMissing: '缺失', + groupInfo: '分组信息', + platform: '平台', + rateMultiplierLabel: '倍率', + dailyLimit: '日限额', + weeklyLimit: '周限额', + monthlyLimit: '月限额', + unlimited: '无限制', + searchUserSubs: '搜索用户订阅...', + daily: '日', + weekly: '周', + monthly: '月', + subsStatus: { + active: '生效中', + expired: '已过期', + revoked: '已撤销', + }, + }, + }, + +}