diff --git a/README.md b/README.md index d960d93fa1..eb24b0d3d6 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,11 @@ Please read the following carefully before using this project: - **🚨 Terms of Service Risk**: Using this project may violate the terms of service of Anthropic and other upstream providers. Please review the relevant providers' user agreements before use; all risks arising from such use are borne solely by the user. - **⚖️ Compliant Use**: Use this project only in compliance with the laws and regulations of your country or region. Any unlawful use is strictly prohibited. - **📖 Disclaimer**: This project is provided for technical learning and research purposes only. The authors assume no liability for account bans, service interruptions, data loss, or any other direct or indirect damages resulting from the use of this project. +- **🚫 No Commercial Authorization**: The developers of this project have never authorized any individual or organization to conduct any form of commercial operation based on this project. Any commercial activity conducted in the name of or based on this project is unrelated to this project and its developers, and all resulting disputes, losses, and legal liabilities shall be borne solely by the party conducting such activity. ## ❤️ Sponsors -> [Want to appear here?](mailto:support@pincc.ai) +> [Want to appear here?](mailto:support@sub2api.org) @@ -140,7 +141,7 @@ Model authenticity: no content intervention or secondary filtering — experienc - @@ -522,20 +523,20 @@ Additional security-related options are available in `config.yaml`: **⚠️ Security Warning: HTTP URL Configuration** -When `security.url_allowlist.enabled=false`, the system performs minimal URL validation by default, **rejecting HTTP URLs** and only allowing HTTPS. To allow HTTP URLs (e.g., for development or internal testing), you must explicitly set: +When `security.url_allowlist.enabled=false`, the system performs minimal URL validation and **allows HTTP URLs by default** (dev-friendly mode; Docker Compose deployments use the same default). For production, explicitly tighten this to HTTPS-only: ```yaml security: url_allowlist: enabled: false # Disable allowlist checks - allow_insecure_http: true # Allow HTTP URLs (⚠️ INSECURE) + allow_insecure_http: false # HTTPS only (recommended for production) ``` **Or via environment variable:** ```bash SECURITY_URL_ALLOWLIST_ENABLED=false -SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true +SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false ``` **Risks of allowing HTTP:** @@ -549,7 +550,7 @@ SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true - ✅ Testing account connectivity before obtaining HTTPS - ❌ Production environments (use HTTPS only) -**Example error without this setting:** +**Example error for HTTP URLs when `allow_insecure_http: false` is set:** ``` Invalid base URL: invalid url scheme: http ``` @@ -630,8 +631,10 @@ Sub2API supports Grok subscription accounts through xAI OAuth and forwards OpenA - Public Claude-compatible target: `/v1/messages`, converted to xAI Responses and returned as Anthropic Messages output for Claude CLI style clients - Public Chat Completions targets: `/v1/chat/completions` and `/chat/completions`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/chat/completions` - Codex CLI style Responses WebSocket ingress is accepted on the Responses targets and bridged to xAI HTTP/SSE Responses upstream -- Initial models: `grok-4.3`, `grok-build-0.1`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309` -- Out of scope for this provider: image, video, TTS, transcription, browser automation, cookies, and Grok web scraping +- Initial text models: `grok-4.3`, `grok-build-0.1`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309` +- Media targets for Grok groups: `/v1/images/generations`, `/images/generations`, `/v1/images/edits`, `/images/edits`, `/v1/videos/generations`, `/videos/generations`, `/v1/videos/{request_id}`, and `/videos/{request_id}`. Generation requests require the group image-generation permission. +- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5` +- Out of scope for this provider: TTS, transcription, browser automation, cookies, and Grok web scraping ### OAuth Configuration @@ -689,12 +692,6 @@ Antigravity accounts support optional **hybrid scheduling**. When enabled, the g > **⚠️ Warning**: Anthropic Claude and Antigravity Claude **cannot be mixed within the same conversation context**. Use groups to isolate them properly. -### Known Issues - -In Claude Code, Plan Mode cannot exit automatically. (Normally when using the native Claude API, after planning is complete, Claude Code will pop up options for users to approve or reject the plan.) - -**Workaround**: Press `Shift + Tab` to manually exit Plan Mode, then type your response to approve or reject the plan. - --- ## Project Structure @@ -725,16 +722,6 @@ sub2api/ └── install.sh # One-click installation script ``` -## Disclaimer - -> **Please read carefully before using this project:** -> -> :rotating_light: **Terms of Service Risk**: Using this project may violate Anthropic's Terms of Service. Please read Anthropic's user agreement carefully before use. All risks arising from the use of this project are borne solely by the user. -> -> :book: **Disclaimer**: This project is for technical learning and research purposes only. The author assumes no responsibility for account suspension, service interruption, or any other losses caused by the use of this project. - ---- - ## Star History diff --git a/README_CN.md b/README_CN.md index 2ba06a5299..a93056282b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -24,10 +24,11 @@ - **🚨 服务条款风险**:使用本项目可能违反 Anthropic 等上游服务商的服务条款。请在使用前仔细阅读相关服务商的用户协议,由此产生的一切风险由用户自行承担。 - **⚖️ 合规使用**:请在符合您所在国家或地区法律法规的前提下使用本项目,严禁将其用于任何违法违规用途。 - **📖 免责声明**:本项目仅供技术学习与研究使用,作者不对因使用本项目导致的账户封禁、服务中断、数据丢失或其他任何直接或间接损失承担责任。 +- **🚫 无商业授权**:本项目从未授权任何个人或组织基于本项目开展任何形式的商业化运营。任何以本项目名义或基于本项目从事的商业行为均与本项目及其开发者无关,由此产生的一切纠纷、损失和法律责任由行为主体自行承担。 ## ❤️ 赞助商 -> [想出现在这里?](mailto:support@pincc.ai) +> [想出现在这里?](mailto:support@sub2api.org)
proxy4freeProxy4Free is a data proxy service provider for developers and AI applications, offering residential proxies, static residential proxies, ISP proxies, and datacenter proxies for scenarios such as Web Scraping, Browser Automation, and AI Agents. With global IP resources, stable connections, and flexible switching, it helps developers improve data collection success rates and reduce the risk of IP bans. Register via this link to get started and easily build more stable and efficient automation workflows. +Thanks to Proxy4Free for sponsoring this project! Proxy4Free is a data proxy service provider for developers and AI applications, offering residential proxies, static residential proxies, ISP proxies, and datacenter proxies for scenarios such as Web Scraping, Browser Automation, and AI Agents. With global IP resources, stable connections, and flexible switching, it helps developers improve data collection success rates and reduce the risk of IP bans. Register via this link to get started and easily build more stable and efficient automation workflows.
@@ -143,10 +144,11 @@ - +
proxy4freeProxy4Free 是面向开发者和 AI 应用的数据代理服务商,提供住宅代理、静态住宅代理、ISP 代理及数据中心代理等多种代理解决方案,适用于 Web Scraping、Browser Automation、AI Agent 等场景。支持全球 IP 资源、稳定连接与灵活切换,帮助开发者提升数据采集成功率,降低 IP 封禁风险。通过此链接注册即可开始体验,轻松构建更稳定、高效的自动化工作流。 +感谢 Proxy4Free 赞助本项目!Proxy4Free 是面向开发者和 AI 应用的数据代理服务商,提供住宅代理、静态住宅代理、ISP 代理及数据中心代理等多种代理解决方案,适用于 Web Scraping、Browser Automation、AI Agent 等场景。支持全球 IP 资源、稳定连接与灵活切换,帮助开发者提升数据采集成功率,降低 IP 封禁风险。通过此链接注册即可开始体验,轻松构建更稳定、高效的自动化工作流。
## 项目概述 @@ -566,20 +568,20 @@ gateway: **⚠️ 安全警告:HTTP URL 配置** -当 `security.url_allowlist.enabled=false` 时,系统默认执行最小 URL 校验,**拒绝 HTTP URL**,仅允许 HTTPS。要允许 HTTP URL(例如用于开发或内网测试),必须显式设置: +当 `security.url_allowlist.enabled=false` 时,系统仅执行最小 URL 校验,且**默认允许 HTTP URL**(开发友好模式,Docker Compose 部署的默认值一致)。生产环境建议显式收紧为仅允许 HTTPS: ```yaml security: url_allowlist: enabled: false # 禁用白名单检查 - allow_insecure_http: true # 允许 HTTP URL(⚠️ 不安全) + allow_insecure_http: false # 仅允许 HTTPS(生产环境推荐) ``` **或通过环境变量:** ```bash SECURITY_URL_ALLOWLIST_ENABLED=false -SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true +SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false ``` **允许 HTTP 的风险:** @@ -593,7 +595,7 @@ SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true - ✅ 获取 HTTPS 前测试账号连通性 - ❌ 生产环境(仅使用 HTTPS) -**未设置此项时的错误示例:** +**设置 `allow_insecure_http: false` 后,HTTP URL 会返回如下错误:** ``` Invalid base URL: invalid url scheme: http ``` @@ -709,10 +711,6 @@ Antigravity 账户支持可选的**混合调度**功能。开启后,通用端 > **⚠️ 注意**:Anthropic Claude 和 Antigravity Claude **不能在同一上下文中混合使用**,请通过分组功能做好隔离。 - -### 已知问题 -在 Claude Code 中,无法自动退出Plan Mode。(正常使用原生Claude Api时,Plan 完成后,Claude Code会弹出弹出选项让用户同意或拒绝Plan。) -解决办法:shift + Tab,手动退出Plan mode,然后输入内容 告诉 Claude Code 同意或拒绝 Plan --- ## 项目结构 @@ -743,16 +741,6 @@ sub2api/ └── install.sh # 一键安装脚本 ``` -## 免责声明 - -> **使用本项目前请仔细阅读:** -> -> :rotating_light: **服务条款风险**: 使用本项目可能违反 Anthropic 的服务条款。请在使用前仔细阅读 Anthropic 的用户协议,使用本项目的一切风险由用户自行承担。 -> -> :book: **免责声明**: 本项目仅供技术学习和研究使用,作者不对因使用本项目导致的账户封禁、服务中断或其他损失承担任何责任。 - ---- - ## Star History diff --git a/README_JA.md b/README_JA.md index 3772b93957..bd154a9a6b 100644 --- a/README_JA.md +++ b/README_JA.md @@ -23,10 +23,11 @@ - **🚨 利用規約のリスク**:本プロジェクトの使用は、Anthropic をはじめとする上流プロバイダーの利用規約に違反する可能性があります。ご利用前に各プロバイダーのユーザー規約を必ずご確認ください。使用により生じるすべてのリスクはユーザーご自身が負うものとします。 - **⚖️ 法令遵守**:お住まいの国または地域の法令を遵守した上で本プロジェクトをご利用ください。いかなる違法な目的での使用も固く禁じます。 - **📖 免責事項**:本プロジェクトは技術的な学習および研究の目的でのみ提供されます。本プロジェクトの使用により生じたアカウントの停止、サービスの中断、データの損失、その他一切の直接的または間接的な損害について、作者は一切の責任を負いません。 +- **🚫 商用利用の非許諾**:本プロジェクトの開発者は、いかなる個人または組織に対しても、本プロジェクトを利用したいかなる形態の商業運営も一切許諾していません。本プロジェクトの名義で、または本プロジェクトに基づいて行われる商業行為はすべて本プロジェクトおよびその開発者とは無関係であり、それにより生じる一切の紛争、損失、法的責任は行為者自身が負うものとします。 ## ❤️ スポンサー -> [こちらに掲載しませんか?](mailto:support@pincc.ai) +> [こちらに掲載しませんか?](mailto:support@sub2api.org) @@ -138,7 +139,7 @@ - @@ -520,20 +521,20 @@ default: **⚠️ セキュリティ警告: HTTP URL 設定** -`security.url_allowlist.enabled=false` の場合、システムはデフォルトで最小限の URL バリデーションを行い、**HTTP URL を拒否**して HTTPS のみを許可します。HTTP URL を許可するには(開発環境や内部テスト用など)、以下を明示的に設定する必要があります: +`security.url_allowlist.enabled=false` の場合、システムは最小限の URL バリデーションのみを行い、**デフォルトで HTTP URL を許可**します(開発フレンドリーモード。Docker Compose デプロイのデフォルトも同じです)。本番環境では、以下のように明示的に HTTPS のみに制限することを推奨します: ```yaml security: url_allowlist: enabled: false # 許可リストチェックを無効化 - allow_insecure_http: true # HTTP URL を許可(⚠️ セキュリティリスクあり) + allow_insecure_http: false # HTTPS のみ許可(本番環境推奨) ``` **または環境変数で設定:** ```bash SECURITY_URL_ALLOWLIST_ENABLED=false -SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true +SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false ``` **HTTP を許可するリスク:** @@ -547,7 +548,7 @@ SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true - ✅ HTTPS 取得前のアカウント接続テスト - ❌ 本番環境(HTTPS のみを使用) -**この設定なしで表示されるエラー例:** +**`allow_insecure_http: false` 設定時に HTTP URL で表示されるエラー例:** ``` Invalid base URL: invalid url scheme: http ``` @@ -640,12 +641,6 @@ Antigravity アカウントはオプションの**ハイブリッドスケジュ > **⚠️ 警告**: Anthropic Claude と Antigravity Claude は**同じ会話コンテキスト内で混在させることはできません**。グループを使用して適切に分離してください。 -### 既知の問題 - -Claude Code では、Plan Mode を自動的に終了できません。(通常、ネイティブの Claude API を使用する場合、計画が完了すると Claude Code はユーザーに計画を承認または拒否するオプションをポップアップ表示します。) - -**回避策**: `Shift + Tab` を押して手動で Plan Mode を終了し、計画を承認または拒否するためのレスポンスを入力してください。 - --- ## プロジェクト構成 @@ -676,16 +671,6 @@ sub2api/ └── install.sh # ワンクリックインストールスクリプト ``` -## 免責事項 - -> **本プロジェクトをご利用の前に、以下をよくお読みください:** -> -> :rotating_light: **利用規約違反のリスク**: 本プロジェクトの使用は Anthropic の利用規約に違反する可能性があります。使用前に Anthropic のユーザー契約をよくお読みください。本プロジェクトの使用に起因するすべてのリスクは、ユーザー自身が負うものとします。 -> -> :book: **免責事項**: 本プロジェクトは技術的な学習および研究目的のみで提供されています。作者は、本プロジェクトの使用によるアカウント停止、サービス中断、その他の損失について一切の責任を負いません。 - ---- - ## スター履歴 diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 3170382e8b..3ef481c326 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.143 +0.1.144 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0e94c82527..d45756d5c6 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1016,6 +1016,9 @@ type GatewayOpenAIWSSchedulerScoreWeights struct { Reset float64 `mapstructure:"reset"` // QuotaHeadroom 倾向 7d 剩余额度更健康的账号;默认 0(关闭,不改变原有行为)。 QuotaHeadroom float64 `mapstructure:"quota_headroom"` + // PreviousResponse/SessionSticky 仅在开启 OpenAI 高级调度的粘性加权时生效。 + PreviousResponse float64 `mapstructure:"previous_response"` + SessionSticky float64 `mapstructure:"session_sticky"` } // GatewayOpenAISchedulerConfig OpenAI 高级调度器配置。 @@ -1981,6 +1984,8 @@ func setDefaults() { viper.SetDefault("gateway.openai_ws.scheduler_score_weights.ttft", 0.5) viper.SetDefault("gateway.openai_ws.scheduler_score_weights.reset", 0.0) viper.SetDefault("gateway.openai_ws.scheduler_score_weights.quota_headroom", 0.0) + viper.SetDefault("gateway.openai_ws.scheduler_score_weights.previous_response", 5.0) + viper.SetDefault("gateway.openai_ws.scheduler_score_weights.session_sticky", 3.0) // OpenAI HTTP upstream protocol strategy viper.SetDefault("gateway.openai_http2.enabled", true) viper.SetDefault("gateway.openai_http2.allow_proxy_fallback_to_http1", true) @@ -2035,7 +2040,10 @@ func setDefaults() { viper.SetDefault("gateway.usage_record.worker_count", 128) viper.SetDefault("gateway.usage_record.queue_size", 16384) viper.SetDefault("gateway.usage_record.task_timeout_seconds", 5) - viper.SetDefault("gateway.usage_record.overflow_policy", UsageRecordOverflowPolicySample) + // 默认 sync:队列满时由提交方内联执行(提交点在响应写出之后,不阻塞客户端)。 + // sample/drop 会在溢出时静默丢弃计费任务,造成扣费与 usage_logs 对账缺口(issue #3656), + // 仅供显式配置的运维场景使用。 + viper.SetDefault("gateway.usage_record.overflow_policy", UsageRecordOverflowPolicySync) viper.SetDefault("gateway.usage_record.overflow_sample_percent", 10) viper.SetDefault("gateway.usage_record.auto_scale_enabled", true) viper.SetDefault("gateway.usage_record.auto_scale_min_workers", 128) @@ -2816,7 +2824,9 @@ func (c *Config) Validate() error { c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue < 0 || c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate < 0 || c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT < 0 || - c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom < 0 { + c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom < 0 || + c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse < 0 || + c.Gateway.OpenAIWS.SchedulerScoreWeights.SessionSticky < 0 { return fmt.Errorf("gateway.openai_ws.scheduler_score_weights.* must be non-negative") } weightSum := c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority + diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 2cd98b8fb8..32aff543af 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1911,8 +1911,8 @@ func TestLoad_DefaultGatewayUsageRecordConfig(t *testing.T) { if cfg.Gateway.UsageRecord.TaskTimeoutSeconds != 5 { t.Fatalf("task_timeout_seconds = %d, want 5", cfg.Gateway.UsageRecord.TaskTimeoutSeconds) } - if cfg.Gateway.UsageRecord.OverflowPolicy != UsageRecordOverflowPolicySample { - t.Fatalf("overflow_policy = %s, want %s", cfg.Gateway.UsageRecord.OverflowPolicy, UsageRecordOverflowPolicySample) + if cfg.Gateway.UsageRecord.OverflowPolicy != UsageRecordOverflowPolicySync { + t.Fatalf("overflow_policy = %s, want %s", cfg.Gateway.UsageRecord.OverflowPolicy, UsageRecordOverflowPolicySync) } if cfg.Gateway.UsageRecord.OverflowSamplePercent != 10 { t.Fatalf("overflow_sample_percent = %d, want 10", cfg.Gateway.UsageRecord.OverflowSamplePercent) diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 0d3bf88705..044e07d7a2 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -68,6 +68,9 @@ const ( SubscriptionStatusSuspended = "suspended" ) +// AntigravityGemini31ProAgentModel is the upstream route for Gemini 3.1 Pro High. +const AntigravityGemini31ProAgentModel = "gemini-pro-agent" + // DefaultAntigravityModelMapping 是 Antigravity 平台的默认模型映射 // 当账号未配置 model_mapping 时使用此默认值 // 与前端 useModelWhitelist.ts 中的 antigravityDefaultMappings 保持一致 @@ -103,10 +106,12 @@ var DefaultAntigravityModelMapping = map[string]string{ "gemini-3-flash-preview": "gemini-3-flash", "gemini-3-pro-preview": "gemini-3-pro-high", // Gemini 3.1 白名单 - "gemini-3.1-pro-high": "gemini-3.1-pro-high", - "gemini-3.1-pro-low": "gemini-3.1-pro-low", + AntigravityGemini31ProAgentModel: AntigravityGemini31ProAgentModel, + "gemini-3.1-pro": AntigravityGemini31ProAgentModel, + "gemini-3.1-pro-high": AntigravityGemini31ProAgentModel, + "gemini-3.1-pro-low": "gemini-3.1-pro-low", // Gemini 3.1 preview 映射 - "gemini-3.1-pro-preview": "gemini-3.1-pro-high", + "gemini-3.1-pro-preview": AntigravityGemini31ProAgentModel, // Gemini 3.1 image 白名单 "gemini-3.1-flash-image": "gemini-3.1-flash-image", // Gemini 3.1 image preview 映射 diff --git a/backend/internal/domain/constants_test.go b/backend/internal/domain/constants_test.go index 0b24aea915..0fb9054f7e 100644 --- a/backend/internal/domain/constants_test.go +++ b/backend/internal/domain/constants_test.go @@ -43,6 +43,28 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) { } } +func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + AntigravityGemini31ProAgentModel: AntigravityGemini31ProAgentModel, + "gemini-3.1-pro": AntigravityGemini31ProAgentModel, + "gemini-3.1-pro-high": AntigravityGemini31ProAgentModel, + "gemini-3.1-pro-preview": AntigravityGemini31ProAgentModel, + "gemini-3.1-pro-low": "gemini-3.1-pro-low", + } + + for from, want := range cases { + got, ok := DefaultAntigravityModelMapping[from] + if !ok { + t.Fatalf("expected mapping for %q to exist", from) + } + if got != want { + t.Fatalf("unexpected mapping for %q: got %q want %q", from, got, want) + } + } +} + func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) { t.Parallel() diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go index 6ec9495d84..01a5fbfa1c 100644 --- a/backend/internal/handler/admin/account_codex_import.go +++ b/backend/internal/handler/admin/account_codex_import.go @@ -253,6 +253,17 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi Message: "已有账号未记录 chatgpt_user_id,已按共享的 chatgpt_account_id 匹配并回填,请确认两者属于同一用户", }) } + preserveExistingRefresh := item.RefreshToken == "" && + codexCredentialString(existing.Credentials, "refresh_token") != "" + if preserveExistingRefresh { + result.Warnings = append(result.Warnings, CodexSessionImportMessage{ + Index: entry.Index, + Name: accountName, + Message: "已有账号包含 refresh_token,本次 accessToken-only 导入已保留自动续期凭据", + }) + effectiveExpiresAt = nil + autoPauseOnExpired = nil + } mergedCredentials := mergeCodexImportCredentials(existing.Credentials, credentials, item) mergedExtra := mergeCodexImportMap(existing.Extra, extra) updateInput := &service.UpdateAccountInput{ @@ -592,7 +603,7 @@ func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, err fingerprint := codexTokenFingerprint(item.AccessToken) item.Extra["access_token_sha256"] = fingerprint - item.IdentityKeys = buildCodexIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken) + item.IdentityKeys = buildCodexImportIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken, item.RefreshToken) item.Name = buildCodexImportAccountName(item, entry.Index) return item, nil @@ -815,13 +826,25 @@ func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any { return out } -// buildCodexIdentityKeys 按身份强度排序生成匹配键:chatgpt_account_id 在同一 -// ChatGPT 团队内是共享的,因此 account: 键排在最后,且命中时还需通过 -// codexIdentityConflicts 的跨用户校验才生效。 -func buildCodexIdentityKeys(accountID, userID, email, accessToken string) []string { +// buildCodexImportIdentityKeys 生成导入条目的匹配键。refresh_token 缺失时 +// Codex session 只能作为 accessToken-only 凭据使用,此时以 access token +// 指纹作为唯一稳定身份,避免同 workspace 下共享的 account/user 标识误合并。 +func buildCodexImportIdentityKeys(accountID, userID, email, accessToken, refreshToken string) []string { + accessToken = strings.TrimSpace(accessToken) + refreshToken = strings.TrimSpace(refreshToken) + if refreshToken == "" && accessToken != "" { + return []string{"access:" + codexTokenFingerprint(accessToken)} + } + return buildCodexStoredIdentityKeys(accountID, userID, email, accessToken) +} + +// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度, +// 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。 +func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string { keys := make([]string, 0, 3) accountID = strings.TrimSpace(accountID) userID = strings.TrimSpace(userID) + accessToken = strings.TrimSpace(accessToken) if userID != "" { keys = append(keys, "user:"+userID) } @@ -830,7 +853,7 @@ func buildCodexIdentityKeys(accountID, userID, email, accessToken string) []stri keys = append(keys, "email:"+email) } } - if accessToken = strings.TrimSpace(accessToken); accessToken != "" { + if accessToken != "" { keys = append(keys, "access:"+codexTokenFingerprint(accessToken)) } if accountID != "" { @@ -854,7 +877,8 @@ func (i *codexAccountIndex) Add(account service.Account) { if i.accountsByKey == nil { i.accountsByKey = map[string][]service.Account{} } - keys := buildCodexIdentityKeys( + i.remove(account.ID) + keys := buildCodexStoredIdentityKeys( codexCredentialString(account.Credentials, "chatgpt_account_id"), codexCredentialString(account.Credentials, "chatgpt_user_id"), codexCredentialString(account.Credentials, "email"), @@ -865,6 +889,22 @@ func (i *codexAccountIndex) Add(account service.Account) { } } +func (i *codexAccountIndex) remove(accountID int64) { + for key, accounts := range i.accountsByKey { + kept := accounts[:0] + for _, account := range accounts { + if account.ID != accountID { + kept = append(kept, account) + } + } + if len(kept) == 0 { + delete(i.accountsByKey, key) + continue + } + i.accountsByKey[key] = kept + } +} + // upsertCodexAccount 保留同一键下的全部候选账号(共享的 account: 键可对应 // 团队内多个账号),同一账号重复 Add 时原位替换为最新状态。 func upsertCodexAccount(accounts []service.Account, account service.Account) []service.Account { @@ -894,9 +934,9 @@ func (i *codexAccountIndex) Find(keys []string, userID string) (*service.Account } // codexIdentityConflicts 判断 account: 键的命中是否把同一 ChatGPT 团队的两个 -// 不同成员误连到一起:双方都携带 user id 且不相等时视为冲突。任一侧缺少 -// user id 时保留匹配,使早期未记录 chatgpt_user_id 的存量账号仍能被更新 -// (并借助凭据合并回填 user id),而不是产生重复账号。 +// 不同成员误连到一起:双方都携带 user id 且不相等时视为冲突。存量索引侧 +// 仍保留 account 键,任一侧缺少 user id 时允许匹配,使含 refresh_token +// 的常规导入和 accessToken-only 账号升级为完整 OAuth 时仍能更新原账号。 func codexIdentityConflicts(key, userID, storedUserID string) bool { if !strings.HasPrefix(key, "account:") { return false @@ -948,8 +988,15 @@ func mergeCodexImportCredentials(existing, incoming map[string]any, item *codexI return out } if strings.TrimSpace(item.RefreshToken) == "" { - delete(out, "refresh_token") - delete(out, "client_id") + if codexCredentialString(existing, "refresh_token") == "" { + delete(out, "refresh_token") + delete(out, "client_id") + } else { + out["refresh_token"] = existing["refresh_token"] + if clientID, ok := existing["client_id"]; ok { + out["client_id"] = clientID + } + } } if strings.TrimSpace(item.IDToken) == "" { delete(out, "id_token") diff --git a/backend/internal/handler/admin/account_codex_import_test.go b/backend/internal/handler/admin/account_codex_import_test.go index f4ee5bd7db..a52463aa86 100644 --- a/backend/internal/handler/admin/account_codex_import_test.go +++ b/backend/internal/handler/admin/account_codex_import_test.go @@ -1,6 +1,7 @@ package admin import ( + "context" "encoding/base64" "encoding/json" "fmt" @@ -144,7 +145,7 @@ func TestNormalizeCodexSessionJSONExtractsCredentialsAndIgnoresSessionToken(t *t } } -func TestMergeCodexImportCredentialsClearsStaleRefreshFieldsWhenIncomingHasNoRefreshToken(t *testing.T) { +func TestMergeCodexImportCredentialsPreservesExistingRefreshFieldsWhenIncomingHasNoRefreshToken(t *testing.T) { existing := map[string]any{ "access_token": "old-access-token", "refresh_token": "old-refresh-token", @@ -171,11 +172,11 @@ func TestMergeCodexImportCredentialsClearsStaleRefreshFieldsWhenIncomingHasNoRef if merged["chatgpt_account_id"] != "acct-new" { t.Fatalf("chatgpt_account_id = %v, want acct-new", merged["chatgpt_account_id"]) } - if _, ok := merged["refresh_token"]; ok { - t.Fatalf("refresh_token should be cleared") + if merged["refresh_token"] != "old-refresh-token" { + t.Fatalf("refresh_token = %v, want old-refresh-token", merged["refresh_token"]) } - if _, ok := merged["client_id"]; ok { - t.Fatalf("client_id should be cleared") + if merged["client_id"] != "old-client-id" { + t.Fatalf("client_id = %v, want old-client-id", merged["client_id"]) } if _, ok := merged["id_token"]; ok { t.Fatalf("id_token should be cleared") @@ -301,9 +302,9 @@ func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *te } func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { - keys := buildCodexIdentityKeys("acct-1", "user-1", "same@example.com", "token") + keys := buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "refresh") if len(keys) == 0 || keys[0] != "user:user-1" { - t.Fatalf("user key should have highest priority: %v", keys) + t.Fatalf("user key should have highest priority when refresh token exists: %v", keys) } if keys[len(keys)-1] != "account:acct-1" { t.Fatalf("shared account key should be the last fallback: %v", keys) @@ -314,7 +315,7 @@ func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { } } - keys = buildCodexIdentityKeys("", "", "same@example.com", "token") + keys = buildCodexImportIdentityKeys("", "", "same@example.com", "token", "refresh") hasEmail := false for _, key := range keys { if key == "email:same@example.com" { @@ -324,6 +325,11 @@ func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { if !hasEmail { t.Fatalf("weak identity should include email fallback: %v", keys) } + + keys = buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "") + if len(keys) != 1 || !strings.HasPrefix(keys[0], "access:") { + t.Fatalf("accessToken-only identity should use only access fingerprint: %v", keys) + } } func TestCodexAccountIndexDoesNotMatchDifferentUsersInSameChatGPTAccount(t *testing.T) { @@ -333,35 +339,37 @@ func TestCodexAccountIndexDoesNotMatchDifferentUsersInSameChatGPTAccount(t *test "chatgpt_account_id": "team-1", "chatgpt_user_id": "user-1", "access_token": "token-1", + "refresh_token": "refresh-1", }, } index := buildCodexAccountIndex([]service.Account{existing}) - keys := buildCodexIdentityKeys("team-1", "user-2", "", "token-2") + keys := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-2", "refresh-2") if got, _ := index.Find(keys, "user-2"); got != nil { t.Fatalf("Find matched account ID %d for a different chatgpt_user_id in the same team", got.ID) } - keys = buildCodexIdentityKeys("team-1", "user-1", "", "token-2") + keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-2", "refresh-2") got, _ := index.Find(keys, "user-1") if got == nil || got.ID != existing.ID { t.Fatalf("Find by same chatgpt_user_id = %v, want account ID %d", got, existing.ID) } } -func TestCodexAccountIndexFallsBackToAccountKeyWhenUserIDMissing(t *testing.T) { - // 存量账号缺少 chatgpt_user_id:携带 user id 的重新导入应命中并更新(回填), - // 而不是创建重复账号。 +func TestCodexAccountIndexFallsBackToAccountKeyWhenRefreshTokenExistsAndUserIDMissing(t *testing.T) { + // 含 refresh_token 的常规导入沿用 a5638a4e 的兼容逻辑:存量账号缺少 + // chatgpt_user_id 时,携带 user id 的重新导入仍可命中并回填。 legacy := service.Account{ ID: 20, Credentials: map[string]any{ "chatgpt_account_id": "team-1", "access_token": "token-old", + "refresh_token": "refresh-old", }, } index := buildCodexAccountIndex([]service.Account{legacy}) - keys := buildCodexIdentityKeys("team-1", "user-1", "", "token-new") + keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "refresh-new") got, matchedKey := index.Find(keys, "user-1") if got == nil || got.ID != legacy.ID { t.Fatalf("Find legacy account without stored user id = %v, want account ID %d", got, legacy.ID) @@ -370,30 +378,59 @@ func TestCodexAccountIndexFallsBackToAccountKeyWhenUserIDMissing(t *testing.T) { t.Fatalf("matched key = %q, want account:team-1", matchedKey) } - // 反向:导入条目无法解析出 user id 时,仍应通过 account 键命中已有账号。 + // 反向:含 refresh_token 的导入条目无法解析出 user id 时,仍应通过 + // account 键命中已有账号,保持常规导入去重行为。 full := service.Account{ ID: 21, Credentials: map[string]any{ "chatgpt_account_id": "team-2", "chatgpt_user_id": "user-9", "access_token": "token-old", + "refresh_token": "refresh-old", }, } index = buildCodexAccountIndex([]service.Account{full}) - keys = buildCodexIdentityKeys("team-2", "", "", "token-opaque") + keys = buildCodexImportIdentityKeys("team-2", "", "", "token-opaque", "refresh-new") got, _ = index.Find(keys, "") if got == nil || got.ID != full.ID { t.Fatalf("Find by account key without entry user id = %v, want account ID %d", got, full.ID) } } +func TestCodexAccountIndexAccessTokenOnlyUsesTokenFingerprint(t *testing.T) { + existing := service.Account{ + ID: 22, + Credentials: map[string]any{ + "chatgpt_account_id": "team-1", + "chatgpt_user_id": "user-1", + "access_token": "token-old", + }, + } + index := buildCodexAccountIndex([]service.Account{existing}) + + keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "") + if got, matchedKey := index.Find(keys, "user-1"); got != nil { + t.Fatalf("accessToken-only import matched by %q despite different token: account ID %d", matchedKey, got.ID) + } + + keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-old", "") + got, matchedKey := index.Find(keys, "user-1") + if got == nil || got.ID != existing.ID { + t.Fatalf("Find accessToken-only duplicate by fingerprint = %v, want account ID %d", got, existing.ID) + } + if !strings.HasPrefix(matchedKey, "access:") { + t.Fatalf("matched key = %q, want access fingerprint", matchedKey) + } +} + func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) { legacy := service.Account{ ID: 30, Credentials: map[string]any{ "chatgpt_account_id": "team-1", "access_token": "token-legacy", + "refresh_token": "refresh-legacy", }, } member := service.Account{ @@ -402,10 +439,11 @@ func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) { "chatgpt_account_id": "team-1", "chatgpt_user_id": "user-2", "access_token": "token-member", + "refresh_token": "refresh-member", }, } - // 无论索引构建顺序如何,携带新 user id 的条目都应跳过 user-2 的账号、 + // 无论索引构建顺序如何,携带新 user id 的条目都应跳过 user-2 的账号, // 命中缺少 user id 的存量账号,而不是因单一候选被遮蔽而落空。 for _, accounts := range [][]service.Account{ {member, legacy}, @@ -413,7 +451,7 @@ func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) { } { index := buildCodexAccountIndex(accounts) - keys := buildCodexIdentityKeys("team-1", "user-1", "", "token-new") + keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "refresh-new") got, matchedKey := index.Find(keys, "user-1") if got == nil || got.ID != legacy.ID { t.Fatalf("Find with shared account key = %v, want legacy account ID %d", got, legacy.ID) @@ -422,7 +460,7 @@ func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) { t.Fatalf("matched key = %q, want account:team-1", matchedKey) } - keys = buildCodexIdentityKeys("team-1", "user-2", "", "token-new") + keys = buildCodexImportIdentityKeys("team-1", "user-2", "", "token-new", "refresh-new") got, matchedKey = index.Find(keys, "user-2") if got == nil || got.ID != member.ID { t.Fatalf("Find by user key = %v, want member account ID %d", got, member.ID) @@ -449,18 +487,19 @@ func TestCodexAccountIndexUpsertReplacesSameAccount(t *testing.T) { "chatgpt_account_id": "team-1", "chatgpt_user_id": "user-1", "access_token": "token-new", + "refresh_token": "refresh-new", }, } index.Add(backfilled) // 回填后同一账号在 account 键下应被原位替换而非残留旧副本: // 其他成员的条目不应再通过旧副本(无 user id)命中该账号。 - keys := buildCodexIdentityKeys("team-1", "user-2", "", "token-other") - if got, _ := index.Find(keys, "user-2"); got != nil { - t.Fatalf("stale candidate matched after upsert: account ID %d", got.ID) + keys := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-other", "refresh-other") + if got, matchedKey := index.Find(keys, "user-2"); got != nil { + t.Fatalf("stale candidate matched after upsert by %q: account ID %d", matchedKey, got.ID) } - keys = buildCodexIdentityKeys("team-1", "user-1", "", "token-other") + keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-other", "refresh-other") got, _ := index.Find(keys, "user-1") if got == nil || got.ID != backfilled.ID { t.Fatalf("Find after upsert = %v, want account ID %d", got, backfilled.ID) @@ -472,28 +511,421 @@ func TestCodexAccountIndexUpsertReplacesSameAccount(t *testing.T) { func TestCodexIdentitySeenDistinguishesTeamMembers(t *testing.T) { seen := map[string]codexSeenIdentity{} - member1 := buildCodexIdentityKeys("team-1", "user-1", "", "token-1") + member1 := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-1", "refresh-1") markCodexIdentitySeen(seen, member1, 1, "user-1") - member2 := buildCodexIdentityKeys("team-1", "user-2", "", "token-2") + member2 := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-2", "refresh-2") if index, ok := firstSeenCodexIdentity(seen, member2, "user-2"); ok { t.Fatalf("different team member treated as duplicate of entry %d", index) } - again := buildCodexIdentityKeys("team-1", "user-1", "", "token-3") + again := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-3", "refresh-3") index, ok := firstSeenCodexIdentity(seen, again, "user-1") if !ok || index != 1 { t.Fatalf("same user re-entry dedup = (%d, %v), want (1, true)", index, ok) } - // 无 user id 的条目与已见同 account 条目视为重复(保守跳过,与既有行为一致)。 - opaque := buildCodexIdentityKeys("team-1", "", "", "token-4") + // 无 user id 的条目不应因共享 account id 与已见团队成员互相去重; + // 只有相同 access token 指纹才视为重复。 + opaque := buildCodexImportIdentityKeys("team-1", "", "", "token-4", "") index, ok = firstSeenCodexIdentity(seen, opaque, "") - if !ok || index != 1 { - t.Fatalf("entry without user id dedup = (%d, %v), want (1, true)", index, ok) + if ok { + t.Fatalf("entry without user id dedup = (%d, %v), want no match", index, ok) } } +func TestNormalizeCodexImportUsesJWTSubForAccessTokenOnlyIdentity(t *testing.T) { + accessToken := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{ + "sub": "user-from-access-token", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "workspace-1", + }, + }) + + item, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: accessToken}) + if err != nil { + t.Fatalf("normalizeCodexImportEntry error = %v", err) + } + if item.UserID != "user-from-access-token" { + t.Fatalf("UserID = %q, want JWT sub", item.UserID) + } + if len(item.IdentityKeys) != 1 || !strings.HasPrefix(item.IdentityKeys[0], "access:") { + t.Fatalf("IdentityKeys = %v, want access fingerprint only for accessToken-only import", item.IdentityKeys) + } + if got := item.Credentials["chatgpt_user_id"]; got != "user-from-access-token" { + t.Fatalf("credential chatgpt_user_id = %v, want JWT sub", got) + } +} + +func TestImportCodexSessionsAccessTokenOnlySameWorkspaceDifferentUsersCreatesTwoAccounts(t *testing.T) { + svc := newCodexImportMemoryAdminService(nil) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: buildCodexAccessOnlyImportValue(t, "workspace-1", "user-1")}, + {Index: 2, Value: buildCodexAccessOnlyImportValue(t, "workspace-1", "user-2")}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 { + t.Fatalf("result = %+v, want two created accounts", result) + } + if len(svc.createdAccounts) != 2 { + t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts)) + } + if svc.createdAccounts[0].Credentials["chatgpt_user_id"] == svc.createdAccounts[1].Credentials["chatgpt_user_id"] { + t.Fatalf("created accounts share user id: %v", svc.createdAccounts) + } +} + +func TestImportCodexSessionsAccessTokenOnlySameWorkspaceAndUserDifferentTokensCreatesTwoAccounts(t *testing.T) { + svc := newCodexImportMemoryAdminService(nil) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: map[string]any{ + "access_token": buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{ + "sub": "shared-user", + "jti": "token-1", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "workspace-1", + }, + }), + }}, + {Index: 2, Value: map[string]any{ + "access_token": buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{ + "sub": "shared-user", + "jti": "token-2", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "workspace-1", + }, + }), + }}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 { + t.Fatalf("result = %+v, want two created accounts", result) + } + if len(svc.createdAccounts) != 2 { + t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts)) + } +} + +func TestImportCodexSessionsAccessTokenOnlySameUserUpdatesExisting(t *testing.T) { + existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour)) + svc := newCodexImportMemoryAdminService([]service.Account{{ + ID: 10, + Name: "existing", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "workspace-1", + "chatgpt_user_id": "user-1", + "access_token": existingToken, + }, + }}) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: map[string]any{"access_token": existingToken}}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 0 || result.Updated != 1 || result.Failed != 0 { + t.Fatalf("result = %+v, want one updated account", result) + } + if len(svc.createdAccounts) != 0 { + t.Fatalf("created accounts = %d, want 0", len(svc.createdAccounts)) + } + if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 10 { + t.Fatalf("updated accounts = %+v, want account 10", svc.updatedAccounts) + } +} + +func TestImportCodexSessionsUpgradesAccessTokenOnlyAccountWithRefreshToken(t *testing.T) { + oldToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "old-token", time.Now().Add(time.Hour)) + newToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "new-token", time.Now().Add(time.Hour)) + svc := newCodexImportMemoryAdminService([]service.Account{{ + ID: 12, + Name: "existing", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "workspace-1", + "chatgpt_user_id": "user-1", + "access_token": oldToken, + }, + }}) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: map[string]any{ + "access_token": newToken, + "refresh_token": "refresh-new", + }}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 0 || result.Updated != 1 || result.Failed != 0 { + t.Fatalf("result = %+v, want one updated account", result) + } + if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 12 { + t.Fatalf("updated accounts = %+v, want account 12", svc.updatedAccounts) + } + if got := svc.updatedAccounts[0].input.Credentials["refresh_token"]; got != "refresh-new" { + t.Fatalf("updated refresh_token = %v, want refresh-new", got) + } +} + +func TestImportCodexSessionsAccessTokenOnlyPreservesExistingRefreshToken(t *testing.T) { + existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour)) + svc := newCodexImportMemoryAdminService([]service.Account{{ + ID: 13, + Name: "existing", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "workspace-1", + "chatgpt_user_id": "user-1", + "access_token": existingToken, + "refresh_token": "refresh-old", + "client_id": "client-old", + }, + }}) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: map[string]any{"access_token": existingToken}}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 0 || result.Updated != 1 || result.Failed != 0 { + t.Fatalf("result = %+v, want one updated account", result) + } + update := svc.updatedAccounts[0].input + if got := update.Credentials["refresh_token"]; got != "refresh-old" { + t.Fatalf("refresh_token = %v, want refresh-old", got) + } + if got := update.Credentials["client_id"]; got != "client-old" { + t.Fatalf("client_id = %v, want client-old", got) + } + if update.ExpiresAt != nil { + t.Fatalf("ExpiresAt = %v, want nil to preserve OAuth account expiry", *update.ExpiresAt) + } + if update.AutoPauseOnExpired != nil { + t.Fatalf("AutoPauseOnExpired = %v, want nil to preserve OAuth account scheduling", *update.AutoPauseOnExpired) + } +} + +func TestImportCodexSessionsBatchOldAccessTokenDoesNotRollbackRefreshToken(t *testing.T) { + oldToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "old-token", time.Now().Add(time.Hour)) + newToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "new-token", time.Now().Add(time.Hour)) + svc := newCodexImportMemoryAdminService([]service.Account{{ + ID: 14, + Name: "existing", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "workspace-1", + "chatgpt_user_id": "user-1", + "access_token": oldToken, + "refresh_token": "refresh-old", + }, + }}) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: map[string]any{ + "access_token": newToken, + "refresh_token": "refresh-new", + }}, + {Index: 2, Value: map[string]any{"access_token": oldToken}}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Updated != 1 || result.Created != 1 || result.Failed != 0 { + t.Fatalf("result = %+v, want first item updated and stale access token created separately", result) + } + if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 14 { + t.Fatalf("updated accounts = %+v, want account 14 updated once", svc.updatedAccounts) + } + stored, err := svc.GetAccount(context.Background(), 14) + if err != nil { + t.Fatalf("GetAccount error = %v", err) + } + if got := stored.Credentials["access_token"]; got != newToken { + t.Fatalf("stored access_token rolled back = %v, want new token", got) + } + if got := stored.Credentials["refresh_token"]; got != "refresh-new" { + t.Fatalf("stored refresh_token = %v, want refresh-new", got) + } +} + +func TestImportCodexSessionsWithRefreshTokenKeepsExistingDedup(t *testing.T) { + existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour)) + svc := newCodexImportMemoryAdminService([]service.Account{{ + ID: 11, + Name: "existing", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "workspace-1", + "chatgpt_user_id": "user-1", + "access_token": existingToken, + "refresh_token": "refresh-old", + }, + }}) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-new")}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 0 || result.Updated != 1 || result.Failed != 0 { + t.Fatalf("result = %+v, want one updated account", result) + } + if got := svc.updatedAccounts[0].input.Credentials["refresh_token"]; got != "refresh-new" { + t.Fatalf("updated refresh_token = %v, want refresh-new", got) + } +} + +type codexImportMemoryAdminService struct { + *stubAdminService + nextID int64 + updatedAccounts []struct { + id int64 + input *service.UpdateAccountInput + } +} + +func newCodexImportMemoryAdminService(accounts []service.Account) *codexImportMemoryAdminService { + stub := newStubAdminService() + stub.accounts = append([]service.Account(nil), accounts...) + return &codexImportMemoryAdminService{ + stubAdminService: stub, + nextID: 100, + } +} + +func (s *codexImportMemoryAdminService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) { + s.createdAccounts = append(s.createdAccounts, input) + if s.createAccountErr != nil { + return nil, s.createAccountErr + } + account := service.Account{ + ID: s.nextID, + Name: input.Name, + Platform: input.Platform, + Type: input.Type, + Status: service.StatusActive, + Credentials: cloneCodexImportTestMap(input.Credentials), + Extra: cloneCodexImportTestMap(input.Extra), + } + s.nextID++ + s.accounts = append(s.accounts, account) + return &account, nil +} + +func (s *codexImportMemoryAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) { + s.updatedAccounts = append(s.updatedAccounts, struct { + id int64 + input *service.UpdateAccountInput + }{id: id, input: input}) + if s.updateAccountErr != nil { + return nil, s.updateAccountErr + } + for idx := range s.accounts { + if s.accounts[idx].ID == id { + s.accounts[idx].Credentials = cloneCodexImportTestMap(input.Credentials) + s.accounts[idx].Extra = cloneCodexImportTestMap(input.Extra) + return &s.accounts[idx], nil + } + } + account := service.Account{ID: id, Status: service.StatusActive, Credentials: cloneCodexImportTestMap(input.Credentials)} + return &account, nil +} + +func (s *codexImportMemoryAdminService) GetAccount(ctx context.Context, id int64) (*service.Account, error) { + for idx := range s.accounts { + if s.accounts[idx].ID == id { + return &s.accounts[idx], nil + } + } + return s.stubAdminService.GetAccount(ctx, id) +} + +func buildCodexAccessOnlyImportValue(t *testing.T, accountID, userID string) map[string]any { + t.Helper() + return map[string]any{ + "access_token": buildCodexAccessToken(t, accountID, userID, time.Now().Add(time.Hour)), + } +} + +func buildCodexRefreshImportValue(t *testing.T, accountID, userID, refreshToken string) map[string]any { + t.Helper() + return map[string]any{ + "access_token": buildCodexAccessToken(t, accountID, userID, time.Now().Add(time.Hour)), + "refresh_token": refreshToken, + } +} + +func buildCodexAccessToken(t *testing.T, accountID, userID string, exp time.Time) string { + t.Helper() + return buildCodexAccessTokenWithJTI(t, accountID, userID, "", exp) +} + +func buildCodexAccessTokenWithJTI(t *testing.T, accountID, userID, jti string, exp time.Time) string { + t.Helper() + claims := map[string]any{ + "sub": userID, + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": accountID, + }, + } + if jti != "" { + claims["jti"] = jti + } + return buildCodexImportTestJWT(t, exp, claims) +} + +func cloneCodexImportTestMap(input map[string]any) map[string]any { + if input == nil { + return nil + } + out := make(map[string]any, len(input)) + for key, value := range input { + out[key] = value + } + return out +} + +func boolPtr(v bool) *bool { + return &v +} + func buildCodexImportTestJWT(t *testing.T, exp time.Time, extraClaims map[string]any) string { t.Helper() header := map[string]any{ diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 8d4a5dfea6..efe95801c3 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -171,15 +171,39 @@ type CheckMixedChannelRequest struct { // AccountWithConcurrency extends Account with real-time concurrency info type AccountWithConcurrency struct { *dto.Account - CurrentConcurrency int `json:"current_concurrency"` + CurrentConcurrency int `json:"current_concurrency"` + SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"` + SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"` // 以下字段仅对 Anthropic OAuth/SetupToken 账号有效,且仅在启用相应功能时返回 CurrentWindowCost *float64 `json:"current_window_cost,omitempty"` // 当前窗口费用 ActiveSessions *int `json:"active_sessions,omitempty"` // 当前活跃会话数 CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数 } +type AccountSchedulerScore struct { + BaseScore float64 `json:"base_score"` + StickyScore float64 `json:"sticky_score"` + StickyScoreInfinity bool `json:"sticky_score_infinity"` + StickyWeightedEnabled bool `json:"sticky_weighted_enabled"` +} + +type AccountSchedulerGroupScore struct { + GroupID *int64 `json:"group_id"` + GroupName string `json:"group_name,omitempty"` + GroupPriority *int `json:"group_priority,omitempty"` + AccountSchedulerScore +} + const accountListGroupUngroupedQueryValue = "ungrouped" +type openAIAccountSchedulerScorePoolLister interface { + ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]service.Account, error) +} + +type accountSchedulerScoreFilterPoolLister interface { + ListAccountsForSchedulerScoreFilter(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, error) +} + func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency { item := AccountWithConcurrency{ Account: dto.AccountFromService(account), @@ -226,6 +250,185 @@ func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, ac return item } +func (h *AccountHandler) scoreOpenAIAccountSchedulerPool(ctx context.Context, accounts []service.Account) map[int64]AccountSchedulerScore { + if len(accounts) == 0 { + return nil + } + + openAIAccounts := make([]*service.Account, 0, len(accounts)) + loadReq := make([]service.AccountWithConcurrency, 0, len(accounts)) + for i := range accounts { + account := &accounts[i] + if account.Platform != service.PlatformOpenAI { + continue + } + openAIAccounts = append(openAIAccounts, account) + loadReq = append(loadReq, service.AccountWithConcurrency{ + ID: account.ID, + MaxConcurrency: account.EffectiveLoadFactor(), + }) + } + if len(openAIAccounts) == 0 { + return nil + } + + loadMap := map[int64]*service.AccountLoadInfo{} + if h.concurrencyService != nil { + if batchLoad, err := h.concurrencyService.GetAccountsLoadBatch(ctx, loadReq); err == nil && batchLoad != nil { + loadMap = batchLoad + } + } + + var scores map[int64]service.OpenAIAccountSchedulerScoreSnapshot + if h.rateLimitService != nil { + scores = h.rateLimitService.BuildOpenAIAccountSchedulerScoreSnapshot(ctx, openAIAccounts, loadMap) + } else { + scores = service.BuildOpenAIAccountSchedulerScoreSnapshot(openAIAccounts, loadMap) + } + result := make(map[int64]AccountSchedulerScore, len(scores)) + for accountID, score := range scores { + result[accountID] = AccountSchedulerScore{ + BaseScore: score.BaseScore, + StickyScore: score.StickyScore, + StickyScoreInfinity: score.StickyScoreInfinity, + StickyWeightedEnabled: score.StickyWeightedEnabled, + } + } + return result +} + +func (h *AccountHandler) buildOpenAIAccountSchedulerScores( + ctx context.Context, + accounts []service.Account, + filterPool []service.Account, +) (map[int64]*AccountSchedulerScore, map[int64][]AccountSchedulerGroupScore) { + if len(accounts) == 0 { + return nil, nil + } + if len(filterPool) == 0 { + filterPool = accounts + } + + baseScores := make(map[int64]*AccountSchedulerScore) + for accountID, score := range h.scoreOpenAIAccountSchedulerPool(ctx, filterPool) { + copiedScore := score + baseScores[accountID] = &copiedScore + } + + pageOpenAIAccountIDs := make(map[int64]struct{}) + groupIDs := make(map[int64]struct{}) + for i := range accounts { + account := &accounts[i] + if account.Platform != service.PlatformOpenAI { + continue + } + pageOpenAIAccountIDs[account.ID] = struct{}{} + if len(account.AccountGroups) == 0 && len(account.GroupIDs) == 0 { + continue + } + for _, accountGroup := range account.AccountGroups { + if accountGroup.GroupID > 0 { + groupIDs[accountGroup.GroupID] = struct{}{} + } + } + for _, groupID := range account.GroupIDs { + if groupID > 0 { + groupIDs[groupID] = struct{}{} + } + } + } + if len(pageOpenAIAccountIDs) == 0 { + return baseScores, nil + } + + groupScoresByAccount := make(map[int64][]AccountSchedulerGroupScore) + scoreGroupPool := func(groupID *int64, groupNameByID map[int64]string, groupPriorityByAccount map[int64]int, pool []service.Account) { + if len(pool) == 0 { + return + } + scores := h.scoreOpenAIAccountSchedulerPool(ctx, pool) + for accountID, schedulerScore := range scores { + if _, ok := pageOpenAIAccountIDs[accountID]; !ok { + continue + } + groupScore := AccountSchedulerGroupScore{ + GroupID: groupID, + AccountSchedulerScore: schedulerScore, + } + if groupID != nil { + groupScore.GroupName = groupNameByID[*groupID] + if priority, ok := groupPriorityByAccount[accountID]; ok { + groupScore.GroupPriority = &priority + } + } + groupScoresByAccount[accountID] = append(groupScoresByAccount[accountID], groupScore) + } + } + + if lister, ok := h.adminService.(openAIAccountSchedulerScorePoolLister); ok { + groupIDList := make([]int64, 0, len(groupIDs)) + for groupID := range groupIDs { + groupIDList = append(groupIDList, groupID) + } + sort.Slice(groupIDList, func(i, j int) bool { return groupIDList[i] < groupIDList[j] }) + + for _, groupID := range groupIDList { + gid := groupID + pool, err := lister.ListOpenAISchedulableAccountsForSchedulerScore(ctx, &gid) + if err != nil { + slog.Warn("openai_scheduler_group_score_pool_failed", "group_id", gid, "error", err) + continue + } + groupNameByID := make(map[int64]string) + groupPriorityByAccount := make(map[int64]int) + for i := range pool { + account := &pool[i] + for _, accountGroup := range account.AccountGroups { + if accountGroup.GroupID != gid { + continue + } + groupPriorityByAccount[account.ID] = accountGroup.Priority + if accountGroup.Group != nil { + groupNameByID[gid] = accountGroup.Group.Name + } + } + } + scoreGroupPool(&gid, groupNameByID, groupPriorityByAccount, pool) + } + + } + + for accountID := range groupScoresByAccount { + sort.SliceStable(groupScoresByAccount[accountID], func(i, j int) bool { + left := groupScoresByAccount[accountID][i] + right := groupScoresByAccount[accountID][j] + return *left.GroupID < *right.GroupID + }) + } + return baseScores, groupScoresByAccount +} + +func (h *AccountHandler) listAccountSchedulerScoreFilterPool( + ctx context.Context, + platform, accountType, status, search string, + groupID int64, + privacyMode string, +) []service.Account { + if h.adminService == nil || (platform != "" && platform != service.PlatformOpenAI) { + return nil + } + lister, ok := h.adminService.(accountSchedulerScoreFilterPoolLister) + if !ok { + return nil + } + accounts, err := lister.ListAccountsForSchedulerScoreFilter(ctx, platform, accountType, status, search, groupID, privacyMode) + if err != nil { + slog.Warn("openai_scheduler_filter_score_pool_failed", "error", err) + return nil + } + return accounts +} + // List handles listing all accounts with pagination // GET /api/v1/admin/accounts func (h *AccountHandler) List(c *gin.Context) { @@ -278,6 +481,8 @@ func (h *AccountHandler) List(c *gin.Context) { var windowCosts map[int64]float64 var activeSessions map[int64]int var rpmCounts map[int64]int + schedulerFilterPool := h.listAccountSchedulerScoreFilterPool(c.Request.Context(), platform, accountType, status, search, groupID, privacyMode) + schedulerScores, schedulerGroupScores := h.buildOpenAIAccountSchedulerScores(c.Request.Context(), accounts, schedulerFilterPool) // 始终获取并发数(Redis ZCARD,极低开销) if h.concurrencyService != nil { @@ -358,6 +563,8 @@ func (h *AccountHandler) List(c *gin.Context) { item := AccountWithConcurrency{ Account: dto.AccountFromService(acc), CurrentConcurrency: concurrencyCounts[acc.ID], + SchedulerScore: schedulerScores[acc.ID], + SchedulerScores: schedulerGroupScores[acc.ID], } // 添加窗口费用(仅当启用时) diff --git a/backend/internal/handler/admin/account_handler_list_test.go b/backend/internal/handler/admin/account_handler_list_test.go index 4d628365df..29e36ad865 100644 --- a/backend/internal/handler/admin/account_handler_list_test.go +++ b/backend/internal/handler/admin/account_handler_list_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -50,3 +51,222 @@ func TestAccountHandlerListIncludesCreatedAt(t *testing.T) { _, offset := parsed.Zone() require.Equal(t, 0, offset) } + +func TestAccountHandlerListReturnsSchedulerScoresPerGroup(t *testing.T) { + router, adminSvc := setupAccountListRouter() + now := time.Now().UTC() + groupID := int64(41) + adminSvc.accounts = []service.Account{ + { + ID: 101, + Name: "account-high-priority", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 10, + Priority: 1, + AccountGroups: []service.AccountGroup{ + {AccountID: 101, GroupID: groupID, Priority: 100, Group: &service.Group{ID: groupID, Name: "openai"}}, + }, + GroupIDs: []int64{groupID}, + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: 102, + Name: "account-low-priority", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 10, + Priority: 100000, + AccountGroups: []service.AccountGroup{ + {AccountID: 102, GroupID: groupID, Priority: 1, Group: &service.Group{ID: groupID, Name: "openai"}}, + }, + GroupIDs: []int64{groupID}, + CreatedAt: now, + UpdatedAt: now, + }, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&platform=openai", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var payload struct { + Data struct { + Items []struct { + ID int64 `json:"id"` + SchedulerScore struct { + BaseScore float64 `json:"base_score"` + } `json:"scheduler_score"` + SchedulerScores []struct { + GroupID *int64 `json:"group_id"` + GroupName string `json:"group_name"` + GroupPriority *int `json:"group_priority"` + BaseScore float64 `json:"base_score"` + } `json:"scheduler_scores"` + } `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Len(t, payload.Data.Items, 2) + + var high, low *struct { + ID int64 `json:"id"` + SchedulerScore struct { + BaseScore float64 `json:"base_score"` + } `json:"scheduler_score"` + SchedulerScores []struct { + GroupID *int64 `json:"group_id"` + GroupName string `json:"group_name"` + GroupPriority *int `json:"group_priority"` + BaseScore float64 `json:"base_score"` + } `json:"scheduler_scores"` + } + for i := range payload.Data.Items { + item := &payload.Data.Items[i] + switch item.ID { + case 101: + high = item + case 102: + low = item + } + } + require.NotNil(t, high) + require.NotNil(t, low) + require.Len(t, high.SchedulerScores, 1) + require.Len(t, low.SchedulerScores, 1) + require.Equal(t, groupID, *high.SchedulerScores[0].GroupID) + require.Equal(t, "openai", high.SchedulerScores[0].GroupName) + require.Equal(t, 100, *high.SchedulerScores[0].GroupPriority) + require.Equal(t, 1, *low.SchedulerScores[0].GroupPriority) + require.Greater(t, high.SchedulerScores[0].BaseScore, low.SchedulerScores[0].BaseScore) +} + +func TestAccountHandlerListKeepsSchedulerScoreScopedToFilter(t *testing.T) { + router, adminSvc := setupAccountListRouter() + now := time.Now().UTC() + groupID := int64(42) + visibleAccount := service.Account{ + ID: 201, + Name: "visible-low-priority", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 10, + Priority: 100000, + AccountGroups: []service.AccountGroup{ + {AccountID: 201, GroupID: groupID, Priority: 1, Group: &service.Group{ID: groupID, Name: "openai"}}, + }, + GroupIDs: []int64{groupID}, + CreatedAt: now, + UpdatedAt: now, + } + hiddenGroupPeer := service.Account{ + ID: 202, + Name: "hidden-high-priority", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 10, + Priority: 1, + AccountGroups: []service.AccountGroup{ + {AccountID: 202, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "openai"}}, + }, + GroupIDs: []int64{groupID}, + CreatedAt: now, + UpdatedAt: now, + } + adminSvc.accounts = []service.Account{visibleAccount} + adminSvc.accountSchedulerScoreFilterAccounts = []service.Account{visibleAccount, hiddenGroupPeer} + adminSvc.openAISchedulerScorePoolAccounts = []service.Account{visibleAccount, hiddenGroupPeer} + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var payload struct { + Data struct { + Items []struct { + ID int64 `json:"id"` + SchedulerScore struct { + BaseScore float64 `json:"base_score"` + } `json:"scheduler_score"` + SchedulerScores []struct { + GroupID *int64 `json:"group_id"` + BaseScore float64 `json:"base_score"` + } `json:"scheduler_scores"` + } `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Len(t, payload.Data.Items, 1) + item := payload.Data.Items[0] + require.Equal(t, int64(201), item.ID) + require.Len(t, item.SchedulerScores, 1) + require.Equal(t, groupID, *item.SchedulerScores[0].GroupID) + require.Equal(t, item.SchedulerScores[0].BaseScore, item.SchedulerScore.BaseScore) +} + +func TestAccountHandlerListSchedulerScoreIgnoresPagination(t *testing.T) { + router, adminSvc := setupAccountListRouter() + now := time.Now().UTC() + visibleAccount := service.Account{ + ID: 301, + Name: "visible-low-priority", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 10, + Priority: 100000, + CreatedAt: now, + UpdatedAt: now, + } + hiddenFilterPeer := service.Account{ + ID: 302, + Name: "hidden-high-priority", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 10, + Priority: 1, + CreatedAt: now, + UpdatedAt: now, + } + adminSvc.accounts = []service.Account{visibleAccount} + adminSvc.accountSchedulerScoreFilterAccounts = []service.Account{visibleAccount, hiddenFilterPeer} + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var payload struct { + Data struct { + Items []struct { + ID int64 `json:"id"` + SchedulerScore struct { + BaseScore float64 `json:"base_score"` + } `json:"scheduler_score"` + SchedulerScores []struct { + GroupID *int64 `json:"group_id"` + BaseScore float64 `json:"base_score"` + } `json:"scheduler_scores"` + } `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Len(t, payload.Data.Items, 1) + require.Equal(t, int64(301), payload.Data.Items[0].ID) + require.Less(t, payload.Data.Items[0].SchedulerScore.BaseScore, 3.75) + require.Empty(t, payload.Data.Items[0].SchedulerScores) +} diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index f7bab13253..187925e33a 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -10,27 +10,29 @@ import ( ) type stubAdminService struct { - users []service.User - apiKeys []service.APIKey - groups []service.Group - accounts []service.Account - proxies []service.Proxy - proxyCounts []service.ProxyWithAccountCount - redeems []service.RedeemCode - boundAuthIdentity *service.AdminBindAuthIdentityInput - boundAuthIdentityFor int64 - createdAccounts []*service.CreateAccountInput - createdProxies []*service.CreateProxyInput - updatedProxyIDs []int64 - updatedProxies []*service.UpdateProxyInput - testedProxyIDs []int64 - getUserErr error - createAccountErr error - createSparkShadowErr error - updateAccountErr error - bulkUpdateAccountErr error - checkMixedErr error - lastMixedCheck struct { + users []service.User + apiKeys []service.APIKey + groups []service.Group + accounts []service.Account + accountSchedulerScoreFilterAccounts []service.Account + openAISchedulerScorePoolAccounts []service.Account + proxies []service.Proxy + proxyCounts []service.ProxyWithAccountCount + redeems []service.RedeemCode + boundAuthIdentity *service.AdminBindAuthIdentityInput + boundAuthIdentityFor int64 + createdAccounts []*service.CreateAccountInput + createdProxies []*service.CreateProxyInput + updatedProxyIDs []int64 + updatedProxies []*service.UpdateProxyInput + testedProxyIDs []int64 + getUserErr error + createAccountErr error + createSparkShadowErr error + updateAccountErr error + bulkUpdateAccountErr error + checkMixedErr error + lastMixedCheck struct { accountID int64 platform string groupIDs []int64 @@ -329,7 +331,56 @@ func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, s.lastListAccounts.sortBy = sortBy s.lastListAccounts.sortOrder = sortOrder s.lastListAccounts.calls++ - return s.accounts, int64(len(s.accounts)), nil + accounts := s.accounts + total := len(accounts) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = total + } + start := (page - 1) * pageSize + if start >= total { + return []service.Account{}, int64(total), nil + } + end := start + pageSize + if end > total { + end = total + } + return accounts[start:end], int64(total), nil +} + +func (s *stubAdminService) ListAccountsForSchedulerScoreFilter(_ context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, error) { + if s.accountSchedulerScoreFilterAccounts != nil { + return s.accountSchedulerScoreFilterAccounts, nil + } + return s.accounts, nil +} + +func (s *stubAdminService) ListOpenAISchedulableAccountsForSchedulerScore(_ context.Context, groupID *int64) ([]service.Account, error) { + accounts := s.openAISchedulerScorePoolAccounts + if accounts == nil { + accounts = s.accounts + } + out := make([]service.Account, 0, len(accounts)) + for _, account := range accounts { + if account.Platform != service.PlatformOpenAI || !account.IsSchedulable() { + continue + } + if groupID == nil { + if len(account.AccountGroups) == 0 && len(account.GroupIDs) == 0 { + out = append(out, account) + } + continue + } + for _, accountGroup := range account.AccountGroups { + if accountGroup.GroupID == *groupID { + out = append(out, account) + break + } + } + } + return out, nil } func (s *stubAdminService) GetAccount(ctx context.Context, id int64) (*service.Account, error) { diff --git a/backend/internal/handler/admin/ops_handler.go b/backend/internal/handler/admin/ops_handler.go index b9558b97b1..e820aef0c6 100644 --- a/backend/internal/handler/admin/ops_handler.go +++ b/backend/internal/handler/admin/ops_handler.go @@ -73,6 +73,13 @@ func NewOpsHandler(opsService *service.OpsService) *OpsHandler { } // GetErrorLogs lists ops error logs. +// applyOpsErrorSortParams reads sort_by/sort_order query params into the filter. +// Column whitelist and order normalization live in the repository; unknown +// values degrade to the default (created_at DESC), mirroring the usage list. +func applyOpsErrorSortParams(c *gin.Context, filter *service.OpsErrorLogFilter) { + filter.SetSort(c.Query("sort_by"), c.Query("sort_order")) +} + // GET /api/v1/admin/ops/errors func (h *OpsHandler) GetErrorLogs(c *gin.Context) { if h.opsService == nil { @@ -114,10 +121,17 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) { // buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。 filter.Model = strings.TrimSpace(c.Query("model")) - // Force request errors: client-visible status >= 400. - // buildOpsErrorLogsWhere already applies this for non-upstream phase. - if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") { - filter.Phase = "" + // 请求错误语义:client-visible status>=400 守卫恒生效(未设 + // IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故 + // phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致 + // 错误类型下拉选「上游」等于不过滤。 + + // 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射; + // 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。 + if cat := strings.TrimSpace(c.Query("category")); cat != "" { + phases, types := service.CategoryToFilter(cat) + filter.ErrorPhasesAny = phases + filter.ErrorTypesAny = types } if platform := strings.TrimSpace(c.Query("platform")); platform != "" { @@ -187,6 +201,8 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) { filter.StatusCodes = out } + applyOpsErrorSortParams(c, filter) + result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter) if err != nil { response.ErrorFrom(c, err) @@ -234,10 +250,17 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) { // buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。 filter.Model = strings.TrimSpace(c.Query("model")) - // Force request errors: client-visible status >= 400. - // buildOpsErrorLogsWhere already applies this for non-upstream phase. - if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") { - filter.Phase = "" + // 请求错误语义:client-visible status>=400 守卫恒生效(未设 + // IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故 + // phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致 + // 错误类型下拉选「上游」等于不过滤。 + + // 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射; + // 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。 + if cat := strings.TrimSpace(c.Query("category")); cat != "" { + phases, types := service.CategoryToFilter(cat) + filter.ErrorPhasesAny = phases + filter.ErrorTypesAny = types } if platform := strings.TrimSpace(c.Query("platform")); platform != "" { @@ -291,6 +314,8 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) { filter.StatusCodes = out } + applyOpsErrorSortParams(c, filter) + result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter) if err != nil { response.ErrorFrom(c, err) @@ -362,6 +387,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) { } filter.View = "all" filter.Phase = "upstream" + // 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。 + filter.IncludeRecoveredUpstream = true filter.Owner = "provider" filter.Source = strings.TrimSpace(c.Query("error_source")) filter.Query = strings.TrimSpace(c.Query("q")) @@ -377,6 +404,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) { filter.ClientRequestID = clientRequestID } + applyOpsErrorSortParams(c, filter) + result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter) if err != nil { response.ErrorFrom(c, err) @@ -442,6 +471,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) { filter.View = parseOpsViewParam(c) filter.Phase = "upstream" + // 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。 + filter.IncludeRecoveredUpstream = true filter.Owner = "provider" filter.Source = strings.TrimSpace(c.Query("error_source")) filter.Query = strings.TrimSpace(c.Query("q")) @@ -497,6 +528,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) { filter.StatusCodes = out } + applyOpsErrorSortParams(c, filter) + result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 0f50ab723b..624fddb1e6 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -119,188 +119,210 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { } payload := dto.SystemSettings{ - RegistrationEnabled: settings.RegistrationEnabled, - EmailVerifyEnabled: settings.EmailVerifyEnabled, - RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: settings.PromoCodeEnabled, - PasswordResetEnabled: settings.PasswordResetEnabled, - FrontendURL: settings.FrontendURL, - InvitationCodeEnabled: settings.InvitationCodeEnabled, - TotpEnabled: settings.TotpEnabled, - TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), - LoginAgreementEnabled: settings.LoginAgreementEnabled, - LoginAgreementMode: settings.LoginAgreementMode, - LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, - LoginAgreementDocuments: loginAgreementDocumentsToDTO(settings.LoginAgreementDocuments), - SMTPHost: settings.SMTPHost, - SMTPPort: settings.SMTPPort, - SMTPUsername: settings.SMTPUsername, - SMTPPasswordConfigured: settings.SMTPPasswordConfigured, - SMTPFrom: settings.SMTPFrom, - SMTPFromName: settings.SMTPFromName, - SMTPUseTLS: settings.SMTPUseTLS, - TurnstileEnabled: settings.TurnstileEnabled, - TurnstileSiteKey: settings.TurnstileSiteKey, - TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured, - APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP, - LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled, - LinuxDoConnectClientID: settings.LinuxDoConnectClientID, - LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured, - LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL, - DingTalkConnectEnabled: settings.DingTalkConnectEnabled, - DingTalkConnectClientID: settings.DingTalkConnectClientID, - DingTalkConnectClientSecretConfigured: settings.DingTalkConnectClientSecretConfigured, - DingTalkConnectRedirectURL: settings.DingTalkConnectRedirectURL, - DingTalkConnectCorpRestrictionPolicy: settings.DingTalkConnectCorpRestrictionPolicy, - DingTalkConnectInternalCorpID: settings.DingTalkConnectInternalCorpID, - DingTalkConnectBypassRegistration: settings.DingTalkConnectBypassRegistration, - DingTalkConnectSyncCorpEmail: settings.DingTalkConnectSyncCorpEmail, - DingTalkConnectSyncDisplayName: settings.DingTalkConnectSyncDisplayName, - DingTalkConnectSyncDept: settings.DingTalkConnectSyncDept, - DingTalkConnectSyncCorpEmailAttrKey: settings.DingTalkConnectSyncCorpEmailAttrKey, - DingTalkConnectSyncDisplayNameAttrKey: settings.DingTalkConnectSyncDisplayNameAttrKey, - DingTalkConnectSyncDeptAttrKey: settings.DingTalkConnectSyncDeptAttrKey, - DingTalkConnectSyncCorpEmailAttrName: settings.DingTalkConnectSyncCorpEmailAttrName, - DingTalkConnectSyncDisplayNameAttrName: settings.DingTalkConnectSyncDisplayNameAttrName, - DingTalkConnectSyncDeptAttrName: settings.DingTalkConnectSyncDeptAttrName, - WeChatConnectEnabled: settings.WeChatConnectEnabled, - WeChatConnectAppID: settings.WeChatConnectAppID, - WeChatConnectAppSecretConfigured: settings.WeChatConnectAppSecretConfigured, - WeChatConnectOpenAppID: settings.WeChatConnectOpenAppID, - WeChatConnectOpenAppSecretConfigured: settings.WeChatConnectOpenAppSecretConfigured, - WeChatConnectMPAppID: settings.WeChatConnectMPAppID, - WeChatConnectMPAppSecretConfigured: settings.WeChatConnectMPAppSecretConfigured, - WeChatConnectMobileAppID: settings.WeChatConnectMobileAppID, - WeChatConnectMobileAppSecretConfigured: settings.WeChatConnectMobileAppSecretConfigured, - WeChatConnectOpenEnabled: settings.WeChatConnectOpenEnabled, - WeChatConnectMPEnabled: settings.WeChatConnectMPEnabled, - WeChatConnectMobileEnabled: settings.WeChatConnectMobileEnabled, - WeChatConnectMode: settings.WeChatConnectMode, - WeChatConnectScopes: settings.WeChatConnectScopes, - WeChatConnectRedirectURL: settings.WeChatConnectRedirectURL, - WeChatConnectFrontendRedirectURL: settings.WeChatConnectFrontendRedirectURL, - OIDCConnectEnabled: settings.OIDCConnectEnabled, - OIDCConnectProviderName: settings.OIDCConnectProviderName, - OIDCConnectClientID: settings.OIDCConnectClientID, - OIDCConnectClientSecretConfigured: settings.OIDCConnectClientSecretConfigured, - OIDCConnectIssuerURL: settings.OIDCConnectIssuerURL, - OIDCConnectDiscoveryURL: settings.OIDCConnectDiscoveryURL, - OIDCConnectAuthorizeURL: settings.OIDCConnectAuthorizeURL, - OIDCConnectTokenURL: settings.OIDCConnectTokenURL, - OIDCConnectUserInfoURL: settings.OIDCConnectUserInfoURL, - OIDCConnectJWKSURL: settings.OIDCConnectJWKSURL, - OIDCConnectScopes: settings.OIDCConnectScopes, - OIDCConnectRedirectURL: settings.OIDCConnectRedirectURL, - OIDCConnectFrontendRedirectURL: settings.OIDCConnectFrontendRedirectURL, - OIDCConnectTokenAuthMethod: settings.OIDCConnectTokenAuthMethod, - OIDCConnectUsePKCE: settings.OIDCConnectUsePKCE, - OIDCConnectValidateIDToken: settings.OIDCConnectValidateIDToken, - OIDCConnectAllowedSigningAlgs: settings.OIDCConnectAllowedSigningAlgs, - OIDCConnectClockSkewSeconds: settings.OIDCConnectClockSkewSeconds, - OIDCConnectRequireEmailVerified: settings.OIDCConnectRequireEmailVerified, - OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath, - OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath, - OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath, - GitHubOAuthEnabled: settings.GitHubOAuthEnabled, - GitHubOAuthClientID: settings.GitHubOAuthClientID, - GitHubOAuthClientSecretConfigured: settings.GitHubOAuthClientSecretConfigured, - GitHubOAuthRedirectURL: settings.GitHubOAuthRedirectURL, - GitHubOAuthFrontendRedirectURL: settings.GitHubOAuthFrontendRedirectURL, - GoogleOAuthEnabled: settings.GoogleOAuthEnabled, - GoogleOAuthClientID: settings.GoogleOAuthClientID, - GoogleOAuthClientSecretConfigured: settings.GoogleOAuthClientSecretConfigured, - GoogleOAuthRedirectURL: settings.GoogleOAuthRedirectURL, - GoogleOAuthFrontendRedirectURL: settings.GoogleOAuthFrontendRedirectURL, - 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: dto.ParseCustomMenuItems(settings.CustomMenuItems), - CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), - DefaultConcurrency: settings.DefaultConcurrency, - DefaultBalance: settings.DefaultBalance, - RiskControlEnabled: settings.RiskControlEnabled, - CyberSessionBlockEnabled: settings.CyberSessionBlockEnabled, - CyberSessionBlockTTLSeconds: settings.CyberSessionBlockTTLSeconds, - AffiliateRebateRate: settings.AffiliateRebateRate, - AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours, - AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays, - AffiliateRebatePerInviteeCap: settings.AffiliateRebatePerInviteeCap, - DefaultUserRPMLimit: settings.DefaultUserRPMLimit, - DefaultSubscriptions: defaultSubscriptions, - EnableModelFallback: settings.EnableModelFallback, - FallbackModelAnthropic: settings.FallbackModelAnthropic, - FallbackModelOpenAI: settings.FallbackModelOpenAI, - FallbackModelGemini: settings.FallbackModelGemini, - FallbackModelAntigravity: settings.FallbackModelAntigravity, - EnableIdentityPatch: settings.EnableIdentityPatch, - IdentityPatchPrompt: settings.IdentityPatchPrompt, - OpsMonitoringEnabled: opsEnabled && settings.OpsMonitoringEnabled, - OpsRealtimeMonitoringEnabled: settings.OpsRealtimeMonitoringEnabled, - OpsQueryModeDefault: settings.OpsQueryModeDefault, - OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds, - MinClaudeCodeVersion: settings.MinClaudeCodeVersion, - MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion, - AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling, - BackendModeEnabled: settings.BackendModeEnabled, - EnableFingerprintUnification: settings.EnableFingerprintUnification, - EnableMetadataPassthrough: settings.EnableMetadataPassthrough, - EnableCCHSigning: settings.EnableCCHSigning, - EnableClaudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection, - ClaudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt, - ClaudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks, - EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, - RewriteMessageCacheControl: settings.RewriteMessageCacheControl, - EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization, - AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion, - OpenAICodexUserAgent: settings.OpenAICodexUserAgent, - MinCodexVersion: settings.MinCodexVersion, - MaxCodexVersion: settings.MaxCodexVersion, - CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist, - CodexCLIOnlyWhitelist: settings.CodexCLIOnlyWhitelist, - CodexCLIOnlyAllowAppServerClients: settings.CodexCLIOnlyAllowAppServerClients, - CodexCLIOnlyEngineFingerprintSignals: settings.CodexCLIOnlyEngineFingerprintSignals, - WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled, - PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource, - PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource, - PaymentVisibleMethodAlipayEnabled: settings.PaymentVisibleMethodAlipayEnabled, - PaymentVisibleMethodWxpayEnabled: settings.PaymentVisibleMethodWxpayEnabled, - OpenAIAdvancedSchedulerEnabled: settings.OpenAIAdvancedSchedulerEnabled, - BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, - BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, - SubscriptionExpiryNotifyEnabled: settings.SubscriptionExpiryNotifyEnabled, - AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, - AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(settings.AccountQuotaNotifyEmails), - PaymentEnabled: paymentCfg.Enabled, - PaymentMinAmount: paymentCfg.MinAmount, - PaymentMaxAmount: paymentCfg.MaxAmount, - PaymentDailyLimit: paymentCfg.DailyLimit, - PaymentOrderTimeoutMin: paymentCfg.OrderTimeoutMin, - PaymentMaxPendingOrders: paymentCfg.MaxPendingOrders, - PaymentEnabledTypes: paymentCfg.EnabledTypes, - PaymentBalanceDisabled: paymentCfg.BalanceDisabled, - PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier, - PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate, - PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy, - PaymentProductNamePrefix: paymentCfg.ProductNamePrefix, - PaymentProductNameSuffix: paymentCfg.ProductNameSuffix, - PaymentHelpImageURL: paymentCfg.HelpImageURL, - PaymentHelpText: paymentCfg.HelpText, - PaymentCancelRateLimitEnabled: paymentCfg.CancelRateLimitEnabled, - PaymentCancelRateLimitMax: paymentCfg.CancelRateLimitMax, - PaymentCancelRateLimitWindow: paymentCfg.CancelRateLimitWindow, - PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit, - PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode, - PaymentAlipayForceQRCode: paymentCfg.AlipayForceQRCode, + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, + PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, + FrontendURL: settings.FrontendURL, + InvitationCodeEnabled: settings.InvitationCodeEnabled, + TotpEnabled: settings.TotpEnabled, + TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + LoginAgreementEnabled: settings.LoginAgreementEnabled, + LoginAgreementMode: settings.LoginAgreementMode, + LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocumentsToDTO(settings.LoginAgreementDocuments), + SMTPHost: settings.SMTPHost, + SMTPPort: settings.SMTPPort, + SMTPUsername: settings.SMTPUsername, + SMTPPasswordConfigured: settings.SMTPPasswordConfigured, + SMTPFrom: settings.SMTPFrom, + SMTPFromName: settings.SMTPFromName, + SMTPUseTLS: settings.SMTPUseTLS, + TurnstileEnabled: settings.TurnstileEnabled, + TurnstileSiteKey: settings.TurnstileSiteKey, + TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured, + APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP, + LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled, + LinuxDoConnectClientID: settings.LinuxDoConnectClientID, + LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured, + LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL, + DingTalkConnectEnabled: settings.DingTalkConnectEnabled, + DingTalkConnectClientID: settings.DingTalkConnectClientID, + DingTalkConnectClientSecretConfigured: settings.DingTalkConnectClientSecretConfigured, + DingTalkConnectRedirectURL: settings.DingTalkConnectRedirectURL, + DingTalkConnectCorpRestrictionPolicy: settings.DingTalkConnectCorpRestrictionPolicy, + DingTalkConnectInternalCorpID: settings.DingTalkConnectInternalCorpID, + DingTalkConnectBypassRegistration: settings.DingTalkConnectBypassRegistration, + DingTalkConnectSyncCorpEmail: settings.DingTalkConnectSyncCorpEmail, + DingTalkConnectSyncDisplayName: settings.DingTalkConnectSyncDisplayName, + DingTalkConnectSyncDept: settings.DingTalkConnectSyncDept, + DingTalkConnectSyncCorpEmailAttrKey: settings.DingTalkConnectSyncCorpEmailAttrKey, + DingTalkConnectSyncDisplayNameAttrKey: settings.DingTalkConnectSyncDisplayNameAttrKey, + DingTalkConnectSyncDeptAttrKey: settings.DingTalkConnectSyncDeptAttrKey, + DingTalkConnectSyncCorpEmailAttrName: settings.DingTalkConnectSyncCorpEmailAttrName, + DingTalkConnectSyncDisplayNameAttrName: settings.DingTalkConnectSyncDisplayNameAttrName, + DingTalkConnectSyncDeptAttrName: settings.DingTalkConnectSyncDeptAttrName, + WeChatConnectEnabled: settings.WeChatConnectEnabled, + WeChatConnectAppID: settings.WeChatConnectAppID, + WeChatConnectAppSecretConfigured: settings.WeChatConnectAppSecretConfigured, + WeChatConnectOpenAppID: settings.WeChatConnectOpenAppID, + WeChatConnectOpenAppSecretConfigured: settings.WeChatConnectOpenAppSecretConfigured, + WeChatConnectMPAppID: settings.WeChatConnectMPAppID, + WeChatConnectMPAppSecretConfigured: settings.WeChatConnectMPAppSecretConfigured, + WeChatConnectMobileAppID: settings.WeChatConnectMobileAppID, + WeChatConnectMobileAppSecretConfigured: settings.WeChatConnectMobileAppSecretConfigured, + WeChatConnectOpenEnabled: settings.WeChatConnectOpenEnabled, + WeChatConnectMPEnabled: settings.WeChatConnectMPEnabled, + WeChatConnectMobileEnabled: settings.WeChatConnectMobileEnabled, + WeChatConnectMode: settings.WeChatConnectMode, + WeChatConnectScopes: settings.WeChatConnectScopes, + WeChatConnectRedirectURL: settings.WeChatConnectRedirectURL, + WeChatConnectFrontendRedirectURL: settings.WeChatConnectFrontendRedirectURL, + OIDCConnectEnabled: settings.OIDCConnectEnabled, + OIDCConnectProviderName: settings.OIDCConnectProviderName, + OIDCConnectClientID: settings.OIDCConnectClientID, + OIDCConnectClientSecretConfigured: settings.OIDCConnectClientSecretConfigured, + OIDCConnectIssuerURL: settings.OIDCConnectIssuerURL, + OIDCConnectDiscoveryURL: settings.OIDCConnectDiscoveryURL, + OIDCConnectAuthorizeURL: settings.OIDCConnectAuthorizeURL, + OIDCConnectTokenURL: settings.OIDCConnectTokenURL, + OIDCConnectUserInfoURL: settings.OIDCConnectUserInfoURL, + OIDCConnectJWKSURL: settings.OIDCConnectJWKSURL, + OIDCConnectScopes: settings.OIDCConnectScopes, + OIDCConnectRedirectURL: settings.OIDCConnectRedirectURL, + OIDCConnectFrontendRedirectURL: settings.OIDCConnectFrontendRedirectURL, + OIDCConnectTokenAuthMethod: settings.OIDCConnectTokenAuthMethod, + OIDCConnectUsePKCE: settings.OIDCConnectUsePKCE, + OIDCConnectValidateIDToken: settings.OIDCConnectValidateIDToken, + OIDCConnectAllowedSigningAlgs: settings.OIDCConnectAllowedSigningAlgs, + OIDCConnectClockSkewSeconds: settings.OIDCConnectClockSkewSeconds, + OIDCConnectRequireEmailVerified: settings.OIDCConnectRequireEmailVerified, + OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath, + OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath, + OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath, + GitHubOAuthEnabled: settings.GitHubOAuthEnabled, + GitHubOAuthClientID: settings.GitHubOAuthClientID, + GitHubOAuthClientSecretConfigured: settings.GitHubOAuthClientSecretConfigured, + GitHubOAuthRedirectURL: settings.GitHubOAuthRedirectURL, + GitHubOAuthFrontendRedirectURL: settings.GitHubOAuthFrontendRedirectURL, + GoogleOAuthEnabled: settings.GoogleOAuthEnabled, + GoogleOAuthClientID: settings.GoogleOAuthClientID, + GoogleOAuthClientSecretConfigured: settings.GoogleOAuthClientSecretConfigured, + GoogleOAuthRedirectURL: settings.GoogleOAuthRedirectURL, + GoogleOAuthFrontendRedirectURL: settings.GoogleOAuthFrontendRedirectURL, + 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: dto.ParseCustomMenuItems(settings.CustomMenuItems), + CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), + DefaultConcurrency: settings.DefaultConcurrency, + DefaultBalance: settings.DefaultBalance, + RiskControlEnabled: settings.RiskControlEnabled, + CyberSessionBlockEnabled: settings.CyberSessionBlockEnabled, + CyberSessionBlockTTLSeconds: settings.CyberSessionBlockTTLSeconds, + AffiliateRebateRate: settings.AffiliateRebateRate, + AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours, + AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays, + AffiliateRebatePerInviteeCap: settings.AffiliateRebatePerInviteeCap, + DefaultUserRPMLimit: settings.DefaultUserRPMLimit, + DefaultSubscriptions: defaultSubscriptions, + EnableModelFallback: settings.EnableModelFallback, + FallbackModelAnthropic: settings.FallbackModelAnthropic, + FallbackModelOpenAI: settings.FallbackModelOpenAI, + FallbackModelGemini: settings.FallbackModelGemini, + FallbackModelAntigravity: settings.FallbackModelAntigravity, + EnableIdentityPatch: settings.EnableIdentityPatch, + IdentityPatchPrompt: settings.IdentityPatchPrompt, + OpsMonitoringEnabled: opsEnabled && settings.OpsMonitoringEnabled, + OpsRealtimeMonitoringEnabled: settings.OpsRealtimeMonitoringEnabled, + OpsQueryModeDefault: settings.OpsQueryModeDefault, + OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds, + MinClaudeCodeVersion: settings.MinClaudeCodeVersion, + MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion, + AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling, + BackendModeEnabled: settings.BackendModeEnabled, + EnableFingerprintUnification: settings.EnableFingerprintUnification, + EnableMetadataPassthrough: settings.EnableMetadataPassthrough, + EnableCCHSigning: settings.EnableCCHSigning, + EnableClaudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection, + ClaudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt, + ClaudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks, + EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, + RewriteMessageCacheControl: settings.RewriteMessageCacheControl, + EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization, + AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion, + OpenAICodexUserAgent: settings.OpenAICodexUserAgent, + MinCodexVersion: settings.MinCodexVersion, + MaxCodexVersion: settings.MaxCodexVersion, + CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist, + CodexCLIOnlyWhitelist: settings.CodexCLIOnlyWhitelist, + CodexCLIOnlyAllowAppServerClients: settings.CodexCLIOnlyAllowAppServerClients, + CodexCLIOnlyEngineFingerprintSignals: settings.CodexCLIOnlyEngineFingerprintSignals, + WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled, + PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource, + PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource, + PaymentVisibleMethodAlipayEnabled: settings.PaymentVisibleMethodAlipayEnabled, + PaymentVisibleMethodWxpayEnabled: settings.PaymentVisibleMethodWxpayEnabled, + OpenAIAdvancedSchedulerEnabled: settings.OpenAIAdvancedSchedulerEnabled, + OpenAIAdvancedSchedulerStickyWeightedEnabled: settings.OpenAIAdvancedSchedulerStickyWeightedEnabled, + OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled, + OpenAIAdvancedSchedulerLBTopK: settings.OpenAIAdvancedSchedulerLBTopK, + OpenAIAdvancedSchedulerWeightPriority: settings.OpenAIAdvancedSchedulerWeightPriority, + OpenAIAdvancedSchedulerWeightLoad: settings.OpenAIAdvancedSchedulerWeightLoad, + OpenAIAdvancedSchedulerWeightQueue: settings.OpenAIAdvancedSchedulerWeightQueue, + OpenAIAdvancedSchedulerWeightErrorRate: settings.OpenAIAdvancedSchedulerWeightErrorRate, + OpenAIAdvancedSchedulerWeightTTFT: settings.OpenAIAdvancedSchedulerWeightTTFT, + OpenAIAdvancedSchedulerWeightReset: settings.OpenAIAdvancedSchedulerWeightReset, + OpenAIAdvancedSchedulerWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, + OpenAIAdvancedSchedulerWeightPreviousResponse: settings.OpenAIAdvancedSchedulerWeightPreviousResponse, + OpenAIAdvancedSchedulerWeightSessionSticky: settings.OpenAIAdvancedSchedulerWeightSessionSticky, + OpenAIAdvancedSchedulerEffectiveLBTopK: settings.OpenAIAdvancedSchedulerEffectiveLBTopK, + OpenAIAdvancedSchedulerEffectiveWeightPriority: settings.OpenAIAdvancedSchedulerEffectiveWeightPriority, + OpenAIAdvancedSchedulerEffectiveWeightLoad: settings.OpenAIAdvancedSchedulerEffectiveWeightLoad, + OpenAIAdvancedSchedulerEffectiveWeightQueue: settings.OpenAIAdvancedSchedulerEffectiveWeightQueue, + OpenAIAdvancedSchedulerEffectiveWeightErrorRate: settings.OpenAIAdvancedSchedulerEffectiveWeightErrorRate, + OpenAIAdvancedSchedulerEffectiveWeightTTFT: settings.OpenAIAdvancedSchedulerEffectiveWeightTTFT, + OpenAIAdvancedSchedulerEffectiveWeightReset: settings.OpenAIAdvancedSchedulerEffectiveWeightReset, + OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom, + OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: settings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse, + OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: settings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky, + BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, + BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, + SubscriptionExpiryNotifyEnabled: settings.SubscriptionExpiryNotifyEnabled, + AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, + AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(settings.AccountQuotaNotifyEmails), + PaymentEnabled: paymentCfg.Enabled, + PaymentMinAmount: paymentCfg.MinAmount, + PaymentMaxAmount: paymentCfg.MaxAmount, + PaymentDailyLimit: paymentCfg.DailyLimit, + PaymentOrderTimeoutMin: paymentCfg.OrderTimeoutMin, + PaymentMaxPendingOrders: paymentCfg.MaxPendingOrders, + PaymentEnabledTypes: paymentCfg.EnabledTypes, + PaymentBalanceDisabled: paymentCfg.BalanceDisabled, + PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier, + PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate, + PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy, + PaymentProductNamePrefix: paymentCfg.ProductNamePrefix, + PaymentProductNameSuffix: paymentCfg.ProductNameSuffix, + PaymentHelpImageURL: paymentCfg.HelpImageURL, + PaymentHelpText: paymentCfg.HelpText, + PaymentCancelRateLimitEnabled: paymentCfg.CancelRateLimitEnabled, + PaymentCancelRateLimitMax: paymentCfg.CancelRateLimitMax, + PaymentCancelRateLimitWindow: paymentCfg.CancelRateLimitWindow, + PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit, + PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode, + PaymentAlipayForceQRCode: paymentCfg.AlipayForceQRCode, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, @@ -618,7 +640,19 @@ type UpdateSettingsRequest struct { PaymentVisibleMethodWxpayEnabled *bool `json:"payment_visible_method_wxpay_enabled"` // OpenAI account scheduling - OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"` + 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"` @@ -1792,6 +1826,28 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } 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 @@ -2014,184 +2070,206 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } 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, - 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, - 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, + 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, + 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, @@ -2677,6 +2755,42 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, 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") @@ -3829,3 +3943,10 @@ func equalPlatformQuotaSettings(before, after map[string]*service.DefaultPlatfor } 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_auth_source_defaults_test.go b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go index f953f76760..1626007f19 100644 --- a/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go +++ b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go @@ -217,12 +217,13 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) body := map[string]any{ - "promo_code_enabled": true, - "payment_visible_method_alipay_source": "easypay", - "payment_visible_method_wxpay_source": "wxpay", - "payment_visible_method_alipay_enabled": true, - "payment_visible_method_wxpay_enabled": false, - "openai_advanced_scheduler_enabled": true, + "promo_code_enabled": true, + "payment_visible_method_alipay_source": "easypay", + "payment_visible_method_wxpay_source": "wxpay", + "payment_visible_method_alipay_enabled": true, + "payment_visible_method_wxpay_enabled": false, + "openai_advanced_scheduler_enabled": true, + "openai_advanced_scheduler_subscription_priority_enabled": true, } rawBody, err := json.Marshal(body) require.NoError(t, err) @@ -240,6 +241,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS require.Equal(t, "true", repo.values[service.SettingPaymentVisibleMethodAlipayEnabled]) require.Equal(t, "false", repo.values[service.SettingPaymentVisibleMethodWxpayEnabled]) require.Equal(t, "true", repo.values["openai_advanced_scheduler_enabled"]) + require.Equal(t, "true", repo.values[service.SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled]) var resp response.Response require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) @@ -250,6 +252,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS require.Equal(t, true, data["payment_visible_method_alipay_enabled"]) require.Equal(t, false, data["payment_visible_method_wxpay_enabled"]) require.Equal(t, true, data["openai_advanced_scheduler_enabled"]) + require.Equal(t, true, data["openai_advanced_scheduler_subscription_priority_enabled"]) } func TestSettingHandler_UpdateSettings_PreservesLegacyBlankPaymentVisibleMethodSource(t *testing.T) { diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 1d6f73bb99..9a20191a0c 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -208,7 +208,29 @@ type SystemSettings struct { PaymentVisibleMethodWxpayEnabled bool `json:"payment_visible_method_wxpay_enabled"` // OpenAI account scheduling - OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"` + 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"` + OpenAIAdvancedSchedulerEffectiveLBTopK string `json:"openai_advanced_scheduler_effective_lb_top_k"` + OpenAIAdvancedSchedulerEffectiveWeightPriority string `json:"openai_advanced_scheduler_effective_weight_priority"` + OpenAIAdvancedSchedulerEffectiveWeightLoad string `json:"openai_advanced_scheduler_effective_weight_load"` + OpenAIAdvancedSchedulerEffectiveWeightQueue string `json:"openai_advanced_scheduler_effective_weight_queue"` + OpenAIAdvancedSchedulerEffectiveWeightErrorRate string `json:"openai_advanced_scheduler_effective_weight_error_rate"` + OpenAIAdvancedSchedulerEffectiveWeightTTFT string `json:"openai_advanced_scheduler_effective_weight_ttft"` + OpenAIAdvancedSchedulerEffectiveWeightReset string `json:"openai_advanced_scheduler_effective_weight_reset"` + OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom string `json:"openai_advanced_scheduler_effective_weight_quota_headroom"` + OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse string `json:"openai_advanced_scheduler_effective_weight_previous_response"` + OpenAIAdvancedSchedulerEffectiveWeightSessionSticky string `json:"openai_advanced_scheduler_effective_weight_session_sticky"` // Payment configuration PaymentEnabled bool `json:"payment_enabled"` diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index b65dedf9cc..b20d9ef652 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1006,7 +1006,8 @@ func (h *GatewayHandler) Models(c *gin.Context) { // Get available models from account configurations for the selected group platform. availableModels := h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, platform) if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() { - availableModels = filterModelsByCustomList(availableModels, defaultModelIDsForPlatform(platform), apiKey.Group.ModelsListConfig.Models) + fallbackModels := defaultModelIDsForPlatform(platform) + availableModels = filterModelsByCustomList(customModelsListSource(platform, availableModels, fallbackModels), fallbackModels, apiKey.Group.ModelsListConfig.Models) writeCustomModelsList(c, platform, availableModels) return } @@ -1090,6 +1091,13 @@ func writeOpenAIModelsList(c *gin.Context, modelIDs []string) { }) } +func customModelsListSource(platform string, availableModels, fallbackModels []string) []string { + if platform == service.PlatformAnthropic && len(availableModels) > 0 { + return mergeModelIDs(availableModels, fallbackModels) + } + return availableModels +} + func filterModelsByCustomList(availableModels, fallbackModels, selectedModels []string) []string { if len(selectedModels) == 0 { return availableModels @@ -1158,6 +1166,15 @@ func defaultModelIDsForPlatform(platform string) []string { ids = append(ids, model.ID) } return ids + case service.PlatformAnthropic: + ids := make([]string, 0, len(claude.DefaultModels)+len(antigravity.DefaultModels())) + for _, model := range claude.DefaultModels { + ids = append(ids, model.ID) + } + for _, model := range antigravity.DefaultModels() { + ids = append(ids, model.ID) + } + return mergeModelIDs(ids, nil) case service.PlatformGrok: return xai.DefaultModelIDs() default: @@ -1169,6 +1186,25 @@ func defaultModelIDsForPlatform(platform string) []string { } } +func mergeModelIDs(primary, secondary []string) []string { + seen := make(map[string]struct{}, len(primary)+len(secondary)) + merged := make([]string, 0, len(primary)+len(secondary)) + for _, models := range [][]string{primary, secondary} { + for _, model := range models { + model = strings.TrimSpace(model) + if model == "" { + continue + } + if _, ok := seen[model]; ok { + continue + } + seen[model] = struct{}{} + merged = append(merged, model) + } + } + return merged +} + // AntigravityModels 返回 Antigravity 支持的全部模型 // GET /antigravity/models func (h *GatewayHandler) AntigravityModels(c *gin.Context) { diff --git a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go index 09b20722c9..75e3c1aa8c 100644 --- a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go +++ b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go @@ -137,6 +137,7 @@ func (f *fakeConcurrencyCache) GetAccountConcurrencyBatch(_ context.Context, acc return result, nil } func (f *fakeConcurrencyCache) CleanupExpiredAccountSlots(context.Context, int64) error { return nil } +func (f *fakeConcurrencyCache) CleanupExpiredAccountSlotKeys(context.Context) error { return nil } func (f *fakeConcurrencyCache) CleanupStaleProcessSlots(context.Context, string) error { return nil } func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*service.Account) (*GatewayHandler, func()) { diff --git a/backend/internal/handler/gateway_helper_fastpath_test.go b/backend/internal/handler/gateway_helper_fastpath_test.go index c7c0fb6c9e..fecb9b071d 100644 --- a/backend/internal/handler/gateway_helper_fastpath_test.go +++ b/backend/internal/handler/gateway_helper_fastpath_test.go @@ -89,6 +89,10 @@ func (m *concurrencyCacheMock) CleanupExpiredAccountSlots(ctx context.Context, a return nil } +func (m *concurrencyCacheMock) CleanupExpiredAccountSlotKeys(ctx context.Context) error { + return nil +} + func (m *concurrencyCacheMock) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error { return nil } diff --git a/backend/internal/handler/gateway_helper_hotpath_test.go b/backend/internal/handler/gateway_helper_hotpath_test.go index 65dc849683..fb17481f1c 100644 --- a/backend/internal/handler/gateway_helper_hotpath_test.go +++ b/backend/internal/handler/gateway_helper_hotpath_test.go @@ -140,6 +140,10 @@ func (s *helperConcurrencyCacheStub) CleanupExpiredAccountSlots(ctx context.Cont return nil } +func (s *helperConcurrencyCacheStub) CleanupExpiredAccountSlotKeys(ctx context.Context) error { + return nil +} + func (s *helperConcurrencyCacheStub) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error { return nil } diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index c5238f2a2f..6011e13027 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -269,6 +269,149 @@ func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMappin require.Equal(t, []string{"claude-sonnet-4-6"}, modelIDsForTest(got.Data)) } +func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeAndMappedDeepSeek(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(28) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeOAuth, + }, + { + ID: 2, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "deepseek-v4-pro": "deepseek-v4-pro", + }, + }, + }, + }, + }, + }, + ) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ + ID: groupID, + Platform: service.PlatformAnthropic, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"}, + }, + }, + }) + + h.Models(c) + + require.Equal(t, http.StatusOK, rec.Code) + + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"}, modelIDsForTest(got.Data)) +} + +func TestGatewayModels_AnthropicCustomModelsListDisabledKeepsMappedModelList(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(29) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeOAuth, + }, + { + ID: 2, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "deepseek-v4-pro": "deepseek-v4-pro", + }, + }, + }, + }, + }, + }, + ) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ + ID: groupID, + Platform: service.PlatformAnthropic, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: false, + Models: []string{"claude-fable-5", "deepseek-v4-pro"}, + }, + }, + }) + + h.Models(c) + + require.Equal(t, http.StatusOK, rec.Code) + + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, []string{"deepseek-v4-pro"}, modelIDsForTest(got.Data)) +} + +func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeWithoutMappings(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(30) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformAnthropic, + Type: service.AccountTypeOAuth, + }, + }, + }, + }, + ) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ + ID: groupID, + Platform: service.PlatformAnthropic, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"}, + }, + }, + }) + + h.Models(c) + + require.Equal(t, http.StatusOK, rec.Code) + + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"}, modelIDsForTest(got.Data)) +} + func TestGatewayModels_CustomModelsListCanReturnEmptyWhenSelectionsUnavailable(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 8eec667fa6..ccafde7b02 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1266,6 +1266,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "previous_response_id must be a response.id (resp_*), not a message id") return } + firstMessageToolContext := service.ValidateFunctionCallOutputContextBytes(firstMessage) + previousResponseCanMove := !firstMessageToolContext.HasFunctionCallOutput || firstMessageToolContext.HasToolCallContext reqLog = reqLog.With( zap.Bool("ws_ingress", true), zap.String("model", reqModel), @@ -1382,6 +1384,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { service.OpenAIEndpointCapabilityChatCompletions, false, requestPlatform, + func() string { + if previousResponseCanMove { + return "previous_response_can_move" + } + return "" + }(), ) if err != nil { reqLog.Warn("openai.websocket_account_select_failed", @@ -1581,8 +1589,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // 说明该会话链不属于本次调度到的账号,原样转发会触发上游会话链鉴权失败(“鉴权失败,请检查 API Key”)。 // 故剥离首包里的 previous_response_id,改用首包内 input 重建上下文;带 function_call_output 的 // 工具续链无法重建,保持原样。仅作用于首轮首包,后续 turn 的续链由 WS 转发层既有逻辑处理。 - if previousResponseID != "" && !scheduleDecision.StickyPreviousHit && - !service.ValidateFunctionCallOutputContextBytes(wsFirstMessage).HasFunctionCallOutput { + if previousResponseID != "" && !scheduleDecision.StickyPreviousHit && previousResponseCanMove { wsFirstMessage = service.RemovePreviousResponseIDFromBody(wsFirstMessage) reqLog.Debug("openai.websocket_previous_response_id_stripped_cross_group", zap.Int64("account_id", account.ID), diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index 9d0f1d8fac..be6dc917bb 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -322,6 +322,9 @@ func (h *UsageHandler) ListErrors(c *gin.Context) { filter.ErrorTypesAny = types } + // 排序对齐用量明细:列白名单与方向归一在 repo 层,非法值回退 created_at DESC。 + filter.SetSort(c.Query("sort_by"), c.Query("sort_order")) + result, err := h.opsService.ListUserErrorRequests(c.Request.Context(), subject.UserID, filter) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 19a1ff0485..a8015ddba0 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -482,7 +482,7 @@ func (r *accountRepository) List(ctx context.Context, params pagination.Paginati return r.ListWithFilters(ctx, params, "", "", "", "", 0, "") } -func (r *accountRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, *pagination.PaginationResult, error) { +func (r *accountRepository) accountListFilteredQuery(platform, accountType, status, search string, groupID int64, privacyMode string) *dbent.AccountQuery { q := r.client.Account.Query() if platform != "" { @@ -575,6 +575,11 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati })) } + return q +} + +func (r *accountRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, *pagination.PaginationResult, error) { + q := r.accountListFilteredQuery(platform, accountType, status, search, groupID, privacyMode) // Clone before Count so interceptor-appended predicates (SoftDeleteMixin's // deleted_at IS NULL) don't accumulate on the shared builder and pollute the // subsequent list query. Same pattern used in group_repo/promo_code_repo/user_repo @@ -603,6 +608,14 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati return outAccounts, paginationResultFromTotal(int64(total), params), nil } +func (r *accountRepository) ListAllWithFilters(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, error) { + accounts, err := r.accountListFilteredQuery(platform, accountType, status, search, groupID, privacyMode).All(ctx) + if err != nil { + return nil, err + } + return r.accountsToService(ctx, accounts) +} + func (r *accountRepository) ListOpsAccountsForStats(ctx context.Context, platformFilter string, groupIDFilter *int64) ([]service.Account, error) { if r == nil || r.client == nil { return []service.Account{}, nil @@ -1061,6 +1074,90 @@ func (r *accountRepository) ListSchedulableByGroupID(ctx context.Context, groupI }) } +func (r *accountRepository) ListSchedulableCapacityByGroupIDs(ctx context.Context, groupIDs []int64) ([]service.GroupAccountCapacityRow, error) { + groupIDs = uniquePositiveInt64s(groupIDs) + if len(groupIDs) == 0 { + return []service.GroupAccountCapacityRow{}, nil + } + if r.sql == nil { + rows := make([]service.GroupAccountCapacityRow, 0) + for _, groupID := range groupIDs { + accounts, err := r.ListSchedulableByGroupID(ctx, groupID) + if err != nil { + return nil, err + } + for i := range accounts { + acc := &accounts[i] + rows = append(rows, service.GroupAccountCapacityRow{ + GroupID: groupID, + AccountID: acc.ID, + Concurrency: acc.Concurrency, + Extra: copyJSONMap(acc.Extra), + SessionWindowStart: acc.SessionWindowStart, + SessionWindowEnd: acc.SessionWindowEnd, + SessionWindowStatus: acc.SessionWindowStatus, + }) + } + } + return rows, nil + } + + rows, err := r.sql.QueryContext(ctx, ` + SELECT + ag.group_id, + a.id AS account_id, + a.concurrency, + COALESCE(a.extra, '{}'::jsonb)::text AS extra, + a.session_window_start, + a.session_window_end, + COALESCE(a.session_window_status, '') AS session_window_status + FROM account_groups ag + JOIN accounts a ON a.id = ag.account_id + WHERE ag.group_id = ANY($1) + AND a.deleted_at IS NULL + AND a.status = $2 + AND a.schedulable = TRUE + AND (a.temp_unschedulable_until IS NULL OR a.temp_unschedulable_until <= $3) + AND (a.expires_at IS NULL OR a.expires_at > $3 OR a.auto_pause_on_expired = FALSE) + AND (a.overload_until IS NULL OR a.overload_until <= $3) + AND (a.rate_limit_reset_at IS NULL OR a.rate_limit_reset_at <= $3) + ORDER BY ag.group_id ASC, ag.priority ASC, a.priority ASC, a.id ASC + `, pq.Array(groupIDs), service.StatusActive, time.Now()) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + out := make([]service.GroupAccountCapacityRow, 0) + for rows.Next() { + var row service.GroupAccountCapacityRow + var extraRaw string + if err := rows.Scan( + &row.GroupID, + &row.AccountID, + &row.Concurrency, + &extraRaw, + &row.SessionWindowStart, + &row.SessionWindowEnd, + &row.SessionWindowStatus, + ); err != nil { + return nil, err + } + if extraRaw != "" && extraRaw != "null" { + var extra map[string]any + if err := json.Unmarshal([]byte(extraRaw), &extra); err != nil { + return nil, err + } + row.Extra = extra + } + out = append(out, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + func (r *accountRepository) ListSchedulableByPlatform(ctx context.Context, platform string) ([]service.Account, error) { now := time.Now() accounts, err := r.client.Account.Query(). diff --git a/backend/internal/repository/concurrency_cache.go b/backend/internal/repository/concurrency_cache.go index 5e6f10062f..b530c30b90 100644 --- a/backend/internal/repository/concurrency_cache.go +++ b/backend/internal/repository/concurrency_cache.go @@ -174,6 +174,29 @@ var ( return 1 `) + // cleanupExpiredSlotKeysScript 批量清理实际存在的账号槽位键,避免后台任务从数据库加载全量账号。 + // KEYS = 有序集合键列表,ARGV[1] = TTL(秒)。 + cleanupExpiredSlotKeysScript = redis.NewScript(` + -- Redis 3.2-4.x compat: opt into effects replication so redis.call('TIME') + -- replicates correctly. No-op on Redis 5.0+ (effects replication is default). + redis.replicate_commands() + local ttl = tonumber(ARGV[1]) + local timeResult = redis.call('TIME') + local now = tonumber(timeResult[1]) + local expireBefore = now - ttl + local removed = 0 + for i = 1, #KEYS do + local key = KEYS[i] + removed = removed + redis.call('ZREMRANGEBYSCORE', key, '-inf', expireBefore) + if redis.call('ZCARD', key) == 0 then + redis.call('DEL', key) + else + redis.call('EXPIRE', key, ttl) + end + end + return removed + `) + // startupCleanupScript 清理非当前进程前缀的槽位成员。 // KEYS 是有序集合键列表,ARGV[1] 是当前进程前缀,ARGV[2] 是槽位 TTL。 // 遍历每个 KEYS[i],移除前缀不匹配的成员,清空后删 key,否则刷新 EXPIRE。 @@ -503,6 +526,10 @@ func (c *concurrencyCache) CleanupExpiredAccountSlots(ctx context.Context, accou return err } +func (c *concurrencyCache) CleanupExpiredAccountSlotKeys(ctx context.Context) error { + return c.cleanupExpiredSlotKeysByPattern(ctx, accountSlotKeyPrefix+"*") +} + func (c *concurrencyCache) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error { if activeRequestPrefix == "" { return nil @@ -527,6 +554,29 @@ func (c *concurrencyCache) CleanupStaleProcessSlots(ctx context.Context, activeR return nil } +// cleanupExpiredSlotKeysByPattern 扫描实际存在的账号槽位键并批量清理过期成员。 +func (c *concurrencyCache) cleanupExpiredSlotKeysByPattern(ctx context.Context, pattern string) error { + const scanCount = 200 + var cursor uint64 + for { + keys, nextCursor, err := c.rdb.Scan(ctx, cursor, pattern, scanCount).Result() + if err != nil { + return fmt.Errorf("scan %s: %w", pattern, err) + } + if len(keys) > 0 { + _, err := cleanupExpiredSlotKeysScript.Run(ctx, c.rdb, keys, c.slotTTLSeconds).Result() + if err != nil { + return fmt.Errorf("cleanup expired slots %s: %w", pattern, err) + } + } + cursor = nextCursor + if cursor == 0 { + break + } + } + return nil +} + // cleanupSlotsByPattern 扫描匹配 pattern 的有序集合键,批量调用 Lua 脚本清理非当前进程成员。 func (c *concurrencyCache) cleanupSlotsByPattern(ctx context.Context, pattern, activePrefix string) error { const scanCount = 200 diff --git a/backend/internal/repository/concurrency_cache_integration_test.go b/backend/internal/repository/concurrency_cache_integration_test.go index 5da94fc258..4aa172b252 100644 --- a/backend/internal/repository/concurrency_cache_integration_test.go +++ b/backend/internal/repository/concurrency_cache_integration_test.go @@ -435,6 +435,39 @@ func (s *ConcurrencyCacheSuite) TestCleanupExpiredAccountSlots_NoExpired() { require.Equal(s.T(), 2, cur) } +func (s *ConcurrencyCacheSuite) TestCleanupExpiredAccountSlotKeys() { + now := time.Now().Unix() + expiredTime := now - int64(testSlotTTL.Seconds()) - 10 + accountKeyWithFresh := fmt.Sprintf("%s%d", accountSlotKeyPrefix, 301) + accountKeyExpiredOnly := fmt.Sprintf("%s%d", accountSlotKeyPrefix, 302) + userKey := fmt.Sprintf("%s%d", userSlotKeyPrefix, 303) + + require.NoError(s.T(), s.rdb.ZAdd(s.ctx, accountKeyWithFresh, + redis.Z{Score: float64(expiredTime), Member: "expired"}, + redis.Z{Score: float64(now), Member: "fresh"}, + ).Err()) + require.NoError(s.T(), s.rdb.ZAdd(s.ctx, accountKeyExpiredOnly, + redis.Z{Score: float64(expiredTime), Member: "expired-only"}, + ).Err()) + require.NoError(s.T(), s.rdb.ZAdd(s.ctx, userKey, + redis.Z{Score: float64(expiredTime), Member: "user-expired"}, + ).Err()) + + require.NoError(s.T(), s.cache.CleanupExpiredAccountSlotKeys(s.ctx)) + + accountMembers, err := s.rdb.ZRange(s.ctx, accountKeyWithFresh, 0, -1).Result() + require.NoError(s.T(), err) + require.Equal(s.T(), []string{"fresh"}, accountMembers) + + exists, err := s.rdb.Exists(s.ctx, accountKeyExpiredOnly).Result() + require.NoError(s.T(), err) + require.EqualValues(s.T(), 0, exists) + + userMembers, err := s.rdb.ZRange(s.ctx, userKey, 0, -1).Result() + require.NoError(s.T(), err) + require.Equal(s.T(), []string{"user-expired"}, userMembers) +} + func (s *ConcurrencyCacheSuite) TestCleanupStaleProcessSlots_RemovesOldPrefixesAndWaitCounters() { accountID := int64(901) userID := int64(902) diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index cb4437cf56..0aab6b0c05 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -472,6 +472,49 @@ func (r *groupRepository) ListActive(ctx context.Context) ([]service.Group, erro return outGroups, nil } +func (r *groupRepository) ListActiveIDs(ctx context.Context) ([]int64, error) { + if r.sql != nil { + rows, err := r.sql.QueryContext(ctx, ` + SELECT id + FROM groups + WHERE status = $1 + AND deleted_at IS NULL + ORDER BY sort_order ASC, id ASC + `, service.StatusActive) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + ids := make([]int64, 0) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return ids, nil + } + + groups, err := r.client.Group.Query(). + Where(group.StatusEQ(service.StatusActive)). + Select(group.FieldID). + Order(dbent.Asc(group.FieldSortOrder), dbent.Asc(group.FieldID)). + All(ctx) + if err != nil { + return nil, err + } + ids := make([]int64, 0, len(groups)) + for i := range groups { + ids = append(ids, groups[i].ID) + } + return ids, nil +} + func (r *groupRepository) ListActiveByPlatform(ctx context.Context, platform string) ([]service.Group, error) { groups, err := r.client.Group.Query(). Where(group.StatusEQ(service.StatusActive), group.PlatformEQ(platform)). diff --git a/backend/internal/repository/ops_error_where_test.go b/backend/internal/repository/ops_error_where_test.go index 5b9d7ab1c3..c997865ba4 100644 --- a/backend/internal/repository/ops_error_where_test.go +++ b/backend/internal/repository/ops_error_where_test.go @@ -85,10 +85,21 @@ func TestBuildOpsErrorLogsWhere_CyberPolicyStatusExemption(t *testing.T) { t.Fatalf("default filter must still include the status >= 400 guard for non-cyber rows\nfull: %s", where) } - // phase=upstream skips the status guard entirely — exemption is irrelevant there. + // phase=upstream WITHOUT the recovered-upstream opt-in keeps the status guard: + // request-error list endpoints filter by phase=upstream as a plain condition. whereUpstream, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream"}) - if strings.Contains(whereUpstream, "status_code") { - t.Fatalf("upstream phase filter must not add any status_code clause\nfull: %s", whereUpstream) + if !strings.Contains(whereUpstream, "COALESCE(e.status_code, 0) >= 400") { + t.Fatalf("upstream phase without IncludeRecoveredUpstream must keep the status guard\nfull: %s", whereUpstream) + } + if !strings.Contains(whereUpstream, "e.error_phase = $") { + t.Fatalf("upstream phase filter must emit the error_phase condition\nfull: %s", whereUpstream) + } + + // phase=upstream WITH IncludeRecoveredUpstream (ops 上游列表) skips the guard, + // exposing recovered (<400) upstream rows. + whereRecovered, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream", IncludeRecoveredUpstream: true}) + if strings.Contains(whereRecovered, "status_code") { + t.Fatalf("upstream phase with IncludeRecoveredUpstream must not add any status_code clause\nfull: %s", whereRecovered) } } diff --git a/backend/internal/repository/ops_repo.go b/backend/internal/repository/ops_repo.go index 9923c08d99..2129a451c4 100644 --- a/backend/internal/repository/ops_repo.go +++ b/backend/internal/repository/ops_repo.go @@ -177,6 +177,37 @@ func opsInsertErrorLogArgs(input *service.OpsInsertErrorLogInput) []any { } } +// opsErrorLogsOrderBy builds the ORDER BY clause from a whitelist, mirroring +// usageLogOrderBy semantics. Unknown SortBy falls back to created_at; e.id is +// always appended as tiebreaker for stable pagination. +func opsErrorLogsOrderBy(filter *service.OpsErrorLogFilter) string { + sortBy := "" + sortOrder := "" + if filter != nil { + sortBy = strings.ToLower(strings.TrimSpace(filter.SortBy)) + sortOrder = strings.ToLower(strings.TrimSpace(filter.SortOrder)) + } + + var column string + switch sortBy { + case "model": + column = "COALESCE(NULLIF(TRIM(e.requested_model), ''), e.model)" + case "status_code": + // 与展示列/过滤保持同义:列表展示 COALESCE(upstream_status_code, status_code, 0), + // status_code 过滤也用同一表达式,故排序必须一致——否则 recovered upstream 行 + //(status_code<400 但展示上游 5xx)排序键与显示值/分页切分不符。 + column = "COALESCE(e.upstream_status_code, e.status_code, 0)" + default: + column = "e.created_at" + } + + dir := "DESC" + if sortOrder == "asc" { + dir = "ASC" + } + return fmt.Sprintf("%s %s, e.id %s", column, dir, dir) +} + func (r *opsRepository) ListErrorLogs(ctx context.Context, filter *service.OpsErrorLogFilter) (*service.OpsErrorLogList, error) { if r == nil || r.db == nil { return nil, fmt.Errorf("nil ops repository") @@ -233,25 +264,29 @@ SELECT COALESCE(a.name, ''), e.group_id, COALESCE(g.name, ''), - CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END, + CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END, COALESCE(e.request_path, ''), e.stream, COALESCE(e.inbound_endpoint, ''), COALESCE(e.upstream_endpoint, ''), COALESCE(e.requested_model, ''), COALESCE(e.upstream_model, ''), + COALESCE(e.user_agent, ''), e.request_type, COALESCE(ak.name, ''), ak.deleted_at, - COALESCE(e.deleted_key_name, '') + COALESCE(e.deleted_key_name, ''), + e.deleted_key_owner_user_id, + COALESCE(du.email, '') FROM ops_error_logs e LEFT JOIN accounts a ON e.account_id = a.id LEFT JOIN groups g ON e.group_id = g.id LEFT JOIN users u ON e.user_id = u.id LEFT JOIN users u2 ON e.resolved_by_user_id = u2.id +LEFT JOIN users du ON e.deleted_key_owner_user_id = du.id LEFT JOIN api_keys ak ON ak.id = e.api_key_id ` + where + ` -ORDER BY e.created_at DESC +ORDER BY ` + opsErrorLogsOrderBy(filter) + ` LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) rows, err := r.db.QueryContext(ctx, selectSQL, argsWithLimit...) @@ -279,6 +314,8 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) var apiKeyName string var apiKeyDeletedAt sql.NullTime var deletedKeyName string + var deletedKeyOwnerID sql.NullInt64 + var deletedKeyOwnerEmail string if err := rows.Scan( &item.ID, &item.CreatedAt, @@ -311,10 +348,13 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) &item.UpstreamEndpoint, &item.RequestedModel, &item.UpstreamModel, + &item.UserAgent, &requestType, &apiKeyName, &apiKeyDeletedAt, &deletedKeyName, + &deletedKeyOwnerID, + &deletedKeyOwnerEmail, ); err != nil { return nil, err } @@ -364,6 +404,12 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) } // 已删除:ak.deleted_at 非空(软删),或仅命中 deleted_key_name 兜底。 item.APIKeyDeleted = apiKeyDeletedAt.Valid || (apiKeyName == "" && deletedKeyName != "") + // 已删除 KEY 所有者快照:认证失败行 user_id 为空,列表用户列以此回退。 + if deletedKeyOwnerID.Valid { + v := deletedKeyOwnerID.Int64 + item.DeletedKeyOwnerUserID = &v + item.DeletedKeyOwnerEmail = deletedKeyOwnerEmail + } out = append(out, &item) } if err := rows.Err(); err != nil { @@ -417,7 +463,7 @@ SELECT COALESCE(a.name, ''), e.group_id, COALESCE(g.name, ''), - CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END, + CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END, COALESCE(e.request_path, ''), e.stream, COALESCE(e.inbound_endpoint, ''), @@ -927,12 +973,14 @@ func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) { if filter != nil { resolvedFilter = filter.Resolved } - // Keep list endpoints scoped to client errors unless explicitly filtering upstream phase. + // Keep list endpoints scoped to client errors unless the caller explicitly opts + // into recovered upstream rows (Phase=="upstream" + IncludeRecoveredUpstream, + // ops 专用上游列表)。请求错误语义的端点即便过滤 phase=upstream 也保留该守卫。 // cyber_policy is exempt from the status >= 400 guard: streaming cyber hits arrive with // status 200 (the SSE stream opened successfully before upstream returned response.failed), // but they are always client-visible blocked requests that belong in admin + user error // lists. Without the exemption the entire streaming-path cyber sink would be invisible. - if phaseFilter != "upstream" { + if phaseFilter != "upstream" || filter == nil || !filter.IncludeRecoveredUpstream { clauses = append(clauses, "(COALESCE(e.status_code, 0) >= 400 OR e.error_type = 'cyber_policy')") } diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index c508f09c71..c8e1fe14e0 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -518,7 +518,7 @@ func filterSchedulerCredentials(credentials map[string]any) map[string]any { if len(credentials) == 0 { return nil } - keys := []string{"model_mapping", "compact_model_mapping", "api_key", "project_id", "oauth_type"} + keys := []string{"model_mapping", "compact_model_mapping", "api_key", "project_id", "oauth_type", "plan_type"} filtered := make(map[string]any) for _, key := range keys { if value, ok := credentials[key]; ok && value != nil { diff --git a/backend/internal/repository/scheduler_cache_test.go b/backend/internal/repository/scheduler_cache_test.go new file mode 100644 index 0000000000..f438b2f456 --- /dev/null +++ b/backend/internal/repository/scheduler_cache_test.go @@ -0,0 +1,37 @@ +package repository + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestFilterSchedulerCredentialsKeepsSubscriptionPlanType(t *testing.T) { + filtered := filterSchedulerCredentials(map[string]any{ + "plan_type": "plus", + "access_token": "secret-access-token", + "refresh_token": "secret-refresh-token", + }) + + require.Equal(t, "plus", filtered["plan_type"]) + require.NotContains(t, filtered, "access_token") + require.NotContains(t, filtered, "refresh_token") +} + +func TestSchedulerMetadataAccountKeepsOpenAISubscriptionIdentity(t *testing.T) { + account := service.Account{ + ID: 24, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "plan_type": "plus", + "access_token": "secret-access-token", + }, + } + + metadata := buildSchedulerMetadataAccount(account) + + require.True(t, metadata.IsOpenAIChatGPTSubscription()) + require.Empty(t, metadata.GetCredential("access_token")) +} diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 885e63f9fd..24c648b0a5 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -372,12 +372,13 @@ func (r *usageLogRepository) CreateBestEffort(ctx context.Context, log *service. } } + // 队列满时阻塞等待而非立即丢弃:批处理器持续排空队列,短暂等待即可入队。 + // 立即丢弃会造成“已扣费但无 usage_log”的永久数据缺口(issue #3656); + // 阻塞上限由调用方 ctx 期限约束,超时后由上层同步兜底。 select { case r.bestEffortBatchCh <- req: case <-ctx.Done(): return service.MarkUsageLogCreateDropped(ctx.Err()) - default: - return service.MarkUsageLogCreateDropped(errors.New("usage log best-effort queue full")) } select { @@ -493,12 +494,12 @@ func (r *usageLogRepository) createBatched(ctx context.Context, log *service.Usa resultCh: make(chan usageLogCreateResult, 1), } + // 队列满时阻塞等待而非立即报错:本路径是 best-effort 丢弃后的最后兜底, + // 立即失败会让日志永久丢失;阻塞上限由调用方 ctx 期限约束。 select { case r.createBatchCh <- req: case <-ctx.Done(): return false, service.MarkUsageLogCreateNotPersisted(ctx.Err()) - default: - return false, service.MarkUsageLogCreateNotPersisted(errors.New("usage log create batch queue full")) } select { @@ -520,22 +521,28 @@ func (r *usageLogRepository) createBatched(ctx context.Context, log *service.Usa } func (r *usageLogRepository) ensureCreateBatcher() { - if r == nil || r.db == nil || r.createBatchCh != nil { + if r == nil || r.db == nil { return } + // nil 检查必须在 Once 内部:在外层做无同步快路径读会与 Once 内的写构成数据竞争。 r.createBatchOnce.Do(func() { - r.createBatchCh = make(chan usageLogCreateRequest, usageLogCreateBatchQueueCap) - go r.runCreateBatcher(r.db) + 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 || r.bestEffortBatchCh != nil { + if r == nil || r.db == nil { return } + // 同 ensureCreateBatcher:nil 检查放在 Once 内部以避免数据竞争。 r.bestEffortBatchOnce.Do(func() { - r.bestEffortBatchCh = make(chan usageLogBestEffortRequest, usageLogBestEffortBatchQueueCap) - go r.runBestEffortBatcher(r.db) + if r.bestEffortBatchCh == nil { + r.bestEffortBatchCh = make(chan usageLogBestEffortRequest, usageLogBestEffortBatchQueueCap) + go r.runBestEffortBatcher(r.db) + } }) } diff --git a/backend/internal/repository/usage_log_repo_integration_test.go b/backend/internal/repository/usage_log_repo_integration_test.go index ed3050d89c..b43c6d56ef 100644 --- a/backend/internal/repository/usage_log_repo_integration_test.go +++ b/backend/internal/repository/usage_log_repo_integration_test.go @@ -288,21 +288,21 @@ func TestUsageLogRepositoryCreateBestEffort_BatchPathDuplicateRequestID(t *testi }, 3*time.Second, 20*time.Millisecond) } -func TestUsageLogRepositoryCreateBestEffort_QueueFullReturnsDropped(t *testing.T) { - ctx := context.Background() +func TestUsageLogRepositoryCreateBestEffort_QueueFullBlocksUntilCtxDeadline(t *testing.T) { + // 队列满时不再立即丢弃:阻塞等待入队,直到调用方 ctx 到期才标记 dropped(issue #3656)。 client := testEntClient(t) repo := newUsageLogRepositoryWithSQL(client, integrationDB) repo.bestEffortBatchCh = make(chan usageLogBestEffortRequest, 1) repo.bestEffortBatchCh <- usageLogBestEffortRequest{} - user := mustCreateUser(t, client, &service.User{Email: fmt.Sprintf("usage-best-effort-full-%d@example.com", time.Now().UnixNano())}) - apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: "sk-usage-best-effort-full-" + uuid.NewString(), Name: "k"}) - account := mustCreateAccount(t, client, &service.Account{Name: "acc-usage-best-effort-full-" + uuid.NewString()}) + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + start := time.Now() err := repo.CreateBestEffort(ctx, &service.UsageLog{ - UserID: user.ID, - APIKeyID: apiKey.ID, - AccountID: account.ID, + UserID: 1, + APIKeyID: 2, + AccountID: 3, RequestID: uuid.NewString(), Model: "claude-3", InputTokens: 10, @@ -314,6 +314,40 @@ func TestUsageLogRepositoryCreateBestEffort_QueueFullReturnsDropped(t *testing.T require.Error(t, err) require.True(t, service.IsUsageLogCreateDropped(err)) + require.GreaterOrEqual(t, time.Since(start), 150*time.Millisecond) +} + +func TestUsageLogRepositoryCreateBestEffort_QueueFullWaitsForDrain(t *testing.T) { + // 队列满但批处理器随后排空时,阻塞的入队应成功完成而非丢弃。 + client := testEntClient(t) + repo := newUsageLogRepositoryWithSQL(client, integrationDB) + repo.bestEffortBatchCh = make(chan usageLogBestEffortRequest, 1) + repo.bestEffortBatchCh <- usageLogBestEffortRequest{} + + go func() { + time.Sleep(100 * time.Millisecond) + <-repo.bestEffortBatchCh // 排空占位请求,为阻塞中的入队腾出空间 + req := <-repo.bestEffortBatchCh + sendUsageLogBestEffortResult(req.resultCh, nil) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := repo.CreateBestEffort(ctx, &service.UsageLog{ + UserID: 1, + APIKeyID: 2, + AccountID: 3, + RequestID: uuid.NewString(), + Model: "claude-3", + InputTokens: 10, + OutputTokens: 20, + TotalCost: 0.5, + ActualCost: 0.5, + CreatedAt: time.Now().UTC(), + }) + + require.NoError(t, err) } func TestUsageLogRepositoryCreate_BatchPathCanceledContextMarksNotPersisted(t *testing.T) { @@ -346,7 +380,7 @@ func TestUsageLogRepositoryCreate_BatchPathCanceledContextMarksNotPersisted(t *t } func TestUsageLogRepositoryCreate_BatchPathQueueFullMarksNotPersisted(t *testing.T) { - ctx := context.Background() + // 队列满时阻塞等待入队,直到调用方 ctx 到期才标记 not persisted(issue #3656)。 client := testEntClient(t) repo := newUsageLogRepositoryWithSQL(client, integrationDB) repo.createBatchCh = make(chan usageLogCreateRequest, 1) @@ -356,6 +390,10 @@ func TestUsageLogRepositoryCreate_BatchPathQueueFullMarksNotPersisted(t *testing apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: "sk-usage-create-full-" + uuid.NewString(), Name: "k"}) account := mustCreateAccount(t, client, &service.Account{Name: "acc-usage-create-full-" + uuid.NewString()}) + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + start := time.Now() inserted, err := repo.Create(ctx, &service.UsageLog{ UserID: user.ID, APIKeyID: apiKey.ID, @@ -372,6 +410,7 @@ func TestUsageLogRepositoryCreate_BatchPathQueueFullMarksNotPersisted(t *testing require.False(t, inserted) require.Error(t, err) require.True(t, service.IsUsageLogCreateNotPersisted(err)) + require.GreaterOrEqual(t, time.Since(start), 150*time.Millisecond) } func TestUsageLogRepositoryCreate_BatchPathCanceledAfterQueueMarksNotPersisted(t *testing.T) { diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index fcf240b19d..ee2b320177 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -665,15 +665,17 @@ func TestAPIContracts(t *testing.T) { service.SettingKeyTableDefaultPageSize: "20", service.SettingKeyTablePageSizeOptions: "[10,20,50,100]", - service.SettingKeyOpsMonitoringEnabled: "false", - service.SettingKeyOpsRealtimeMonitoringEnabled: "true", - service.SettingKeyOpsQueryModeDefault: "auto", - service.SettingKeyOpsMetricsIntervalSeconds: "60", - service.SettingPaymentVisibleMethodAlipaySource: service.VisibleMethodSourceEasyPayAlipay, - service.SettingPaymentVisibleMethodWxpaySource: service.VisibleMethodSourceOfficialWechat, - service.SettingPaymentVisibleMethodAlipayEnabled: "true", - service.SettingPaymentVisibleMethodWxpayEnabled: "false", - "openai_advanced_scheduler_enabled": "true", + service.SettingKeyOpsMonitoringEnabled: "false", + service.SettingKeyOpsRealtimeMonitoringEnabled: "true", + service.SettingKeyOpsQueryModeDefault: "auto", + service.SettingKeyOpsMetricsIntervalSeconds: "60", + service.SettingPaymentVisibleMethodAlipaySource: service.VisibleMethodSourceEasyPayAlipay, + service.SettingPaymentVisibleMethodWxpaySource: service.VisibleMethodSourceOfficialWechat, + service.SettingPaymentVisibleMethodAlipayEnabled: "true", + service.SettingPaymentVisibleMethodWxpayEnabled: "false", + "openai_advanced_scheduler_enabled": "true", + service.SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled: "false", + service.SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled: "false", }) }, method: http.MethodGet, @@ -865,6 +867,28 @@ func TestAPIContracts(t *testing.T) { "payment_visible_method_alipay_enabled": true, "payment_visible_method_wxpay_enabled": false, "openai_advanced_scheduler_enabled": true, + "openai_advanced_scheduler_sticky_weighted_enabled": false, + "openai_advanced_scheduler_subscription_priority_enabled": false, + "openai_advanced_scheduler_lb_top_k": "", + "openai_advanced_scheduler_weight_priority": "", + "openai_advanced_scheduler_weight_load": "", + "openai_advanced_scheduler_weight_queue": "", + "openai_advanced_scheduler_weight_error_rate": "", + "openai_advanced_scheduler_weight_ttft": "", + "openai_advanced_scheduler_weight_reset": "", + "openai_advanced_scheduler_weight_quota_headroom": "", + "openai_advanced_scheduler_weight_previous_response": "", + "openai_advanced_scheduler_weight_session_sticky": "", + "openai_advanced_scheduler_effective_lb_top_k": "7", + "openai_advanced_scheduler_effective_weight_priority": "1", + "openai_advanced_scheduler_effective_weight_load": "1", + "openai_advanced_scheduler_effective_weight_queue": "0.7", + "openai_advanced_scheduler_effective_weight_error_rate": "0.8", + "openai_advanced_scheduler_effective_weight_ttft": "0.5", + "openai_advanced_scheduler_effective_weight_reset": "0", + "openai_advanced_scheduler_effective_weight_quota_headroom": "0", + "openai_advanced_scheduler_effective_weight_previous_response": "5", + "openai_advanced_scheduler_effective_weight_session_sticky": "3", "openai_codex_user_agent": "", "openai_fast_policy_settings": { "rules": [] @@ -1114,6 +1138,28 @@ func TestAPIContracts(t *testing.T) { "payment_visible_method_alipay_enabled": false, "payment_visible_method_wxpay_enabled": false, "openai_advanced_scheduler_enabled": false, + "openai_advanced_scheduler_sticky_weighted_enabled": false, + "openai_advanced_scheduler_subscription_priority_enabled": false, + "openai_advanced_scheduler_lb_top_k": "", + "openai_advanced_scheduler_weight_priority": "", + "openai_advanced_scheduler_weight_load": "", + "openai_advanced_scheduler_weight_queue": "", + "openai_advanced_scheduler_weight_error_rate": "", + "openai_advanced_scheduler_weight_ttft": "", + "openai_advanced_scheduler_weight_reset": "", + "openai_advanced_scheduler_weight_quota_headroom": "", + "openai_advanced_scheduler_weight_previous_response": "", + "openai_advanced_scheduler_weight_session_sticky": "", + "openai_advanced_scheduler_effective_lb_top_k": "7", + "openai_advanced_scheduler_effective_weight_priority": "1", + "openai_advanced_scheduler_effective_weight_load": "1", + "openai_advanced_scheduler_effective_weight_queue": "0.7", + "openai_advanced_scheduler_effective_weight_error_rate": "0.8", + "openai_advanced_scheduler_effective_weight_ttft": "0.5", + "openai_advanced_scheduler_effective_weight_reset": "0", + "openai_advanced_scheduler_effective_weight_quota_headroom": "0", + "openai_advanced_scheduler_effective_weight_previous_response": "5", + "openai_advanced_scheduler_effective_weight_session_sticky": "3", "openai_codex_user_agent": "", "openai_fast_policy_settings": { "rules": [] diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 7d5e70a15b..8db5805e9d 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -580,6 +580,7 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri "gemini-3.1-pro-high", "gemini-3.1-pro-low", }) + applyAntigravityGemini31ProAliases(result) } return result } @@ -646,6 +647,61 @@ func ensureAntigravityDefaultPassthroughs(mapping map[string]string, models []st } } +func applyAntigravityGemini31ProAliases(mapping map[string]string) { + target := strings.TrimSpace(mapping[domain.AntigravityGemini31ProAgentModel]) + if target == "" { + return + } + + aliases := []struct { + model string + legacyTargets map[string]struct{} + }{ + { + model: "gemini-3.1-pro", + legacyTargets: map[string]struct{}{ + "gemini-3.1-pro": {}, + }, + }, + { + model: "gemini-3.1-pro-high", + legacyTargets: map[string]struct{}{ + "gemini-3.1-pro-high": {}, + }, + }, + { + model: "gemini-3.1-pro-preview", + legacyTargets: map[string]struct{}{ + "gemini-3.1-pro-preview": {}, + "gemini-3.1-pro-high": {}, + }, + }, + } + + for _, alias := range aliases { + current, exists := mapping[alias.model] + if exists { + if _, legacy := alias.legacyTargets[current]; legacy { + mapping[alias.model] = target + } + continue + } + if mappingHasWildcardForModel(mapping, alias.model) { + continue + } + mapping[alias.model] = target + } +} + +func mappingHasWildcardForModel(mapping map[string]string, model string) bool { + for pattern := range mapping { + if matchWildcard(pattern, model) { + return true + } + } + return false +} + func normalizeRequestedModelForLookup(platform, requestedModel string) string { trimmed := strings.TrimSpace(requestedModel) if trimmed == "" { @@ -1126,6 +1182,18 @@ func (a *Account) IsOpenAIOAuth() bool { return a.IsOpenAI() && a.Type == AccountTypeOAuth } +func (a *Account) IsOpenAIChatGPTSubscription() bool { + if !a.IsOpenAIOAuth() { + return false + } + switch strings.ToLower(strings.TrimSpace(a.GetCredential("plan_type"))) { + case "", "free", "abnormal": + return false + default: + return true + } +} + func (a *Account) IsOpenAIPersonalAccessToken() bool { if !a.IsOpenAIOAuth() { return false diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 3c50baaec2..d9f6200359 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -184,6 +184,7 @@ type UsageInfo struct { FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口 SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口 SevenDaySonnet *UsageProgress `json:"seven_day_sonnet,omitempty"` // 7天Sonnet窗口 + SevenDayFable *UsageProgress `json:"seven_day_fable,omitempty"` // 7天Fable窗口(响应头 7d_oi) GeminiSharedDaily *UsageProgress `json:"gemini_shared_daily,omitempty"` // Gemini shared pool RPD (Google One / Code Assist) GeminiProDaily *UsageProgress `json:"gemini_pro_daily,omitempty"` // Gemini Pro 日配额 GeminiFlashDaily *UsageProgress `json:"gemini_flash_daily,omitempty"` // Gemini Flash 日配额 @@ -236,6 +237,12 @@ type UsageInfo struct { Error string `json:"error,omitempty"` } +// ClaudeUsageWindow Anthropic /api/oauth/usage 返回的单个用量窗口 +type ClaudeUsageWindow struct { + Utilization float64 `json:"utilization"` + ResetsAt string `json:"resets_at"` +} + // ClaudeUsageResponse Anthropic API返回的usage结构 type ClaudeUsageResponse struct { FiveHour struct { @@ -250,6 +257,10 @@ type ClaudeUsageResponse struct { Utilization float64 `json:"utilization"` ResetsAt string `json:"resets_at"` } `json:"seven_day_sonnet"` + // Fable 专属 7d 窗口(对应响应头 7d_oi,claim 名为 seven_day_overage_included, + // 见 anthropic-ratelimit-unified-representative-claim 头)。上游 usage API + // 若不下发该字段,GetUsage 会用被动采样数据回填。 + SevenDayOverageIncluded ClaudeUsageWindow `json:"seven_day_overage_included"` } // ClaudeUsageFetchOptions 包含获取 Claude 用量数据所需的所有选项 @@ -429,6 +440,12 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64, for // 5. 将主动查询结果同步到被动缓存,下次 passive 加载即为最新值 s.syncActiveToPassive(ctx, account.ID, usage) + // 6. 上游 usage API 目前不一定下发 Fable 7d 窗口;缺失时回填被动采样 + // (7d_oi 响应头)的数据,避免主动查询后 7d F 进度条丢失。 + if usage.SevenDayFable == nil { + usage.SevenDayFable = buildPassiveUsageWindow(account.Extra, "passive_usage_7d_oi_utilization", "passive_usage_7d_oi_reset") + } + s.tryClearRecoverableAccountError(ctx, account) return usage, nil } @@ -471,25 +488,10 @@ func (s *AccountUsageService) GetPassiveUsage(ctx context.Context, accountID int } // 构建 7d 窗口(从被动采样数据) - util7d := parseExtraFloat64(account.Extra["passive_usage_7d_utilization"]) - reset7dRaw := parseExtraFloat64(account.Extra["passive_usage_7d_reset"]) - if util7d > 0 || reset7dRaw > 0 { - var resetAt *time.Time - var remaining int - if reset7dRaw > 0 { - t := time.Unix(int64(reset7dRaw), 0) - resetAt = &t - remaining = int(time.Until(t).Seconds()) - if remaining < 0 { - remaining = 0 - } - } - info.SevenDay = &UsageProgress{ - Utilization: util7d * 100, - ResetsAt: resetAt, - RemainingSeconds: remaining, - } - } + info.SevenDay = buildPassiveUsageWindow(account.Extra, "passive_usage_7d_utilization", "passive_usage_7d_reset") + + // 构建 7d Fable 窗口(从被动采样的 7d_oi 响应头数据) + info.SevenDayFable = buildPassiveUsageWindow(account.Extra, "passive_usage_7d_oi_utilization", "passive_usage_7d_oi_reset") // 添加窗口统计 s.addWindowStats(ctx, account, info) @@ -497,6 +499,31 @@ func (s *AccountUsageService) GetPassiveUsage(ctx context.Context, accountID int return info, nil } +// buildPassiveUsageWindow 从 Extra 中的被动采样数据(utilization 为 0-1 小数、reset 为 Unix 秒) +// 构建用量窗口,无数据时返回 nil。 +func buildPassiveUsageWindow(extra map[string]any, utilKey, resetKey string) *UsageProgress { + util := parseExtraFloat64(extra[utilKey]) + resetRaw := parseExtraFloat64(extra[resetKey]) + if util <= 0 && resetRaw <= 0 { + return nil + } + var resetAt *time.Time + var remaining int + if resetRaw > 0 { + t := time.Unix(int64(resetRaw), 0) + resetAt = &t + remaining = int(time.Until(t).Seconds()) + if remaining < 0 { + remaining = 0 + } + } + return &UsageProgress{ + Utilization: util * 100, + ResetsAt: resetAt, + RemainingSeconds: remaining, + } +} + // syncActiveToPassive 将主动查询的最新数据回写到 Extra 被动缓存, // 这样下次被动加载时能看到最新值。 func (s *AccountUsageService) syncActiveToPassive(ctx context.Context, accountID int64, usage *UsageInfo) { @@ -511,6 +538,12 @@ func (s *AccountUsageService) syncActiveToPassive(ctx context.Context, accountID extraUpdates["passive_usage_7d_reset"] = usage.SevenDay.ResetsAt.Unix() } } + if usage.SevenDayFable != nil { + extraUpdates["passive_usage_7d_oi_utilization"] = usage.SevenDayFable.Utilization / 100 + if usage.SevenDayFable.ResetsAt != nil { + extraUpdates["passive_usage_7d_oi_reset"] = usage.SevenDayFable.ResetsAt.Unix() + } + } if len(extraUpdates) > 0 { extraUpdates["passive_usage_sampled_at"] = time.Now().UTC().Format(time.RFC3339) @@ -1010,8 +1043,8 @@ func enrichUsageWithAccountError(info *UsageInfo, account *Account) { // 使用独立缓存(1 分钟),与 API 缓存分离 func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Account, usage *UsageInfo) { // 修复:即使 FiveHour 为 nil,也要尝试获取统计数据 - // 因为 SevenDay/SevenDaySonnet 可能需要 - if usage.FiveHour == nil && usage.SevenDay == nil && usage.SevenDaySonnet == nil { + // 因为 SevenDay/SevenDaySonnet/SevenDayFable 可能需要 + if usage.FiveHour == nil && usage.SevenDay == nil && usage.SevenDaySonnet == nil && usage.SevenDayFable == nil { return } @@ -1347,6 +1380,22 @@ func (s *AccountUsageService) buildUsageInfo(resp *ClaudeUsageResponse, updatedA } } + // 7天Fable窗口(响应头 7d_oi 对应的窗口) + if fable := resp.SevenDayOverageIncluded; fable.ResetsAt != "" { + if fableReset, err := parseTime(fable.ResetsAt); err == nil { + info.SevenDayFable = &UsageProgress{ + Utilization: fable.Utilization, + ResetsAt: &fableReset, + RemainingSeconds: int(time.Until(fableReset).Seconds()), + } + } else { + log.Printf("Failed to parse SevenDayFable.ResetsAt: %s, error: %v", fable.ResetsAt, err) + info.SevenDayFable = &UsageProgress{ + Utilization: fable.Utilization, + } + } + } + return info } diff --git a/backend/internal/service/account_usage_service_fable_test.go b/backend/internal/service/account_usage_service_fable_test.go new file mode 100644 index 0000000000..60f2e9c79f --- /dev/null +++ b/backend/internal/service/account_usage_service_fable_test.go @@ -0,0 +1,119 @@ +package service + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestClaudeUsageResponse_FableWindowDecoding(t *testing.T) { + t.Run("seven_day_overage_included", func(t *testing.T) { + raw := `{ + "five_hour": {"utilization": 12.0, "resets_at": "2026-07-03T10:00:00Z"}, + "seven_day": {"utilization": 34.0, "resets_at": "2026-07-08T00:00:00Z"}, + "seven_day_overage_included": {"utilization": 56.0, "resets_at": "2026-07-08T03:00:00Z"} +}` + var resp ClaudeUsageResponse + require.NoError(t, json.Unmarshal([]byte(raw), &resp)) + require.Equal(t, 56.0, resp.SevenDayOverageIncluded.Utilization) + require.Equal(t, "2026-07-08T03:00:00Z", resp.SevenDayOverageIncluded.ResetsAt) + }) + + t.Run("absent", func(t *testing.T) { + raw := `{"five_hour": {"utilization": 12.0, "resets_at": "2026-07-03T10:00:00Z"}}` + var resp ClaudeUsageResponse + require.NoError(t, json.Unmarshal([]byte(raw), &resp)) + require.Zero(t, resp.SevenDayOverageIncluded.Utilization) + require.Empty(t, resp.SevenDayOverageIncluded.ResetsAt) + }) +} + +func TestBuildUsageInfo_SevenDayFable(t *testing.T) { + svc := &AccountUsageService{} + now := time.Now() + + resetAt := now.Add(72 * time.Hour).UTC().Truncate(time.Second) + var resp ClaudeUsageResponse + resp.FiveHour.Utilization = 10 + resp.SevenDayOverageIncluded = ClaudeUsageWindow{ + Utilization: 88, + ResetsAt: resetAt.Format(time.RFC3339), + } + + info := svc.buildUsageInfo(&resp, &now) + require.NotNil(t, info.SevenDayFable) + require.Equal(t, 88.0, info.SevenDayFable.Utilization) + require.NotNil(t, info.SevenDayFable.ResetsAt) + require.True(t, info.SevenDayFable.ResetsAt.Equal(resetAt)) + require.Greater(t, info.SevenDayFable.RemainingSeconds, 0) + + // 无 Fable 数据时不应创建窗口 + var empty ClaudeUsageResponse + empty.FiveHour.Utilization = 10 + info = svc.buildUsageInfo(&empty, &now) + require.Nil(t, info.SevenDayFable) +} + +func TestBuildPassiveUsageWindow(t *testing.T) { + future := time.Now().Add(48 * time.Hour).Unix() + + t.Run("utilization and reset", func(t *testing.T) { + window := buildPassiveUsageWindow(map[string]any{ + "passive_usage_7d_oi_utilization": 0.87, + "passive_usage_7d_oi_reset": float64(future), + }, "passive_usage_7d_oi_utilization", "passive_usage_7d_oi_reset") + require.NotNil(t, window) + require.InDelta(t, 87.0, window.Utilization, 1e-9) + require.NotNil(t, window.ResetsAt) + require.Equal(t, future, window.ResetsAt.Unix()) + require.Greater(t, window.RemainingSeconds, 0) + }) + + t.Run("no data returns nil", func(t *testing.T) { + require.Nil(t, buildPassiveUsageWindow(nil, "u", "r")) + require.Nil(t, buildPassiveUsageWindow(map[string]any{}, "u", "r")) + }) + + t.Run("expired reset clamps remaining to zero", func(t *testing.T) { + past := time.Now().Add(-time.Hour).Unix() + window := buildPassiveUsageWindow(map[string]any{ + "u": 0.5, + "r": float64(past), + }, "u", "r") + require.NotNil(t, window) + require.Equal(t, 0, window.RemainingSeconds) + }) + + t.Run("utilization only", func(t *testing.T) { + window := buildPassiveUsageWindow(map[string]any{"u": 0.25}, "u", "r") + require.NotNil(t, window) + require.InDelta(t, 25.0, window.Utilization, 1e-9) + require.Nil(t, window.ResetsAt) + }) +} + +func TestSyncActiveToPassive_WritesFableExtras(t *testing.T) { + repo := &accountUsageCodexProbeRepo{updateExtraCh: make(chan map[string]any, 1)} + svc := &AccountUsageService{accountRepo: repo} + + resetAt := time.Now().Add(72 * time.Hour).Truncate(time.Second) + usage := &UsageInfo{ + SevenDayFable: &UsageProgress{ + Utilization: 87, + ResetsAt: &resetAt, + }, + } + + svc.syncActiveToPassive(t.Context(), 1, usage) + + select { + case updates := <-repo.updateExtraCh: + require.InDelta(t, 0.87, updates["passive_usage_7d_oi_utilization"], 1e-9) + require.Equal(t, resetAt.Unix(), updates["passive_usage_7d_oi_reset"]) + require.Contains(t, updates, "passive_usage_sampled_at") + default: + t.Fatal("expected UpdateExtra to be called with fable extras") + } +} diff --git a/backend/internal/service/account_wildcard_test.go b/backend/internal/service/account_wildcard_test.go index d903b940a5..6ce804bb16 100644 --- a/backend/internal/service/account_wildcard_test.go +++ b/backend/internal/service/account_wildcard_test.go @@ -4,6 +4,8 @@ package service import ( "testing" + + "github.com/Wei-Shaw/sub2api/internal/domain" ) func TestMatchWildcard(t *testing.T) { @@ -320,6 +322,86 @@ func TestAccountGetMappedModel(t *testing.T) { } } +func TestAccountGetModelMapping_AntigravityNormalizesGemini31ProAliases(t *testing.T) { + t.Parallel() + + account := &Account{ + Platform: PlatformAntigravity, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + domain.AntigravityGemini31ProAgentModel: domain.AntigravityGemini31ProAgentModel, + "gemini-3.1-pro-high": "gemini-3.1-pro-high", + "gemini-3.1-pro-preview": "gemini-3.1-pro-high", + }, + }, + } + + mapping := account.GetModelMapping() + + if got := mapping["gemini-3.1-pro"]; got != domain.AntigravityGemini31ProAgentModel { + t.Fatalf("expected gemini-3.1-pro to map to %q, got %q", domain.AntigravityGemini31ProAgentModel, got) + } + if got := mapping["gemini-3.1-pro-high"]; got != domain.AntigravityGemini31ProAgentModel { + t.Fatalf("expected gemini-3.1-pro-high to map to %q, got %q", domain.AntigravityGemini31ProAgentModel, got) + } + if got := mapping["gemini-3.1-pro-preview"]; got != domain.AntigravityGemini31ProAgentModel { + t.Fatalf("expected gemini-3.1-pro-preview to map to %q, got %q", domain.AntigravityGemini31ProAgentModel, got) + } +} + +func TestAccountGetModelMapping_AntigravityPreservesGemini31ProOverrides(t *testing.T) { + t.Parallel() + + account := &Account{ + Platform: PlatformAntigravity, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + domain.AntigravityGemini31ProAgentModel: domain.AntigravityGemini31ProAgentModel, + "gemini-3.1-pro-high": "custom-high", + "gemini-3.1-pro-preview": "custom-preview", + }, + }, + } + + mapping := account.GetModelMapping() + + if got := mapping["gemini-3.1-pro-high"]; got != "custom-high" { + t.Fatalf("expected gemini-3.1-pro-high override to be preserved, got %q", got) + } + if got := mapping["gemini-3.1-pro-preview"]; got != "custom-preview" { + t.Fatalf("expected gemini-3.1-pro-preview override to be preserved, got %q", got) + } + if got := mapping["gemini-3.1-pro"]; got != domain.AntigravityGemini31ProAgentModel { + t.Fatalf("expected gemini-3.1-pro alias to default to %q, got %q", domain.AntigravityGemini31ProAgentModel, got) + } +} + +func TestAccountGetModelMapping_AntigravityGemini31ProAliasesRespectWildcard(t *testing.T) { + t.Parallel() + + account := &Account{ + Platform: PlatformAntigravity, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + domain.AntigravityGemini31ProAgentModel: domain.AntigravityGemini31ProAgentModel, + "gemini-3.1-*": "custom-wildcard", + }, + }, + } + + mapping := account.GetModelMapping() + + if got := mapping["gemini-3.1-pro"]; got != "" { + t.Fatalf("expected gemini-3.1-pro exact alias to stay unset when wildcard exists, got %q", got) + } + if got := mapping["gemini-3.1-pro-high"]; got != "" { + t.Fatalf("expected gemini-3.1-pro-high exact alias to stay unset when wildcard exists, got %q", got) + } + if got := mapping["gemini-3.1-pro-preview"]; got != "" { + t.Fatalf("expected gemini-3.1-pro-preview exact alias to stay unset when wildcard exists, got %q", got) + } +} + func TestAccountResolveMappedModel(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/service/admin_account_concurrency_test.go b/backend/internal/service/admin_account_concurrency_test.go index 3544f80e24..da57b5a64f 100644 --- a/backend/internal/service/admin_account_concurrency_test.go +++ b/backend/internal/service/admin_account_concurrency_test.go @@ -5,23 +5,16 @@ package service import ( "testing" - "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/stretchr/testify/require" ) -func TestNormalizeAccountConcurrencyCapsGrokOAuthUnlessUnsafe(t *testing.T) { - t.Setenv(xai.EnvUnsafeAllowHighConcurrency, "") - +func TestNormalizeAccountConcurrencyDefaultsInvalidGrokOAuthToOne(t *testing.T) { require.Equal(t, 1, normalizeAccountConcurrency(PlatformGrok, AccountTypeOAuth, 0)) require.Equal(t, 1, normalizeAccountConcurrency(PlatformGrok, AccountTypeOAuth, -5)) - require.Equal(t, 1, normalizeAccountConcurrency(PlatformGrok, AccountTypeOAuth, 50)) +} + +func TestNormalizeAccountConcurrencyPreservesExplicitValues(t *testing.T) { + require.Equal(t, 50, normalizeAccountConcurrency(PlatformGrok, AccountTypeOAuth, 50)) require.Equal(t, 2, normalizeAccountConcurrency(PlatformOpenAI, AccountTypeOAuth, 2)) require.Equal(t, 2, normalizeAccountConcurrency(PlatformGrok, AccountTypeAPIKey, 2)) } - -func TestNormalizeAccountConcurrencyAllowsGrokOAuthUnsafeOverride(t *testing.T) { - t.Setenv(xai.EnvUnsafeAllowHighConcurrency, "true") - - require.Equal(t, 50, normalizeAccountConcurrency(PlatformGrok, AccountTypeOAuth, 50)) - require.Equal(t, 1, normalizeAccountConcurrency(PlatformGrok, AccountTypeOAuth, 0)) -} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 18bf7b60ef..acdfe08202 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2660,6 +2660,29 @@ func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, 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 + } + lister, ok := s.accountRepo.(interface { + ListAllWithFilters(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error) + }) + if !ok { + return nil, nil + } + return lister.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) } @@ -2682,9 +2705,6 @@ func normalizeAccountConcurrency(platform, accountType string, concurrency int) if concurrency <= 0 { return 1 } - if concurrency > 1 && !xai.AllowUnsafeHighConcurrency() { - return 1 - } } return concurrency } diff --git a/backend/internal/service/antigravity_token_refresher.go b/backend/internal/service/antigravity_token_refresher.go index 7ce0ccf0fe..f40df7d95f 100644 --- a/backend/internal/service/antigravity_token_refresher.go +++ b/backend/internal/service/antigravity_token_refresher.go @@ -12,6 +12,10 @@ const ( // antigravityRefreshWindow Antigravity token 提前刷新窗口:15分钟 // Google OAuth token 有效期55分钟,提前15分钟刷新 antigravityRefreshWindow = 15 * time.Minute + + antigravityForceTokenRefreshExtraKey = "antigravity_force_token_refresh" + antigravityForceTokenRefreshReasonExtraKey = "antigravity_force_token_refresh_reason" + antigravityForceTokenRefreshAtExtraKey = "antigravity_force_token_refresh_at" ) // AntigravityTokenRefresher 实现 TokenRefresher 接口 @@ -41,6 +45,9 @@ func (r *AntigravityTokenRefresher) NeedsRefresh(account *Account, _ time.Durati if !r.CanRefresh(account) { return false } + if accountNeedsAntigravityForceTokenRefresh(account) { + return true + } expiresAt := account.GetCredentialAsTime("expires_at") if expiresAt == nil { return false @@ -54,6 +61,29 @@ func (r *AntigravityTokenRefresher) NeedsRefresh(account *Account, _ time.Durati return needsRefresh } +func accountNeedsAntigravityForceTokenRefresh(account *Account) bool { + return account != nil && + account.Platform == PlatformAntigravity && + account.Type == AccountTypeOAuth && + account.getExtraBool(antigravityForceTokenRefreshExtraKey) +} + +func antigravityForceTokenRefreshExtra(reason string) map[string]any { + return map[string]any{ + antigravityForceTokenRefreshExtraKey: true, + antigravityForceTokenRefreshReasonExtraKey: reason, + antigravityForceTokenRefreshAtExtraKey: time.Now().UTC().Format(time.RFC3339), + } +} + +func clearAntigravityForceTokenRefreshExtra() map[string]any { + return map[string]any{ + antigravityForceTokenRefreshExtraKey: false, + antigravityForceTokenRefreshReasonExtraKey: "", + antigravityForceTokenRefreshAtExtraKey: "", + } +} + // Refresh 执行 token 刷新 func (r *AntigravityTokenRefresher) Refresh(ctx context.Context, account *Account) (map[string]any, error) { tokenInfo, err := r.antigravityOAuthService.RefreshAccountToken(ctx, account) diff --git a/backend/internal/service/codex_image_generation_bridge.go b/backend/internal/service/codex_image_generation_bridge.go index c7a894a792..68989d67dc 100644 --- a/backend/internal/service/codex_image_generation_bridge.go +++ b/backend/internal/service/codex_image_generation_bridge.go @@ -4,6 +4,13 @@ import "strings" const featureKeyCodexImageGenerationBridge = "codex_image_generation_bridge" +const ( + featureKeyCodexImageGenerationExplicitToolPolicy = "codex_image_generation_explicit_tool_policy" + + codexImageGenerationExplicitToolPolicyAllow = "allow" + codexImageGenerationExplicitToolPolicyStrip = "strip" +) + func boolOverridePtr(v bool) *bool { return &v } @@ -20,6 +27,27 @@ func boolOverrideFromMap(values map[string]any, keys ...string) *bool { return nil } +func stringOverrideFromMap(values map[string]any, keys ...string) (string, bool) { + if values == nil { + return "", false + } + for _, key := range keys { + if v, ok := values[key].(string); ok { + return v, true + } + } + return "", false +} + +func normalizeCodexImageGenerationExplicitToolPolicy(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case codexImageGenerationExplicitToolPolicyStrip, "remove", "drop": + return codexImageGenerationExplicitToolPolicyStrip + default: + return codexImageGenerationExplicitToolPolicyAllow + } +} + func platformBoolOverride(values map[string]any, key string, platform string) *bool { if values == nil { return nil @@ -62,3 +90,20 @@ func (a *Account) CodexImageGenerationBridgeOverride() *bool { openaiConfig, _ := a.Extra[PlatformOpenAI].(map[string]any) return boolOverrideFromMap(openaiConfig, featureKeyCodexImageGenerationBridge, "codex_image_generation_bridge_enabled") } + +// CodexImageGenerationExplicitToolPolicy returns the account-level policy for +// client-provided Codex /responses image_generation tools. Unknown or unset +// values default to allow to preserve existing behavior. +func (a *Account) CodexImageGenerationExplicitToolPolicy() string { + if a == nil || a.Platform != PlatformOpenAI || a.Extra == nil { + return codexImageGenerationExplicitToolPolicyAllow + } + if policy, ok := stringOverrideFromMap(a.Extra, featureKeyCodexImageGenerationExplicitToolPolicy); ok { + return normalizeCodexImageGenerationExplicitToolPolicy(policy) + } + openaiConfig, _ := a.Extra[PlatformOpenAI].(map[string]any) + if policy, ok := stringOverrideFromMap(openaiConfig, featureKeyCodexImageGenerationExplicitToolPolicy); ok { + return normalizeCodexImageGenerationExplicitToolPolicy(policy) + } + return codexImageGenerationExplicitToolPolicyAllow +} diff --git a/backend/internal/service/concurrency_service.go b/backend/internal/service/concurrency_service.go index 712fc1a749..50e0077b94 100644 --- a/backend/internal/service/concurrency_service.go +++ b/backend/internal/service/concurrency_service.go @@ -47,6 +47,7 @@ type ConcurrencyCache interface { // 清理过期槽位(后台任务) CleanupExpiredAccountSlots(ctx context.Context, accountID int64) error + CleanupExpiredAccountSlotKeys(ctx context.Context) error // 启动时清理旧进程遗留槽位与等待计数 CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error @@ -473,27 +474,19 @@ func (s *ConcurrencyService) CleanupExpiredAccountSlots(ctx context.Context, acc } // StartSlotCleanupWorker starts a background cleanup worker for expired account slots. -func (s *ConcurrencyService) StartSlotCleanupWorker(accountRepo AccountRepository, interval time.Duration) { - if s == nil || s.cache == nil || accountRepo == nil || interval <= 0 { +func (s *ConcurrencyService) StartSlotCleanupWorker(_ AccountRepository, interval time.Duration) { + if s == nil || s.cache == nil || interval <= 0 { return } runCleanup := func() { - listCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - accounts, err := accountRepo.ListSchedulable(listCtx) + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := s.cache.CleanupExpiredAccountSlotKeys(cleanupCtx) cancel() if err != nil { - logger.LegacyPrintf("service.concurrency", "Warning: list schedulable accounts failed: %v", err) + logger.LegacyPrintf("service.concurrency", "Warning: cleanup expired account slots failed: %v", err) return } - for _, account := range accounts { - accountCtx, accountCancel := context.WithTimeout(context.Background(), 2*time.Second) - err := s.cache.CleanupExpiredAccountSlots(accountCtx, account.ID) - accountCancel() - if err != nil { - logger.LegacyPrintf("service.concurrency", "Warning: cleanup expired slots failed for account %d: %v", account.ID, err) - } - } } go func() { diff --git a/backend/internal/service/concurrency_service_test.go b/backend/internal/service/concurrency_service_test.go index 7d5f501dc5..bacad0245e 100644 --- a/backend/internal/service/concurrency_service_test.go +++ b/backend/internal/service/concurrency_service_test.go @@ -95,6 +95,10 @@ func (c *stubConcurrencyCacheForTest) CleanupExpiredAccountSlots(_ context.Conte return c.cleanupErr } +func (c *stubConcurrencyCacheForTest) CleanupExpiredAccountSlotKeys(_ context.Context) error { + return c.cleanupErr +} + func (c *stubConcurrencyCacheForTest) CleanupStaleProcessSlots(_ context.Context, _ string) error { return c.cleanupErr } diff --git a/backend/internal/service/concurrency_slot_cleanup_test.go b/backend/internal/service/concurrency_slot_cleanup_test.go new file mode 100644 index 0000000000..be26bb391b --- /dev/null +++ b/backend/internal/service/concurrency_slot_cleanup_test.go @@ -0,0 +1,39 @@ +package service + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +type slotCleanupCache struct { + ConcurrencyCache + calls atomic.Int64 +} + +func (c *slotCleanupCache) CleanupExpiredAccountSlotKeys(context.Context) error { + c.calls.Add(1) + return nil +} + +func TestStartSlotCleanupWorker_UsesCacheWideCleanupWithoutAccountRepo(t *testing.T) { + cache := &slotCleanupCache{} + svc := NewConcurrencyService(cache) + + svc.StartSlotCleanupWorker(nil, time.Hour) + + deadline := time.After(time.Second) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + if cache.calls.Load() > 0 { + return + } + select { + case <-deadline: + t.Fatal("cleanup worker did not call cache-wide account slot cleanup") + case <-ticker.C: + } + } +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index ab853c280a..bc1db19105 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -431,6 +431,20 @@ const ( // SettingKeyAllowUngroupedKeyScheduling 允许未分组 API Key 调度(默认 false:未分组 Key 返回 403) SettingKeyAllowUngroupedKeyScheduling = "allow_ungrouped_key_scheduling" + // SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled OpenAI 高级调度下是否启用粘性加权。 + SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled = "openai_advanced_scheduler_sticky_weighted_enabled" + // SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled OpenAI 高级调度下是否优先使用订阅账号池。 + SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled = "openai_advanced_scheduler_subscription_priority_enabled" + SettingKeyOpenAIAdvancedSchedulerLBTopK = "openai_advanced_scheduler_lb_top_k" + SettingKeyOpenAIAdvancedSchedulerWeightPriority = "openai_advanced_scheduler_weight_priority" + SettingKeyOpenAIAdvancedSchedulerWeightLoad = "openai_advanced_scheduler_weight_load" + SettingKeyOpenAIAdvancedSchedulerWeightQueue = "openai_advanced_scheduler_weight_queue" + SettingKeyOpenAIAdvancedSchedulerWeightErrorRate = "openai_advanced_scheduler_weight_error_rate" + SettingKeyOpenAIAdvancedSchedulerWeightTTFT = "openai_advanced_scheduler_weight_ttft" + SettingKeyOpenAIAdvancedSchedulerWeightReset = "openai_advanced_scheduler_weight_reset" + SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom = "openai_advanced_scheduler_weight_quota_headroom" + SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse = "openai_advanced_scheduler_weight_previous_response" + SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky = "openai_advanced_scheduler_weight_session_sticky" // SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录 SettingKeyBackendModeEnabled = "backend_mode_enabled" diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 291cea53b9..f843ba3e45 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -2097,6 +2097,10 @@ func (m *mockConcurrencyCache) CleanupExpiredAccountSlots(ctx context.Context, a return nil } +func (m *mockConcurrencyCache) CleanupExpiredAccountSlotKeys(ctx context.Context) error { + return nil +} + func (m *mockConcurrencyCache) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error { return nil } diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index c819eeca6e..2769251820 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -440,7 +440,9 @@ func TestGatewayServiceRecordUsage_GeneratesRequestIDWhenAllSourcesMissing(t *te require.Equal(t, billingRepo.lastCmd.RequestID, usageRepo.lastLog.RequestID) } -func TestGatewayServiceRecordUsage_DroppedUsageLogDoesNotSyncFallback(t *testing.T) { +func TestGatewayServiceRecordUsage_DroppedUsageLogFallsBackToSyncCreate(t *testing.T) { + // 计费成功后 best-effort 写入被丢弃(队列超时)时必须同步兜底, + // 否则出现“已扣费但无 usage_log”的对账缺口(issue #3656)。 usageRepo := &openAIRecordUsageBestEffortLogRepoStub{ bestEffortErr: MarkUsageLogCreateDropped(errors.New("usage log best-effort queue full")), } @@ -464,7 +466,9 @@ func TestGatewayServiceRecordUsage_DroppedUsageLogDoesNotSyncFallback(t *testing require.NoError(t, err) require.Equal(t, 1, usageRepo.bestEffortCalls) - require.Equal(t, 0, usageRepo.createCalls) + require.Equal(t, 1, usageRepo.createCalls) + // 兜底调用使用的 ctx 必须仍然存活,不能带着已死的 ctx 走过场。 + require.NoError(t, usageRepo.lastCtxErr) } func TestGatewayServiceRecordUsage_BillingErrorSkipsUsageLogWrite(t *testing.T) { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 160a92a8e1..54035345d9 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -9473,10 +9473,17 @@ func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usage if writer, ok := repo.(usageLogBestEffortWriter); ok { if err := writer.CreateBestEffort(usageCtx, usageLog); err != nil { logger.LegacyPrintf(logKey, "Create usage log failed: %v", err) - if IsUsageLogCreateDropped(err) { - return + // 计费已在此前完成,日志必须落库: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(usageCtx, usageLog); syncErr != nil { + if _, syncErr := repo.Create(fallbackCtx, usageLog); syncErr != nil { logger.LegacyPrintf(logKey, "Create usage log sync fallback failed: %v", syncErr) } } diff --git a/backend/internal/service/group_capacity_service.go b/backend/internal/service/group_capacity_service.go index 459084dc59..b77b35773b 100644 --- a/backend/internal/service/group_capacity_service.go +++ b/backend/internal/service/group_capacity_service.go @@ -16,6 +16,26 @@ type GroupCapacitySummary struct { RPMMax int `json:"rpm_max"` } +// GroupAccountCapacityRow is the lightweight account projection needed for +// capacity summary aggregation. +type GroupAccountCapacityRow struct { + GroupID int64 + AccountID int64 + Concurrency int + Extra map[string]any + SessionWindowStart *time.Time + SessionWindowEnd *time.Time + SessionWindowStatus string +} + +type groupCapacityActiveGroupIDLister interface { + ListActiveIDs(ctx context.Context) ([]int64, error) +} + +type groupCapacityAccountLister interface { + ListSchedulableCapacityByGroupIDs(ctx context.Context, groupIDs []int64) ([]GroupAccountCapacityRow, error) +} + // GroupCapacityService aggregates per-group capacity from runtime data. type GroupCapacityService struct { accountRepo AccountRepository @@ -44,24 +64,176 @@ func NewGroupCapacityService( // GetAllGroupCapacity returns capacity summary for all active groups. func (s *GroupCapacityService) GetAllGroupCapacity(ctx context.Context) ([]GroupCapacitySummary, error) { - groups, err := s.groupRepo.ListActive(ctx) + groupIDs, err := s.listActiveGroupIDs(ctx) if err != nil { return nil, err } - results := make([]GroupCapacitySummary, 0, len(groups)) + if lister, ok := s.accountRepo.(groupCapacityAccountLister); ok { + return s.getGroupCapacitiesBatch(ctx, groupIDs, lister) + } + + return s.getGroupCapacitiesSequential(ctx, groupIDs), nil +} + +func (s *GroupCapacityService) listActiveGroupIDs(ctx context.Context) ([]int64, error) { + if lister, ok := s.groupRepo.(groupCapacityActiveGroupIDLister); ok { + return lister.ListActiveIDs(ctx) + } + + groups, err := s.groupRepo.ListActive(ctx) + if err != nil { + return nil, err + } + groupIDs := make([]int64, 0, len(groups)) for i := range groups { - cap, err := s.getGroupCapacity(ctx, groups[i].ID) + groupIDs = append(groupIDs, groups[i].ID) + } + return groupIDs, nil +} + +func (s *GroupCapacityService) getGroupCapacitiesSequential(ctx context.Context, groupIDs []int64) []GroupCapacitySummary { + results := make([]GroupCapacitySummary, 0, len(groupIDs)) + for _, groupID := range groupIDs { + cap, err := s.getGroupCapacity(ctx, groupID) if err != nil { // Skip groups with errors, return partial results continue } - cap.GroupID = groups[i].ID + cap.GroupID = groupID results = append(results, cap) } + return results +} + +type groupCapacityAccountRef struct { + groupID int64 + accountID int64 +} + +func (s *GroupCapacityService) getGroupCapacitiesBatch(ctx context.Context, groupIDs []int64, lister groupCapacityAccountLister) ([]GroupCapacitySummary, error) { + results := make([]GroupCapacitySummary, len(groupIDs)) + groupIndex := make(map[int64]int, len(groupIDs)) + for i, groupID := range groupIDs { + results[i].GroupID = groupID + groupIndex[groupID] = i + } + if len(groupIDs) == 0 { + return results, nil + } + + rows, err := lister.ListSchedulableCapacityByGroupIDs(ctx, groupIDs) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return results, nil + } + + refs := make([]groupCapacityAccountRef, 0, len(rows)) + seenGroupAccount := make(map[groupCapacityAccountRef]struct{}, len(rows)) + accountIDSet := make(map[int64]struct{}, len(rows)) + accountIDs := make([]int64, 0, len(rows)) + sessionTimeouts := make(map[int64]time.Duration) + + for _, row := range rows { + idx, ok := groupIndex[row.GroupID] + if !ok || row.AccountID <= 0 { + continue + } + + ref := groupCapacityAccountRef{groupID: row.GroupID, accountID: row.AccountID} + if _, ok := seenGroupAccount[ref]; ok { + continue + } + seenGroupAccount[ref] = struct{}{} + refs = append(refs, ref) + + if _, ok := accountIDSet[row.AccountID]; !ok { + accountIDSet[row.AccountID] = struct{}{} + accountIDs = append(accountIDs, row.AccountID) + } + + acc := Account{ + ID: row.AccountID, + Concurrency: row.Concurrency, + Extra: row.Extra, + SessionWindowStart: row.SessionWindowStart, + SessionWindowEnd: row.SessionWindowEnd, + SessionWindowStatus: row.SessionWindowStatus, + } + + results[idx].ConcurrencyMax += acc.Concurrency + + if maxSessions := acc.GetMaxSessions(); maxSessions > 0 { + results[idx].SessionsMax += maxSessions + timeout := time.Duration(acc.GetSessionIdleTimeoutMinutes()) * time.Minute + if timeout <= 0 { + timeout = 5 * time.Minute + } + sessionTimeouts[acc.ID] = timeout + } + + if rpm := acc.GetBaseRPM(); rpm > 0 { + results[idx].RPMMax += rpm + } + } + + if len(accountIDs) == 0 { + return results, nil + } + + concurrencyMap := map[int64]int{} + if s.concurrencyService != nil { + concurrencyMap, _ = s.concurrencyService.GetAccountConcurrencyBatch(ctx, accountIDs) + } + + sessionAccountIDs := accountIDsForGroupsWithLimit(refs, groupIndex, results, func(summary GroupCapacitySummary) bool { + return summary.SessionsMax > 0 + }) + var sessionsMap map[int64]int + if len(sessionAccountIDs) > 0 && s.sessionLimitCache != nil { + sessionsMap, _ = s.sessionLimitCache.GetActiveSessionCountBatch(ctx, sessionAccountIDs, sessionTimeouts) + } + + rpmAccountIDs := accountIDsForGroupsWithLimit(refs, groupIndex, results, func(summary GroupCapacitySummary) bool { + return summary.RPMMax > 0 + }) + var rpmMap map[int64]int + if len(rpmAccountIDs) > 0 && s.rpmCache != nil { + rpmMap, _ = s.rpmCache.GetRPMBatch(ctx, rpmAccountIDs) + } + + for _, ref := range refs { + idx := groupIndex[ref.groupID] + results[idx].ConcurrencyUsed += concurrencyMap[ref.accountID] + if sessionsMap != nil && results[idx].SessionsMax > 0 { + results[idx].SessionsUsed += sessionsMap[ref.accountID] + } + if rpmMap != nil && results[idx].RPMMax > 0 { + results[idx].RPMUsed += rpmMap[ref.accountID] + } + } return results, nil } +func accountIDsForGroupsWithLimit(refs []groupCapacityAccountRef, groupIndex map[int64]int, summaries []GroupCapacitySummary, include func(GroupCapacitySummary) bool) []int64 { + seen := make(map[int64]struct{}) + accountIDs := make([]int64, 0) + for _, ref := range refs { + idx, ok := groupIndex[ref.groupID] + if !ok || !include(summaries[idx]) { + continue + } + if _, ok := seen[ref.accountID]; ok { + continue + } + seen[ref.accountID] = struct{}{} + accountIDs = append(accountIDs, ref.accountID) + } + return accountIDs +} + func (s *GroupCapacityService) getGroupCapacity(ctx context.Context, groupID int64) (GroupCapacitySummary, error) { accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID) if err != nil { diff --git a/backend/internal/service/group_capacity_service_test.go b/backend/internal/service/group_capacity_service_test.go new file mode 100644 index 0000000000..73927307d2 --- /dev/null +++ b/backend/internal/service/group_capacity_service_test.go @@ -0,0 +1,179 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type groupCapacityAccountRepoStub struct { + AccountRepository + rows []GroupAccountCapacityRow + requested []int64 +} + +func (s *groupCapacityAccountRepoStub) ListSchedulableCapacityByGroupIDs(_ context.Context, groupIDs []int64) ([]GroupAccountCapacityRow, error) { + s.requested = append([]int64(nil), groupIDs...) + return append([]GroupAccountCapacityRow(nil), s.rows...), nil +} + +type groupCapacityGroupRepoStub struct { + GroupRepository + groupIDs []int64 + listCalls int +} + +func (s *groupCapacityGroupRepoStub) ListActiveIDs(context.Context) ([]int64, error) { + s.listCalls++ + return append([]int64(nil), s.groupIDs...), nil +} + +type groupCapacityConcurrencyCacheStub struct { + ConcurrencyCache + counts map[int64]int + requested []int64 +} + +func (s *groupCapacityConcurrencyCacheStub) GetAccountConcurrencyBatch(_ context.Context, accountIDs []int64) (map[int64]int, error) { + s.requested = append([]int64(nil), accountIDs...) + out := make(map[int64]int, len(accountIDs)) + for _, id := range accountIDs { + out[id] = s.counts[id] + } + return out, nil +} + +type groupCapacitySessionCacheStub struct { + SessionLimitCache + counts map[int64]int + requested []int64 + idleTimeouts map[int64]time.Duration +} + +func (s *groupCapacitySessionCacheStub) GetActiveSessionCountBatch(_ context.Context, accountIDs []int64, idleTimeouts map[int64]time.Duration) (map[int64]int, error) { + s.requested = append([]int64(nil), accountIDs...) + s.idleTimeouts = make(map[int64]time.Duration, len(idleTimeouts)) + for id, timeout := range idleTimeouts { + s.idleTimeouts[id] = timeout + } + out := make(map[int64]int, len(accountIDs)) + for _, id := range accountIDs { + out[id] = s.counts[id] + } + return out, nil +} + +type groupCapacityRPMCacheStub struct { + RPMCache + counts map[int64]int + requested []int64 +} + +func (s *groupCapacityRPMCacheStub) GetRPMBatch(_ context.Context, accountIDs []int64) (map[int64]int, error) { + s.requested = append([]int64(nil), accountIDs...) + out := make(map[int64]int, len(accountIDs)) + for _, id := range accountIDs { + out[id] = s.counts[id] + } + return out, nil +} + +func TestGetAllGroupCapacityBatchAggregatesRuntimeAndLimits(t *testing.T) { + accountRepo := &groupCapacityAccountRepoStub{ + rows: []GroupAccountCapacityRow{ + { + GroupID: 10, + AccountID: 1, + Concurrency: 2, + Extra: map[string]any{ + "max_sessions": 3, + "session_idle_timeout_minutes": 7, + "base_rpm": 11, + }, + }, + { + GroupID: 20, + AccountID: 1, + Concurrency: 2, + Extra: map[string]any{ + "max_sessions": 3, + "session_idle_timeout_minutes": 7, + "base_rpm": 11, + }, + }, + { + GroupID: 20, + AccountID: 2, + Concurrency: 4, + Extra: map[string]any{ + "max_sessions": 1, + "session_idle_timeout_minutes": 9, + "base_rpm": 13, + }, + }, + }, + } + groupRepo := &groupCapacityGroupRepoStub{groupIDs: []int64{10, 20}} + concurrencyCache := &groupCapacityConcurrencyCacheStub{counts: map[int64]int{1: 1, 2: 2}} + sessionCache := &groupCapacitySessionCacheStub{counts: map[int64]int{1: 2, 2: 1}} + rpmCache := &groupCapacityRPMCacheStub{counts: map[int64]int{1: 5, 2: 7}} + svc := NewGroupCapacityService( + accountRepo, + groupRepo, + NewConcurrencyService(concurrencyCache), + sessionCache, + rpmCache, + ) + + results, err := svc.GetAllGroupCapacity(context.Background()) + require.NoError(t, err) + + require.Equal(t, 1, groupRepo.listCalls) + require.Equal(t, []int64{10, 20}, accountRepo.requested) + require.Equal(t, []int64{1, 2}, concurrencyCache.requested) + require.ElementsMatch(t, []int64{1, 2}, sessionCache.requested) + require.ElementsMatch(t, []int64{1, 2}, rpmCache.requested) + require.Equal(t, 7*time.Minute, sessionCache.idleTimeouts[1]) + require.Equal(t, 9*time.Minute, sessionCache.idleTimeouts[2]) + + require.Equal(t, []GroupCapacitySummary{ + { + GroupID: 10, + ConcurrencyUsed: 1, + ConcurrencyMax: 2, + SessionsUsed: 2, + SessionsMax: 3, + RPMUsed: 5, + RPMMax: 11, + }, + { + GroupID: 20, + ConcurrencyUsed: 3, + ConcurrencyMax: 6, + SessionsUsed: 3, + SessionsMax: 4, + RPMUsed: 12, + RPMMax: 24, + }, + }, results) +} + +func TestGetAllGroupCapacityBatchKeepsEmptyGroupRows(t *testing.T) { + accountRepo := &groupCapacityAccountRepoStub{ + rows: []GroupAccountCapacityRow{ + {GroupID: 20, AccountID: 2, Concurrency: 4}, + }, + } + groupRepo := &groupCapacityGroupRepoStub{groupIDs: []int64{10, 20}} + svc := NewGroupCapacityService(accountRepo, groupRepo, nil, nil, nil) + + results, err := svc.GetAllGroupCapacity(context.Background()) + require.NoError(t, err) + + require.Equal(t, []GroupCapacitySummary{ + {GroupID: 10}, + {GroupID: 20, ConcurrencyMax: 4}, + }, results) +} diff --git a/backend/internal/service/model_rate_limit.go b/backend/internal/service/model_rate_limit.go index 1568a00f78..195f5a1dfd 100644 --- a/backend/internal/service/model_rate_limit.go +++ b/backend/internal/service/model_rate_limit.go @@ -12,6 +12,9 @@ const ( modelRateLimitsKey = "model_rate_limits" antigravityGeminiModelRateLimitKey = "antigravity:gemini" openAIImageGenerationRateLimitKey = "openai:image_generation" + // anthropicFableRateLimitKey 是 Anthropic 7d_oi(Fable 专属 7d 窗口)限流的 + // 家族级 scope:命中后所有 Fable 变体(含 [1m] 等后缀)都不再调度到该账号。 + anthropicFableRateLimitKey = "claude-fable-5" ) // isRateLimitActiveForKey 检查指定 key 的限流是否生效 @@ -82,10 +85,19 @@ func (a *Account) modelRateLimitKeysForRequest(ctx context.Context, requestedMod if openAIImageGenerationRateLimitApplies(ctx, requestedModel, modelKey) && modelKey != openAIImageGenerationRateLimitKey { keys = append(keys, openAIImageGenerationRateLimitKey) } + case PlatformAnthropic: + if isAnthropicFableModel(modelKey) && modelKey != anthropicFableRateLimitKey { + keys = append(keys, anthropicFableRateLimitKey) + } } return keys } +// isAnthropicFableModel 判断是否为 Fable 模型家族(claude-fable-5、claude-fable-5[1m] 等变体) +func isAnthropicFableModel(model string) bool { + return strings.Contains(strings.ToLower(model), "fable") +} + func openAIImageGenerationRateLimitApplies(ctx context.Context, requestedModel, modelKey string) bool { if isOpenAIImageGenerationModel(requestedModel) || isOpenAIImageGenerationModel(modelKey) { return true diff --git a/backend/internal/service/model_rate_limit_test.go b/backend/internal/service/model_rate_limit_test.go index c62e4c4b9b..430d91baf6 100644 --- a/backend/internal/service/model_rate_limit_test.go +++ b/backend/internal/service/model_rate_limit_test.go @@ -499,3 +499,47 @@ func TestGetRateLimitRemainingTime(t *testing.T) { }) } } + +func TestIsModelRateLimited_AnthropicFableFamilyKey(t *testing.T) { + now := time.Now() + future := now.Add(48 * time.Hour).Format(time.RFC3339) + + account := &Account{ + Platform: PlatformAnthropic, + Extra: map[string]any{ + modelRateLimitsKey: map[string]any{ + anthropicFableRateLimitKey: map[string]any{ + "rate_limit_reset_at": future, + }, + }, + }, + } + + tests := []struct { + requestedModel string + expected bool + }{ + {"claude-fable-5", true}, + {"claude-fable-5[1m]", true}, // 家族 key 覆盖变体 + {"Claude-Fable-5-20260601", true}, // 大小写不敏感 + {"claude-sonnet-4-6", false}, // 其他模型不受影响 + {"claude-opus-4-8", false}, + } + + for _, tc := range tests { + t.Run(tc.requestedModel, func(t *testing.T) { + got := account.isModelRateLimitedWithContext(context.Background(), tc.requestedModel) + require.Equal(t, tc.expected, got) + remaining := account.GetModelRateLimitRemainingTimeWithContext(context.Background(), tc.requestedModel) + require.Equal(t, tc.expected, remaining > 0) + }) + } +} + +func TestIsAnthropicFableModel(t *testing.T) { + require.True(t, isAnthropicFableModel("claude-fable-5")) + require.True(t, isAnthropicFableModel("claude-fable-5[1m]")) + require.True(t, isAnthropicFableModel("Claude-Fable-5")) + require.False(t, isAnthropicFableModel("claude-sonnet-4-6")) + require.False(t, isAnthropicFableModel("")) +} diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index ab298d4521..dd65163abc 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -36,8 +36,20 @@ const ( ) type cachedOpenAIAdvancedSchedulerSetting struct { - enabled bool - expiresAt int64 + enabled bool + stickyWeightedEnabled bool + subscriptionPriorityEnabled bool + lbTopKOverride int + weightOverrides map[string]float64 + expiresAt int64 +} + +type openAIAdvancedSchedulerRuntimeSettings struct { + enabled bool + stickyWeightedEnabled bool + subscriptionPriorityEnabled bool + lbTopKOverride int + weightOverrides map[string]float64 } var openAIAdvancedSchedulerSettingCache atomic.Value // *cachedOpenAIAdvancedSchedulerSetting @@ -48,8 +60,12 @@ type OpenAIAccountScheduleRequest struct { Platform string SessionHash string StickyAccountID int64 + StickyPreviousAccountID int64 + StickyWeighted bool + SubscriptionPriority bool PreserveStickyBinding bool PreviousResponseID string + PreviousResponseCanMove bool RequestedModel string RequiredTransport OpenAIUpstreamTransport RequiredCapability OpenAIEndpointCapability @@ -111,6 +127,17 @@ type openAIAccountLoadPlan struct { loadSkew float64 } +type openAIAccountLoadSelectionAttempt struct { + result *AccountSelectionResult + selectionOrder []openAIAccountCandidateScore + candidateCount int + topK int + loadSkew float64 + compactBlocked bool + noCompactCandidates bool + err error +} + func (m *openAIAccountSchedulerMetrics) recordSelect(decision OpenAIAccountScheduleDecision) { if m == nil { return @@ -277,7 +304,8 @@ func (s *defaultOpenAIAccountScheduler) Select( }() previousResponseID := strings.TrimSpace(req.PreviousResponseID) - if previousResponseID != "" && normalizeOpenAICompatiblePlatform(req.Platform) == PlatformOpenAI { + if previousResponseID != "" && normalizeOpenAICompatiblePlatform(req.Platform) == PlatformOpenAI && + (!req.StickyWeighted || !req.PreviousResponseCanMove) { selection, err := s.service.selectAccountByPreviousResponseIDForCapability( ctx, req.GroupID, @@ -310,19 +338,21 @@ func (s *defaultOpenAIAccountScheduler) Select( } } - selection, escapedSticky, err := s.selectBySessionHash(ctx, req) - if err != nil { - return nil, decision, err - } - if selection != nil && selection.Account != nil { - decision.Layer = openAIAccountScheduleLayerSessionSticky - decision.StickySessionHit = true - decision.SelectedAccountID = selection.Account.ID - decision.SelectedAccountType = selection.Account.Type - return selection, decision, nil - } - if escapedSticky { - req.PreserveStickyBinding = true + if !req.StickyWeighted { + selection, escapedSticky, err := s.selectBySessionHash(ctx, req) + if err != nil { + return nil, decision, err + } + if selection != nil && selection.Account != nil { + decision.Layer = openAIAccountScheduleLayerSessionSticky + decision.StickySessionHit = true + decision.SelectedAccountID = selection.Account.ID + decision.SelectedAccountType = selection.Account.Type + return selection, decision, nil + } + if escapedSticky { + req.PreserveStickyBinding = true + } } selection, candidateCount, topK, loadSkew, err := s.selectByLoadBalance(ctx, req) @@ -336,6 +366,14 @@ func (s *defaultOpenAIAccountScheduler) Select( if selection != nil && selection.Account != nil { decision.SelectedAccountID = selection.Account.ID decision.SelectedAccountType = selection.Account.Type + if req.StickyWeighted { + if req.StickyPreviousAccountID > 0 && selection.Account.ID == req.StickyPreviousAccountID { + decision.StickyPreviousHit = true + } + if req.StickyAccountID > 0 && selection.Account.ID == req.StickyAccountID { + decision.StickySessionHit = true + } + } } return selection, decision, nil } @@ -453,6 +491,13 @@ func openAIStickyAccountMatchesGroup(account *Account, groupID *int64) bool { return false } +func openAIAccountSchedulingPriority(account *Account) int { + if account == nil { + return 0 + } + return account.Priority +} + func (s *defaultOpenAIAccountScheduler) shouldEscapeStickyAccount(accountID int64, cfg openAIStickyEscapeConfig) (reason string, errorRate float64, ttft float64, shouldEscape bool) { if !cfg.enabled || s == nil || s.stats == nil || accountID <= 0 { return "", 0, 0, false @@ -471,6 +516,7 @@ type openAIAccountCandidateScore struct { account *Account loadInfo *AccountLoadInfo score float64 + priority int errorRate float64 ttft float64 hasTTFT bool @@ -669,6 +715,7 @@ func buildOpenAIWeightedSelectionOrder( } func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan( + ctx context.Context, req OpenAIAccountScheduleRequest, filtered []*Account, loadMap map[int64]*AccountLoadInfo, @@ -716,18 +763,20 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan( return plan } - minPriority, maxPriority := candidates[0].account.Priority, candidates[0].account.Priority + minPriority, maxPriority := openAIAccountSchedulingPriority(candidates[0].account), openAIAccountSchedulingPriority(candidates[0].account) maxWaiting := 1 loadRateSum := 0.0 loadRateSumSquares := 0.0 minTTFT, maxTTFT := 0.0, 0.0 hasTTFTSample := false - for _, candidate := range candidates { - if candidate.account.Priority < minPriority { - minPriority = candidate.account.Priority + for i := range candidates { + candidate := &candidates[i] + candidate.priority = openAIAccountSchedulingPriority(candidate.account) + if candidate.priority < minPriority { + minPriority = candidate.priority } - if candidate.account.Priority > maxPriority { - maxPriority = candidate.account.Priority + if candidate.priority > maxPriority { + maxPriority = candidate.priority } if candidate.loadInfo.WaitingCount > maxWaiting { maxWaiting = candidate.loadInfo.WaitingCount @@ -751,7 +800,7 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan( } plan.loadSkew = calcLoadSkewByMoments(loadRateSum, loadRateSumSquares, len(candidates)) - weights := s.service.openAIWSSchedulerWeights() + weights := s.service.openAIWSSchedulerWeightsForRequest(ctx) // Reset 因子(use-it-or-lose-it):在拥有「未来会话窗口结束时间」的账号中, // 剩余时间越短 → 因子越接近 1(越早重置越优先用尽)。无活跃窗口的账号因子为 0。 @@ -785,7 +834,7 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan( item := &candidates[i] priorityFactor := 1.0 if maxPriority > minPriority { - priorityFactor = 1 - float64(item.account.Priority-minPriority)/float64(maxPriority-minPriority) + priorityFactor = 1 - float64(item.priority-minPriority)/float64(maxPriority-minPriority) } loadFactor := 1 - clamp01(float64(item.loadInfo.LoadRate)/100.0) queueFactor := 1 - clamp01(float64(item.loadInfo.WaitingCount)/float64(maxWaiting)) @@ -817,10 +866,18 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan( weights.TTFT*ttftFactor + weights.Reset*resetFactor + weights.QuotaHeadroom*quotaHeadroomFactor + if req.StickyWeighted { + if req.PreviousResponseCanMove && req.StickyPreviousAccountID > 0 && item.account.ID == req.StickyPreviousAccountID { + item.score += weights.Previous + } + if req.StickyAccountID > 0 && item.account.ID == req.StickyAccountID { + item.score += weights.SessionSticky + } + } } plan.candidates = candidates - plan.topK = s.service.openAIWSLBTopK() + plan.topK = s.service.openAIWSLBTopKForRequest(ctx) if plan.topK > len(candidates) { plan.topK = len(candidates) } @@ -845,6 +902,20 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAISelectionOrder( groupTopK = len(pool) } ranked := selectTopKOpenAICandidates(pool, groupTopK) + if req.StickyWeighted { + for _, stickyID := range []int64{req.StickyPreviousAccountID, req.StickyAccountID} { + if stickyID <= 0 { + continue + } + for i, candidate := range ranked { + if candidate.account != nil && candidate.account.ID == stickyID { + ordered := append([]openAIAccountCandidateScore{candidate}, ranked[:i]...) + ordered = append(ordered, ranked[i+1:]...) + return ordered + } + } + } + } return buildOpenAIWeightedSelectionOrder(ranked, req) } @@ -939,6 +1010,66 @@ func (s *defaultOpenAIAccountScheduler) tryAcquireOpenAISelectionOrder( return nil, compactBlocked, nil } +func (s *defaultOpenAIAccountScheduler) tryFallbackToWeightedSticky( + ctx context.Context, + req OpenAIAccountScheduleRequest, +) (*AccountSelectionResult, error) { + if !req.StickyWeighted { + return nil, nil + } + for _, accountID := range []int64{req.StickyPreviousAccountID, req.StickyAccountID} { + if accountID <= 0 { + continue + } + if req.ExcludedIDs != nil { + if _, excluded := req.ExcludedIDs[accountID]; excluded { + continue + } + } + account, err := s.service.getSchedulableAccount(ctx, accountID) + if err != nil || account == nil { + continue + } + if !s.isAccountRequestCompatible(ctx, account, req) || !s.isAccountTransportCompatible(account, req.RequiredTransport) { + continue + } + account = s.service.recheckSelectedOpenAIAccountFromDB(ctx, account, req.Platform, req.RequestedModel, req.RequireCompact, req.RequiredCapability) + if account == nil || !s.isAccountRequestCompatible(ctx, account, req) || !s.isAccountTransportCompatible(account, req.RequiredTransport) { + continue + } + if req.RequireCompact && openAICompactSupportTier(account) == 0 { + continue + } + result, acquireErr := s.service.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency) + if acquireErr != nil { + return nil, acquireErr + } + if result != nil && result.Acquired { + if req.SessionHash != "" && !req.PreserveStickyBinding { + _ = s.service.BindStickySession(ctx, req.GroupID, req.SessionHash, account.ID) + } + return &AccountSelectionResult{ + Account: account, + Acquired: true, + ReleaseFunc: result.ReleaseFunc, + }, nil + } + if s.service.concurrencyService != nil { + cfg := s.service.schedulingConfig() + return &AccountSelectionResult{ + Account: account, + WaitPlan: &AccountWaitPlan{ + AccountID: account.ID, + MaxConcurrency: account.Concurrency, + Timeout: cfg.StickySessionWaitTimeout, + MaxWaiting: cfg.StickySessionMaxWaiting, + }, + }, nil + } + } + return nil, nil +} + func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( ctx context.Context, req OpenAIAccountScheduleRequest, @@ -1002,52 +1133,159 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( } } - plan := s.buildOpenAIAccountLoadPlan(req, filtered, loadMap) - candidateCount := plan.candidateCount - topK := plan.topK - loadSkew := plan.loadSkew - selectionOrder := plan.selectionOrder - if req.RequireCompact && len(plan.candidates) == 0 && len(plan.staleSnapshotCompactRetry) == 0 { - return nil, 0, 0, 0, ErrNoAvailableCompactAccounts - } - if req.RequireCompact && len(selectionOrder) == 0 && s.service.schedulerSnapshot == nil { - return nil, candidateCount, topK, loadSkew, ErrNoAvailableCompactAccounts - } - if len(selectionOrder) == 0 { - return nil, candidateCount, topK, loadSkew, noAvailableOpenAISelectionError(req.RequestedModel, req.RequireCompact && len(plan.allCandidates) > 0) + if req.SubscriptionPriority { + subscriptionAccounts, regularAccounts := partitionOpenAIChatGPTSubscriptionAccounts(filtered) + if len(subscriptionAccounts) > 0 { + attempt := s.trySelectByLoadBalancePool(ctx, req, subscriptionAccounts, loadMap) + if attempt.err != nil && (!attempt.noCompactCandidates || len(regularAccounts) <= 0) { + return nil, attempt.candidateCount, attempt.topK, attempt.loadSkew, attempt.err + } + if attempt.result != nil { + return attempt.result, attempt.candidateCount, attempt.topK, attempt.loadSkew, nil + } + if len(regularAccounts) > 0 { + regularAttempt := s.trySelectByLoadBalancePool(ctx, req, regularAccounts, loadMap) + if regularAttempt.err != nil { + return nil, regularAttempt.candidateCount, regularAttempt.topK, regularAttempt.loadSkew, regularAttempt.err + } + if regularAttempt.result != nil { + return regularAttempt.result, regularAttempt.candidateCount, regularAttempt.topK, regularAttempt.loadSkew, nil + } + return s.finishLoadBalanceSelectionFallback(ctx, req, regularAttempt) + } + return s.finishLoadBalanceSelectionFallback(ctx, req, attempt) + } } - result, compactBlocked, acquireErr := s.tryAcquireOpenAISelectionOrder(ctx, req, selectionOrder) + attempt := s.trySelectByLoadBalancePool(ctx, req, filtered, loadMap) + if attempt.err != nil { + return nil, attempt.candidateCount, attempt.topK, attempt.loadSkew, attempt.err + } + if attempt.result != nil { + return attempt.result, attempt.candidateCount, attempt.topK, attempt.loadSkew, nil + } + return s.finishLoadBalanceSelectionFallback(ctx, req, attempt) +} + +func partitionOpenAIChatGPTSubscriptionAccounts(accounts []*Account) ([]*Account, []*Account) { + subscriptionAccounts := make([]*Account, 0, len(accounts)) + regularAccounts := make([]*Account, 0, len(accounts)) + for _, account := range accounts { + if account != nil && account.IsOpenAIChatGPTSubscription() { + subscriptionAccounts = append(subscriptionAccounts, account) + continue + } + regularAccounts = append(regularAccounts, account) + } + return subscriptionAccounts, regularAccounts +} + +func (s *defaultOpenAIAccountScheduler) trySelectByLoadBalancePool( + ctx context.Context, + req OpenAIAccountScheduleRequest, + filtered []*Account, + loadMap map[int64]*AccountLoadInfo, +) openAIAccountLoadSelectionAttempt { + plan := s.buildOpenAIAccountLoadPlan(ctx, req, filtered, loadMap) + attempt := openAIAccountLoadSelectionAttempt{ + selectionOrder: plan.selectionOrder, + candidateCount: plan.candidateCount, + topK: plan.topK, + loadSkew: plan.loadSkew, + } + if req.RequireCompact && len(plan.candidates) == 0 && len(plan.staleSnapshotCompactRetry) == 0 { + attempt.noCompactCandidates = true + attempt.err = ErrNoAvailableCompactAccounts + return attempt + } + if req.RequireCompact && len(attempt.selectionOrder) == 0 && s.service.schedulerSnapshot == nil { + attempt.noCompactCandidates = true + attempt.err = ErrNoAvailableCompactAccounts + return attempt + } + if len(attempt.selectionOrder) == 0 { + attempt.compactBlocked = req.RequireCompact && len(plan.allCandidates) > 0 + return attempt + } + + result, compactBlocked, acquireErr := s.tryAcquireOpenAISelectionOrder(ctx, req, attempt.selectionOrder) + attempt.compactBlocked = compactBlocked if acquireErr != nil { - return nil, candidateCount, topK, loadSkew, acquireErr + attempt.err = acquireErr + return attempt } if result != nil { - return result, candidateCount, topK, loadSkew, nil + attempt.result = result + return attempt } if s.service.concurrencyService != nil { + loadReq := buildOpenAIAccountLoadRequest(filtered) if freshLoadMap, loadErr := s.service.concurrencyService.GetAccountsLoadBatchFresh(ctx, loadReq); loadErr == nil { - freshPlan := s.buildOpenAIAccountLoadPlan(req, filtered, freshLoadMap) + freshPlan := s.buildOpenAIAccountLoadPlan(ctx, req, filtered, freshLoadMap) if len(freshPlan.selectionOrder) > 0 { freshResult, freshCompactBlocked, freshAcquireErr := s.tryAcquireOpenAISelectionOrder(ctx, req, freshPlan.selectionOrder) if freshAcquireErr != nil { - return nil, candidateCount, topK, loadSkew, freshAcquireErr + attempt.err = freshAcquireErr + return attempt } if freshResult != nil { - return freshResult, freshPlan.candidateCount, freshPlan.topK, freshPlan.loadSkew, nil + attempt.result = freshResult + attempt.selectionOrder = freshPlan.selectionOrder + attempt.candidateCount = freshPlan.candidateCount + attempt.topK = freshPlan.topK + attempt.loadSkew = freshPlan.loadSkew + return attempt } - compactBlocked = compactBlocked || freshCompactBlocked - selectionOrder = freshPlan.selectionOrder - candidateCount = freshPlan.candidateCount - topK = freshPlan.topK - loadSkew = freshPlan.loadSkew + attempt.compactBlocked = attempt.compactBlocked || freshCompactBlocked + attempt.selectionOrder = freshPlan.selectionOrder + attempt.candidateCount = freshPlan.candidateCount + attempt.topK = freshPlan.topK + attempt.loadSkew = freshPlan.loadSkew } } } + return attempt +} + +func buildOpenAIAccountLoadRequest(accounts []*Account) []AccountWithConcurrency { + loadReq := make([]AccountWithConcurrency, 0, len(accounts)) + for _, account := range accounts { + if account == nil { + continue + } + loadReq = append(loadReq, AccountWithConcurrency{ + ID: account.ID, + MaxConcurrency: account.EffectiveLoadFactor(), + }) + } + return loadReq +} + +func (s *defaultOpenAIAccountScheduler) finishLoadBalanceSelectionFallback( + ctx context.Context, + req OpenAIAccountScheduleRequest, + attempt openAIAccountLoadSelectionAttempt, +) (*AccountSelectionResult, int, int, float64, error) { + candidateCount := attempt.candidateCount + topK := attempt.topK + loadSkew := attempt.loadSkew + + if len(attempt.selectionOrder) == 0 { + return nil, candidateCount, topK, loadSkew, noAvailableOpenAISelectionError(req.RequestedModel, attempt.compactBlocked) + } + + if stickyFallback, stickyErr := s.tryFallbackToWeightedSticky(ctx, req); stickyErr != nil { + return nil, candidateCount, topK, loadSkew, stickyErr + } else if stickyFallback != nil { + return stickyFallback, candidateCount, topK, loadSkew, nil + } + cfg := s.service.schedulingConfig() + compactBlocked := attempt.compactBlocked // WaitPlan.MaxConcurrency 使用 Concurrency(非 EffectiveLoadFactor),因为 WaitPlan 控制的是 Redis 实际并发槽位等待。 - for _, candidate := range selectionOrder { + for _, candidate := range attempt.selectionOrder { fresh := s.service.resolveFreshSchedulableOpenAIAccount(ctx, candidate.account, req.Platform, req.RequestedModel, false, req.RequiredCapability) if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) { continue @@ -1184,40 +1422,164 @@ func (s *OpenAIGatewayService) openAIAdvancedSchedulerSettingRepo() SettingRepos return s.rateLimitService.settingService.settingRepo } -func (s *OpenAIGatewayService) isOpenAIAdvancedSchedulerEnabled(ctx context.Context) bool { +func (s *OpenAIGatewayService) openAIAdvancedSchedulerRuntimeSettings(ctx context.Context) openAIAdvancedSchedulerRuntimeSettings { if cached, ok := openAIAdvancedSchedulerSettingCache.Load().(*cachedOpenAIAdvancedSchedulerSetting); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { - return cached.enabled + return openAIAdvancedSchedulerRuntimeSettings{ + enabled: cached.enabled, + stickyWeightedEnabled: cached.stickyWeightedEnabled, + subscriptionPriorityEnabled: cached.subscriptionPriorityEnabled, + lbTopKOverride: cached.lbTopKOverride, + weightOverrides: cloneOpenAIAdvancedSchedulerWeightOverrides(cached.weightOverrides), + } } } result, _, _ := openAIAdvancedSchedulerSettingSF.Do(openAIAdvancedSchedulerSettingKey, func() (any, error) { if cached, ok := openAIAdvancedSchedulerSettingCache.Load().(*cachedOpenAIAdvancedSchedulerSetting); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { - return cached.enabled, nil + return openAIAdvancedSchedulerRuntimeSettings{ + enabled: cached.enabled, + stickyWeightedEnabled: cached.stickyWeightedEnabled, + subscriptionPriorityEnabled: cached.subscriptionPriorityEnabled, + lbTopKOverride: cached.lbTopKOverride, + weightOverrides: cloneOpenAIAdvancedSchedulerWeightOverrides(cached.weightOverrides), + }, nil } } enabled := false + stickyWeightedEnabled := false + subscriptionPriorityEnabled := false + lbTopKOverride := 0 + weightOverrides := map[string]float64{} if repo := s.openAIAdvancedSchedulerSettingRepo(); repo != nil { dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIAdvancedSchedulerSettingDBTimeout) defer cancel() - value, err := repo.GetValue(dbCtx, openAIAdvancedSchedulerSettingKey) - if err == nil { - enabled = strings.EqualFold(strings.TrimSpace(value), "true") + if values, err := repo.GetMultiple(dbCtx, openAIAdvancedSchedulerRuntimeSettingKeys()); err == nil { + enabled = strings.EqualFold(strings.TrimSpace(values[openAIAdvancedSchedulerSettingKey]), "true") + stickyWeightedEnabled = strings.EqualFold(strings.TrimSpace(values[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled]), "true") + subscriptionPriorityEnabled = strings.EqualFold(strings.TrimSpace(values[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled]), "true") + lbTopKOverride = parsePositiveIntOverride(values[SettingKeyOpenAIAdvancedSchedulerLBTopK]) + weightOverrides = parseOpenAIAdvancedSchedulerWeightOverrides(values) + } else { + if value, err := repo.GetValue(dbCtx, openAIAdvancedSchedulerSettingKey); err == nil { + enabled = strings.EqualFold(strings.TrimSpace(value), "true") + } + if value, err := repo.GetValue(dbCtx, SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled); err == nil { + stickyWeightedEnabled = strings.EqualFold(strings.TrimSpace(value), "true") + } + if value, err := repo.GetValue(dbCtx, SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled); err == nil { + subscriptionPriorityEnabled = strings.EqualFold(strings.TrimSpace(value), "true") + } } } openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ - enabled: enabled, - expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(), + enabled: enabled, + stickyWeightedEnabled: stickyWeightedEnabled, + subscriptionPriorityEnabled: subscriptionPriorityEnabled, + lbTopKOverride: lbTopKOverride, + weightOverrides: cloneOpenAIAdvancedSchedulerWeightOverrides(weightOverrides), + expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(), }) - return enabled, nil + return openAIAdvancedSchedulerRuntimeSettings{ + enabled: enabled, + stickyWeightedEnabled: stickyWeightedEnabled, + subscriptionPriorityEnabled: subscriptionPriorityEnabled, + lbTopKOverride: lbTopKOverride, + weightOverrides: weightOverrides, + }, nil }) - enabled, _ := result.(bool) - return enabled + settings, _ := result.(openAIAdvancedSchedulerRuntimeSettings) + return settings +} + +func (s *OpenAIGatewayService) isOpenAIAdvancedSchedulerEnabled(ctx context.Context) bool { + return s.openAIAdvancedSchedulerRuntimeSettings(ctx).enabled +} + +func (s *OpenAIGatewayService) isOpenAIAdvancedSchedulerStickyWeightedEnabled(ctx context.Context) bool { + settings := s.openAIAdvancedSchedulerRuntimeSettings(ctx) + return settings.enabled && settings.stickyWeightedEnabled +} + +func (s *OpenAIGatewayService) isOpenAIAdvancedSchedulerSubscriptionPriorityEnabled(ctx context.Context) bool { + settings := s.openAIAdvancedSchedulerRuntimeSettings(ctx) + return settings.enabled && settings.subscriptionPriorityEnabled +} + +func openAIAdvancedSchedulerRuntimeSettingKeys() []string { + keys := []string{ + openAIAdvancedSchedulerSettingKey, + SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled, + SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled, + SettingKeyOpenAIAdvancedSchedulerLBTopK, + } + for _, spec := range openAIAdvancedSchedulerWeightOverrideSpecs() { + keys = append(keys, spec.key) + } + return keys +} + +type openAIAdvancedSchedulerWeightOverrideSpec struct { + key string + name string +} + +func openAIAdvancedSchedulerWeightOverrideSpecs() []openAIAdvancedSchedulerWeightOverrideSpec { + return []openAIAdvancedSchedulerWeightOverrideSpec{ + {key: SettingKeyOpenAIAdvancedSchedulerWeightPriority, name: "priority"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightLoad, name: "load"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightQueue, name: "queue"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightErrorRate, name: "error_rate"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightTTFT, name: "ttft"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightReset, name: "reset"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom, name: "quota_headroom"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse, name: "previous_response"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky, name: "session_sticky"}, + } +} + +func parsePositiveIntOverride(raw string) int { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return 0 + } + return value +} + +func parseOpenAIAdvancedSchedulerWeightOverrides(values map[string]string) map[string]float64 { + overrides := map[string]float64{} + for _, spec := range openAIAdvancedSchedulerWeightOverrideSpecs() { + raw := strings.TrimSpace(values[spec.key]) + if raw == "" { + continue + } + value, err := strconv.ParseFloat(raw, 64) + if err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { + continue + } + overrides[spec.name] = value + } + return overrides +} + +func cloneOpenAIAdvancedSchedulerWeightOverrides(in map[string]float64) map[string]float64 { + if len(in) == 0 { + return nil + } + out := make(map[string]float64, len(in)) + for key, value := range in { + out[key] = value + } + return out } func (s *OpenAIGatewayService) getOpenAIAccountScheduler(ctx context.Context) OpenAIAccountScheduler { @@ -1253,7 +1615,7 @@ func (s *OpenAIGatewayService) SelectAccountWithScheduler( requiredTransport OpenAIUpstreamTransport, requireCompact bool, ) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { - return s.selectAccountWithScheduler(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, "", "", requireCompact, PlatformOpenAI) + return s.selectAccountWithScheduler(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, "", "", requireCompact, PlatformOpenAI, false) } func (s *OpenAIGatewayService) SelectAccountWithSchedulerForCapability( @@ -1269,10 +1631,14 @@ func (s *OpenAIGatewayService) SelectAccountWithSchedulerForCapability( platformOverride ...string, ) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { platform := PlatformOpenAI + previousResponseCanMove := false if len(platformOverride) > 0 { platform = platformOverride[0] } - return s.selectAccountWithScheduler(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, requiredCapability, "", requireCompact, platform) + if len(platformOverride) > 1 { + previousResponseCanMove = strings.EqualFold(platformOverride[1], "previous_response_can_move") + } + return s.selectAccountWithScheduler(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, requiredCapability, "", requireCompact, platform, previousResponseCanMove) } func (s *OpenAIGatewayService) SelectAccountWithSchedulerForImages( @@ -1283,13 +1649,13 @@ func (s *OpenAIGatewayService) SelectAccountWithSchedulerForImages( excludedIDs map[int64]struct{}, requiredCapability OpenAIImagesCapability, ) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { - selection, decision, err := s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, "", requiredCapability, false, PlatformOpenAI) + selection, decision, err := s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, "", requiredCapability, false, PlatformOpenAI, false) if err == nil && selection != nil && selection.Account != nil { return selection, decision, nil } // 如果要求 native 能力(如指定了模型)但没有可用的 APIKey 账号,回退到 basic(OAuth 账号) if requiredCapability == OpenAIImagesCapabilityNative { - return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, "", OpenAIImagesCapabilityBasic, false, PlatformOpenAI) + return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, "", OpenAIImagesCapabilityBasic, false, PlatformOpenAI, false) } return selection, decision, err } @@ -1306,6 +1672,7 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler( requiredImageCapability OpenAIImagesCapability, requireCompact bool, platform string, + previousResponseCanMove bool, ) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { ctx = s.withOpenAIQuotaAutoPauseContext(ctx) platform = normalizeOpenAICompatiblePlatform(platform) @@ -1378,13 +1745,23 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler( stickyAccountID = accountID } } + stickyWeighted := s.isOpenAIAdvancedSchedulerStickyWeightedEnabled(ctx) + subscriptionPriority := s.isOpenAIAdvancedSchedulerSubscriptionPriorityEnabled(ctx) + stickyPreviousAccountID := int64(0) + if stickyWeighted && previousResponseCanMove && strings.TrimSpace(previousResponseID) != "" && platform == PlatformOpenAI { + stickyPreviousAccountID = s.ResolveAccountIDByPreviousResponseIDForScheduler(ctx, groupID, previousResponseID, requestedModel, excludedIDs, requiredCapability, requireCompact) + } return scheduler.Select(ctx, OpenAIAccountScheduleRequest{ GroupID: groupID, Platform: platform, SessionHash: sessionHash, StickyAccountID: stickyAccountID, + StickyPreviousAccountID: stickyPreviousAccountID, + StickyWeighted: stickyWeighted, + SubscriptionPriority: subscriptionPriority, PreviousResponseID: previousResponseID, + PreviousResponseCanMove: previousResponseCanMove, RequestedModel: requestedModel, RequiredTransport: requiredTransport, RequiredCapability: requiredCapability, @@ -1473,6 +1850,15 @@ func (s *OpenAIGatewayService) openAIWSLBTopK() int { return 7 } +func (s *OpenAIGatewayService) openAIWSLBTopKForRequest(ctx context.Context) int { + base := s.openAIWSLBTopK() + settings := s.openAIAdvancedSchedulerRuntimeSettings(ctx) + if settings.lbTopKOverride > 0 { + return settings.lbTopKOverride + } + return base +} + func (s *OpenAIGatewayService) openAIStickyEscapeConfig() openAIStickyEscapeConfig { if s != nil && s.cfg != nil { cfg := s.cfg.Gateway.OpenAIScheduler @@ -1514,6 +1900,8 @@ func (s *OpenAIGatewayService) openAIWSSchedulerWeights() GatewayOpenAIWSSchedul TTFT: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT, Reset: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Reset, QuotaHeadroom: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom, + Previous: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse, + SessionSticky: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.SessionSticky, } } return GatewayOpenAIWSSchedulerScoreWeightsView{ @@ -1524,9 +1912,46 @@ func (s *OpenAIGatewayService) openAIWSSchedulerWeights() GatewayOpenAIWSSchedul TTFT: 0.5, Reset: 0.0, QuotaHeadroom: 0.0, + Previous: 5.0, + SessionSticky: 3.0, } } +func (s *OpenAIGatewayService) openAIWSSchedulerWeightsForRequest(ctx context.Context) GatewayOpenAIWSSchedulerScoreWeightsView { + weights := s.openAIWSSchedulerWeights() + settings := s.openAIAdvancedSchedulerRuntimeSettings(ctx) + return applyOpenAIAdvancedSchedulerWeightOverrides(weights, settings.weightOverrides) +} + +func applyOpenAIAdvancedSchedulerWeightOverrides( + weights GatewayOpenAIWSSchedulerScoreWeightsView, + overrides map[string]float64, +) GatewayOpenAIWSSchedulerScoreWeightsView { + for key, value := range overrides { + switch key { + case "priority": + weights.Priority = value + case "load": + weights.Load = value + case "queue": + weights.Queue = value + case "error_rate": + weights.ErrorRate = value + case "ttft": + weights.TTFT = value + case "reset": + weights.Reset = value + case "quota_headroom": + weights.QuotaHeadroom = value + case "previous_response": + weights.Previous = value + case "session_sticky": + weights.SessionSticky = value + } + } + return weights +} + type GatewayOpenAIWSSchedulerScoreWeightsView struct { Priority float64 Load float64 @@ -1536,6 +1961,149 @@ type GatewayOpenAIWSSchedulerScoreWeightsView struct { // Reset 倾向「会话窗口最早重置」的账号;0 表示关闭(默认)。 Reset float64 QuotaHeadroom float64 + Previous float64 + SessionSticky float64 +} + +type OpenAIAccountSchedulerScoreSnapshot struct { + BaseScore float64 + StickyScore float64 + StickyScoreInfinity bool + StickyWeightedEnabled bool +} + +func (s *RateLimitService) BuildOpenAIAccountSchedulerScoreSnapshot( + ctx context.Context, + accounts []*Account, + loadMap map[int64]*AccountLoadInfo, +) map[int64]OpenAIAccountSchedulerScoreSnapshot { + gateway := &OpenAIGatewayService{cfg: nil, rateLimitService: s} + if s != nil { + gateway.cfg = s.cfg + } + return buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, gateway.openAIWSSchedulerWeightsForRequest(ctx), gateway.isOpenAIAdvancedSchedulerStickyWeightedEnabled(ctx)) +} + +func BuildOpenAIAccountSchedulerScoreSnapshot( + accounts []*Account, + loadMap map[int64]*AccountLoadInfo, +) map[int64]OpenAIAccountSchedulerScoreSnapshot { + gateway := &OpenAIGatewayService{} + return buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, gateway.openAIWSSchedulerWeights(), false) +} + +func buildOpenAIAccountSchedulerScoreSnapshot( + accounts []*Account, + loadMap map[int64]*AccountLoadInfo, + weights GatewayOpenAIWSSchedulerScoreWeightsView, + stickyWeightedEnabled bool, +) map[int64]OpenAIAccountSchedulerScoreSnapshot { + if len(accounts) == 0 { + return nil + } + candidates := make([]openAIAccountCandidateScore, 0, len(accounts)) + for _, account := range accounts { + if account == nil { + continue + } + loadInfo := loadMap[account.ID] + if loadInfo == nil { + loadInfo = &AccountLoadInfo{AccountID: account.ID} + } + candidates = append(candidates, openAIAccountCandidateScore{ + account: account, + loadInfo: loadInfo, + errorRate: 0, + ttft: 0, + hasTTFT: false, + }) + } + if len(candidates) == 0 { + return nil + } + + minPriority, maxPriority := openAIAccountSchedulingPriority(candidates[0].account), openAIAccountSchedulingPriority(candidates[0].account) + maxWaiting := 1 + for i := range candidates { + candidate := &candidates[i] + candidate.priority = openAIAccountSchedulingPriority(candidate.account) + if candidate.priority < minPriority { + minPriority = candidate.priority + } + if candidate.priority > maxPriority { + maxPriority = candidate.priority + } + if candidate.loadInfo.WaitingCount > maxWaiting { + maxWaiting = candidate.loadInfo.WaitingCount + } + } + + minResetRemaining, maxResetRemaining := 0.0, 0.0 + hasResetSample := false + now := time.Now() + if weights.Reset > 0 { + for _, candidate := range candidates { + end := candidate.account.SessionWindowEnd + if end == nil || !now.Before(*end) { + continue + } + remaining := end.Sub(now).Seconds() + if !hasResetSample { + minResetRemaining, maxResetRemaining = remaining, remaining + hasResetSample = true + continue + } + if remaining < minResetRemaining { + minResetRemaining = remaining + } + if remaining > maxResetRemaining { + maxResetRemaining = remaining + } + } + } + + result := make(map[int64]OpenAIAccountSchedulerScoreSnapshot, len(candidates)) + for _, candidate := range candidates { + priorityFactor := 1.0 + if maxPriority > minPriority { + priorityFactor = 1 - float64(candidate.priority-minPriority)/float64(maxPriority-minPriority) + } + loadFactor := 1 - clamp01(float64(candidate.loadInfo.LoadRate)/100.0) + queueFactor := 1 - clamp01(float64(candidate.loadInfo.WaitingCount)/float64(maxWaiting)) + errorFactor := 1.0 + ttftFactor := 0.5 + resetFactor := 0.0 + if weights.Reset > 0 && hasResetSample { + if end := candidate.account.SessionWindowEnd; end != nil && now.Before(*end) { + if maxResetRemaining > minResetRemaining { + resetFactor = 1 - clamp01((end.Sub(now).Seconds()-minResetRemaining)/(maxResetRemaining-minResetRemaining)) + } else { + resetFactor = 1 + } + } + } + quotaHeadroomFactor := 0.0 + if weights.QuotaHeadroom > 0 { + quotaHeadroomFactor = openAIQuotaHeadroomFactor(candidate.account, now) + } + baseScore := weights.Priority*priorityFactor + + weights.Load*loadFactor + + weights.Queue*queueFactor + + weights.ErrorRate*errorFactor + + weights.TTFT*ttftFactor + + weights.Reset*resetFactor + + weights.QuotaHeadroom*quotaHeadroomFactor + score := OpenAIAccountSchedulerScoreSnapshot{ + BaseScore: baseScore, + StickyWeightedEnabled: stickyWeightedEnabled, + StickyScoreInfinity: !stickyWeightedEnabled, + } + if stickyWeightedEnabled { + score.StickyScore = baseScore + weights.Previous + weights.SessionSticky + } + result[candidate.account.ID] = score + } + return result } func openAIQuotaHeadroomFactor(account *Account, now time.Time) float64 { diff --git a/backend/internal/service/openai_account_scheduler_reset_test.go b/backend/internal/service/openai_account_scheduler_reset_test.go index 50155f20d6..f1270c8caa 100644 --- a/backend/internal/service/openai_account_scheduler_reset_test.go +++ b/backend/internal/service/openai_account_scheduler_reset_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "testing" "time" @@ -49,7 +50,7 @@ func TestBuildOpenAIAccountLoadPlan_ResetWeightPrefersSoonestReset(t *testing.T) } sched := openAIResetTestScheduler(5.0) - plan := sched.buildOpenAIAccountLoadPlan(OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) + plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) scores := openAIPlanScores(plan) require.Greater(t, scores[2], scores[1], "重置时间最早的账号(ID=2)得分更高") } @@ -65,7 +66,7 @@ func TestBuildOpenAIAccountLoadPlan_ResetWeightZeroNoEffect(t *testing.T) { } sched := openAIResetTestScheduler(0.0) - plan := sched.buildOpenAIAccountLoadPlan(OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) + plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) scores := openAIPlanScores(plan) require.Equal(t, scores[1], scores[2], "Reset 权重为 0 时两账号得分相同") } @@ -80,7 +81,7 @@ func TestBuildOpenAIAccountLoadPlan_ResetWeightIgnoresNilWindow(t *testing.T) { } sched := openAIResetTestScheduler(5.0) - plan := sched.buildOpenAIAccountLoadPlan(OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) + plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) scores := openAIPlanScores(plan) require.Greater(t, scores[2], scores[1], "拥有活跃窗口的账号得分高于无窗口账号") } @@ -161,7 +162,7 @@ func TestBuildOpenAIAccountLoadPlan_QuotaHeadroomPrefersHigher7dRemaining(t *tes } sched := openAIQuotaHeadroomTestScheduler(1.0) - plan := sched.buildOpenAIAccountLoadPlan(OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) + plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) scores := openAIPlanScores(plan) require.Greater(t, scores[2], scores[1], "7d 剩余额度更高的账号得分应更高") } @@ -190,7 +191,7 @@ func TestBuildOpenAIAccountLoadPlan_QuotaHeadroomZeroNoEffect(t *testing.T) { } sched := openAIResetTestScheduler(0) - plan := sched.buildOpenAIAccountLoadPlan(OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) + plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) scores := openAIPlanScores(plan) require.Equal(t, scores[1], scores[2], "quota_headroom 权重为 0 时不应影响打分") } diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 0255dbbde3..a61a923053 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -183,6 +183,17 @@ func newSchedulerTestOpenAIWSV2Config() *config.Config { return cfg } +func newSchedulerTestSubscriptionPriorityConfig() *config.Config { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.LBTopK = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0 + return cfg +} + type openAIAdvancedSchedulerSettingRepoStub struct { values map[string]string } @@ -210,8 +221,14 @@ func (s *openAIAdvancedSchedulerSettingRepoStub) Set(context.Context, string, st panic("unexpected call to Set") } -func (s *openAIAdvancedSchedulerSettingRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) { - panic("unexpected call to GetMultiple") +func (s *openAIAdvancedSchedulerSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + result := make(map[string]string, len(keys)) + for _, key := range keys { + if value, err := s.GetValue(context.Background(), key); err == nil { + result[key] = value + } + } + return result, nil } func (s *openAIAdvancedSchedulerSettingRepoStub) SetMultiple(context.Context, map[string]string) error { @@ -226,7 +243,7 @@ func (s *openAIAdvancedSchedulerSettingRepoStub) Delete(context.Context, string) panic("unexpected call to Delete") } -func newOpenAIAdvancedSchedulerRateLimitService(enabled string) *RateLimitService { +func newOpenAIAdvancedSchedulerRateLimitService(enabled string, values ...string) *RateLimitService { resetOpenAIAdvancedSchedulerSettingCacheForTest() repo := &openAIAdvancedSchedulerSettingRepoStub{ values: map[string]string{}, @@ -234,6 +251,12 @@ func newOpenAIAdvancedSchedulerRateLimitService(enabled string) *RateLimitServic if enabled != "" { repo.values[openAIAdvancedSchedulerSettingKey] = enabled } + if len(values) > 0 && values[0] != "" { + repo.values[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] = values[0] + } + if len(values) > 1 && values[1] != "" { + repo.values[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] = values[1] + } return &RateLimitService{ settingService: NewSettingService(repo, &config.Config{}), } @@ -266,6 +289,45 @@ func (s *openAISnapshotCacheStub) GetAccount(ctx context.Context, accountID int6 return &cloned, nil } +func TestOpenAIGatewayService_OpenAIAdvancedSchedulerRuntimeSettings_DBOverridesConfig(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + defer resetOpenAIAdvancedSchedulerSettingCacheForTest() + + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.LBTopK = 11 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights = config.GatewayOpenAIWSSchedulerScoreWeights{ + Priority: 1, + Load: 2, + Queue: 3, + ErrorRate: 4, + TTFT: 5, + Reset: 6, + QuotaHeadroom: 7, + PreviousResponse: 8, + SessionSticky: 9, + } + repo := &openAIAdvancedSchedulerSettingRepoStub{ + values: map[string]string{ + openAIAdvancedSchedulerSettingKey: "true", + SettingKeyOpenAIAdvancedSchedulerLBTopK: "3", + SettingKeyOpenAIAdvancedSchedulerWeightPriority: "2.5", + SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: "12", + }, + } + svc := &OpenAIGatewayService{ + cfg: cfg, + rateLimitService: &RateLimitService{settingService: NewSettingService(repo, cfg)}, + } + + ctx := context.Background() + require.Equal(t, 3, svc.openAIWSLBTopKForRequest(ctx)) + weights := svc.openAIWSSchedulerWeightsForRequest(ctx) + require.Equal(t, 2.5, weights.Priority) + require.Equal(t, 2.0, weights.Load) + require.Equal(t, 12.0, weights.Previous) + require.Equal(t, 9.0, weights.SessionSticky) +} + func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabledUsesLegacyLoadAwareness(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() @@ -584,6 +646,247 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_EnabledUsesAdvancedPrev require.True(t, decision.StickyPreviousHit) } +func TestOpenAIGatewayService_SelectAccountWithScheduler_StickyWeightedSessionInTopKUsesStickyFirst(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(101071) + accounts := []Account{ + { + ID: 37101, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 100, + GroupIDs: []int64{groupID}, + }, + { + ID: 37102, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + GroupIDs: []int64{groupID}, + }, + } + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.LBTopK = 2 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0.7 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0.8 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0.5 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.SessionSticky = 3 + cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{ + "openai:session_hash_weighted_topk": 37101, + }} + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: cache, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true", "true"), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + + selection, decision, err := svc.SelectAccountWithScheduler( + ctx, + &groupID, + "", + "session_hash_weighted_topk", + "gpt-5.1", + nil, + OpenAIUpstreamTransportAny, + false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(37101), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.True(t, decision.StickySessionHit) + require.Equal(t, 2, decision.TopK) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_StickyWeightedPreviousRequiresMovableContext(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(101072) + accounts := []Account{ + { + ID: 37111, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 100, + GroupIDs: []int64{groupID}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_enabled": true, + }, + }, + { + ID: 37112, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + GroupIDs: []int64{groupID}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_enabled": true, + }, + }, + } + cfg := newSchedulerTestOpenAIWSV2Config() + cfg.Gateway.OpenAIWS.LBTopK = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0.7 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0.8 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0.5 + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true", "true"), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + store := svc.getOpenAIWSStateStore() + require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_weighted_unmovable", 37111, time.Hour)) + + selection, decision, err := svc.SelectAccountWithSchedulerForCapability( + ctx, + &groupID, + "resp_weighted_unmovable", + "", + "gpt-5.1", + nil, + OpenAIUpstreamTransportAny, + OpenAIEndpointCapabilityChatCompletions, + false, + PlatformOpenAI, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(37111), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerPreviousResponse, decision.Layer) + require.True(t, decision.StickyPreviousHit) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + + selection, decision, err = svc.SelectAccountWithSchedulerForCapability( + ctx, + &groupID, + "resp_weighted_unmovable", + "", + "gpt-5.1", + nil, + OpenAIUpstreamTransportAny, + OpenAIEndpointCapabilityChatCompletions, + false, + PlatformOpenAI, + "previous_response_can_move", + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(37112), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.False(t, decision.StickyPreviousHit) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_PreviousResponseCompactUnsupportedDeletesBinding(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(101073) + accounts := []Account{ + { + ID: 37121, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + GroupIDs: []int64{groupID}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_enabled": true, + "openai_compact_mode": OpenAICompactModeForceOff, + }, + }, + { + ID: 37122, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 10, + GroupIDs: []int64{groupID}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_enabled": true, + "openai_compact_mode": OpenAICompactModeForceOn, + }, + }, + } + cfg := newSchedulerTestOpenAIWSV2Config() + cfg.Gateway.OpenAIWS.LBTopK = 2 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0.7 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0.8 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0.5 + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + store := svc.getOpenAIWSStateStore() + require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_compact_unsupported", 37121, time.Hour)) + + selection, decision, err := svc.SelectAccountWithScheduler( + ctx, + &groupID, + "resp_compact_unsupported", + "", + "gpt-5.1", + nil, + OpenAIUpstreamTransportAny, + true, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(37122), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.False(t, decision.StickyPreviousHit) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + + accountID, err := store.GetResponseAccount(ctx, groupID, "resp_compact_unsupported") + require.NoError(t, err) + require.Zero(t, accountID) +} + func TestOpenAIGatewayService_SelectAccountWithScheduler_Enabled_EmbeddingsSkipsChatOnlyAccount(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() @@ -1560,6 +1863,217 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyEscapeDisa require.True(t, decision.StickySessionHit) } +func TestOpenAIGatewayService_SelectAccountWithScheduler_SubscriptionPriorityChoosesSubscriptionPoolFirst(t *testing.T) { + ctx := context.Background() + groupID := int64(10120) + accounts := []Account{ + { + ID: 21601, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 10, + GroupIDs: []int64{groupID}, + Credentials: map[string]any{"plan_type": "plus"}, + }, + { + ID: 21602, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + GroupIDs: []int64{groupID}, + }, + } + concurrencyCache := schedulerTestConcurrencyCache{ + acquireResults: map[int64]bool{21601: true, 21602: true}, + loadMap: map[int64]*AccountLoadInfo{ + 21601: {AccountID: 21601, LoadRate: 90, WaitingCount: 1}, + 21602: {AccountID: 21602, LoadRate: 0, WaitingCount: 0}, + }, + } + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: newSchedulerTestSubscriptionPriorityConfig(), + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true", "", "true"), + concurrencyService: NewConcurrencyService(concurrencyCache), + } + + selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_subscription_first", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(21601), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.Equal(t, 1, decision.TopK) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_SubscriptionPriorityFallsBackWhenSubscriptionFull(t *testing.T) { + ctx := context.Background() + groupID := int64(10121) + accounts := []Account{ + { + ID: 21611, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + GroupIDs: []int64{groupID}, + Credentials: map[string]any{"plan_type": "team"}, + }, + { + ID: 21612, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 9, + GroupIDs: []int64{groupID}, + }, + } + concurrencyCache := schedulerTestConcurrencyCache{ + acquireResults: map[int64]bool{21611: false, 21612: true}, + loadMap: map[int64]*AccountLoadInfo{ + 21611: {AccountID: 21611, LoadRate: 0, WaitingCount: 0}, + 21612: {AccountID: 21612, LoadRate: 90, WaitingCount: 1}, + }, + } + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: newSchedulerTestSubscriptionPriorityConfig(), + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true", "", "true"), + concurrencyService: NewConcurrencyService(concurrencyCache), + } + + selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_subscription_fallback", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(21612), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.True(t, selection.Acquired) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_SubscriptionPriorityDisabledUsesScore(t *testing.T) { + ctx := context.Background() + groupID := int64(10122) + accounts := []Account{ + { + ID: 21621, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 10, + GroupIDs: []int64{groupID}, + Credentials: map[string]any{"plan_type": "pro"}, + }, + { + ID: 21622, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + GroupIDs: []int64{groupID}, + }, + } + concurrencyCache := schedulerTestConcurrencyCache{ + acquireResults: map[int64]bool{21621: true, 21622: true}, + loadMap: map[int64]*AccountLoadInfo{ + 21621: {AccountID: 21621, LoadRate: 90, WaitingCount: 1}, + 21622: {AccountID: 21622, LoadRate: 0, WaitingCount: 0}, + }, + } + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: newSchedulerTestSubscriptionPriorityConfig(), + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true", "", "false"), + concurrencyService: NewConcurrencyService(concurrencyCache), + } + + selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_subscription_disabled", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(21622), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_UsesAccountPriorityWithinGroupPool(t *testing.T) { + ctx := context.Background() + groupID := int64(10123) + accounts := []Account{ + { + ID: 21631, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 1, + AccountGroups: []AccountGroup{ + {AccountID: 21631, GroupID: groupID, Priority: 100}, + }, + GroupIDs: []int64{groupID}, + }, + { + ID: 21632, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 100000, + AccountGroups: []AccountGroup{ + {AccountID: 21632, GroupID: groupID, Priority: 1}, + }, + GroupIDs: []int64{groupID}, + }, + } + cfg := newSchedulerTestSubscriptionPriorityConfig() + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0 + svc := &OpenAIGatewayService{ + accountRepo: schedulerGroupAwareOpenAIAccountRepo{schedulerTestOpenAIAccountRepo{accounts: accounts}}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + + selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_group_priority", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(21631), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + func TestDefaultOpenAIAccountScheduler_ShouldEscapeStickyAccount_ThresholdBoundary(t *testing.T) { stats := newOpenAIAccountRuntimeStats() accountID := int64(21501) diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 0ece5e44ff..c33ac97f0f 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -607,18 +607,21 @@ func hasOpenAIImageGenerationTool(reqBody map[string]any) bool { return false } -// stripCodexSparkImageGenerationTools removes image_generation tool entries from -// reqBody["tools"]. gpt-5.3-codex-spark rejects that tool upstream with HTTP 400 -// (invalid_request_error, param=tools), and Codex CLI advertises it by default, so -// it must be dropped for spark. When the tools list becomes empty the key is removed. -// Returns true when the body was modified. -func stripCodexSparkImageGenerationTools(reqBody map[string]any) bool { +func stripOpenAIImageGenerationTools(reqBody map[string]any) bool { rawTools, ok := reqBody["tools"] if !ok || rawTools == nil { + if openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"]) { + delete(reqBody, "tool_choice") + return true + } return false } tools, ok := rawTools.([]any) if !ok { + if openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"]) { + delete(reqBody, "tool_choice") + return true + } return false } filtered := make([]any, 0, len(tools)) @@ -631,17 +634,31 @@ func stripCodexSparkImageGenerationTools(reqBody map[string]any) bool { } filtered = append(filtered, rawTool) } - if !removed { + if !removed && !openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"]) { return false } - if len(filtered) == 0 { - delete(reqBody, "tools") - } else { - reqBody["tools"] = filtered + if removed { + if len(filtered) == 0 { + delete(reqBody, "tools") + } else { + reqBody["tools"] = filtered + } + } + if openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"]) { + delete(reqBody, "tool_choice") } return true } +// stripCodexSparkImageGenerationTools removes image_generation tool entries from +// reqBody["tools"]. gpt-5.3-codex-spark rejects that tool upstream with HTTP 400 +// (invalid_request_error, param=tools), and Codex CLI advertises it by default, so +// it must be dropped for spark. When the tools list becomes empty the key is removed. +// Returns true when the body was modified. +func stripCodexSparkImageGenerationTools(reqBody map[string]any) bool { + return stripOpenAIImageGenerationTools(reqBody) +} + func hasOpenAIInputImage(reqBody map[string]any) bool { if reqBody == nil { return false diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index a7004f5d54..8323035ffe 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1306,6 +1306,66 @@ func TestOpenAIGatewayServiceRecordUsage_ChannelMappedOverridesBillingModelWhenM require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero") } +func TestOpenAIGatewayServiceRecordUsage_ResponsesMappedBillingModelHonorsBillingModelSource(t *testing.T) { + usage := OpenAIUsage{InputTokens: 20, OutputTokens: 10} + tokens := UsageTokens{InputTokens: 20, OutputTokens: 10} + + tests := []struct { + name string + billingModelSource string + wantBillingModel string + }{ + { + name: "upstream uses mapped billing model", + billingModelSource: BillingModelSourceUpstream, + wantBillingModel: "gpt-5.5", + }, + { + name: "requested overrides mapped billing model", + billingModelSource: BillingModelSourceRequested, + wantBillingModel: "gpt-5.4", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + + expectedCost, err := svc.billingService.CalculateCost(tt.wantBillingModel, tokens, 1.1) + require.NoError(t, err) + + err = svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_mapped_billing_model_source", + Model: "gpt-5.4", + BillingModel: "gpt-5.5", + UpstreamModel: "gpt-5.5", + Usage: usage, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + ChannelUsageFields: ChannelUsageFields{ + OriginalModel: "gpt-5.4", + ChannelMappedModel: "gpt-5.4", + BillingModelSource: tt.billingModelSource, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "gpt-5.4", usageRepo.lastLog.Model) + require.InDelta(t, expectedCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, expectedCost.ActualCost, userRepo.lastAmount, 1e-12) + require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero") + }) + } +} + func TestOpenAIGatewayServiceRecordUsage_BillsCompactOpenAIModelAlias(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index cd84790b98..dd3d48aed0 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -2738,8 +2738,25 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if apiKey != nil { imageGenerationAllowed = GroupAllowsImageGeneration(apiKey.Group) } - codexImageGenerationBridgeEnabled := isCodexCLI && imageGenerationAllowed && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) - imageIntent := IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) + 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()}}) @@ -3216,6 +3233,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco wsAttempts, ) wsResult.UpstreamModel = upstreamModel + if wsResult.BillingModel == "" { + wsResult.BillingModel = billingModel + } if wsResult.ImageCount > 0 { wsResult.ImageSize = imageSizeTier wsResult.ImageInputSize = imageInputSize @@ -3363,6 +3383,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco ResponseID: responseID, Usage: *usage, Model: originalModel, + BillingModel: billingModel, UpstreamModel: upstreamModel, ServiceTier: serviceTier, ReasoningEffort: reasoningEffort, diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index aee69fffa9..1dde60c9f0 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -196,6 +196,144 @@ func TestOpenAIGatewayService_Forward_MappedImageModelUsesImageGate(t *testing.T require.Equal(t, http.StatusForbidden, rec.Code) } +func TestOpenAIGatewayService_Forward_TextResponsesSetsBillingModelToMappedModel(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_text_mapped_billing"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_text_mapped","object":"response","model":"gpt-5.5","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":20,"output_tokens":10,"total_tokens":30}}`, + )), + }, + } + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + account := &Account{ + ID: 4, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://example.com", + "model_mapping": map[string]any{"gpt-5.4": "gpt-5.5"}, + }, + Extra: map[string]any{"use_responses_api": true}, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + + body := []byte(`{"model":"gpt-5.4","stream":false,"input":"hello"}`) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "gpt-5.4", result.Model) + require.Equal(t, "gpt-5.5", result.BillingModel) + require.Equal(t, "gpt-5.5", result.UpstreamModel) + require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, 0, result.ImageCount) +} + +func TestOpenAIGatewayService_Forward_TextResponsesWithoutMappingKeepsRequestedBillingModel(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_text_unmapped_billing"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_text_unmapped","object":"response","model":"gpt-5.4","status":"completed","usage":{"input_tokens":20,"output_tokens":10,"total_tokens":30}}`)), + }, + } + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + account := &Account{ + ID: 4, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://example.com", + }, + Extra: map[string]any{"use_responses_api": true}, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + + result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","stream":false,"input":"hello"}`)) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "gpt-5.4", result.Model) + require.Equal(t, "gpt-5.4", result.BillingModel) + require.Equal(t, "gpt-5.4", result.UpstreamModel) +} + +func TestOpenAIGatewayService_Forward_TextResponsesBillingModelMatchesChatCompletions(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + account := &Account{ + ID: 5, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://example.com", + "model_mapping": map[string]any{"gpt-5.4": "gpt-5.5"}, + }, + Extra: map[string]any{"use_responses_api": true}, + } + + responsesUpstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_responses_mapped_billing"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_native","object":"response","model":"gpt-5.5","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":20,"output_tokens":10,"total_tokens":30}}`, + )), + }, + } + responsesSvc := &OpenAIGatewayService{cfg: cfg, httpUpstream: responsesUpstream} + responsesRecorder := httptest.NewRecorder() + responsesCtx, _ := gin.CreateTestContext(responsesRecorder) + responsesCtx.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + SetOpenAIClientTransport(responsesCtx, OpenAIClientTransportHTTP) + responsesResult, err := responsesSvc.Forward(context.Background(), responsesCtx, account, []byte(`{"model":"gpt-5.4","stream":false,"input":"hello"}`)) + require.NoError(t, err) + require.NotNil(t, responsesResult) + + chatUpstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_mapped_billing"}}, + Body: io.NopCloser(strings.NewReader( + `data: {"type":"response.completed","response":{"id":"resp_chat","object":"response","model":"gpt-5.5","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":20,"output_tokens":10,"total_tokens":30}}}` + "\n\n", + )), + }, + } + chatSvc := &OpenAIGatewayService{cfg: cfg, httpUpstream: chatUpstream} + chatRecorder := httptest.NewRecorder() + chatCtx, _ := gin.CreateTestContext(chatRecorder) + chatCtx.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/chat/completions", nil) + chatResult, err := chatSvc.ForwardAsChatCompletions(context.Background(), chatCtx, account, []byte(`{"model":"gpt-5.4","stream":false,"messages":[{"role":"user","content":"hello"}]}`), "", "") + require.NoError(t, err) + require.NotNil(t, chatResult) + + require.Equal(t, chatResult.BillingModel, responsesResult.BillingModel) + require.Equal(t, "gpt-5.5", responsesResult.BillingModel) + require.Equal(t, "gpt-5.5", chatResult.BillingModel) +} + func TestOpenAIGatewayService_Forward_TextDataImageDoesNotForceMapMarshal(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &httpUpstreamRecorder{ diff --git a/backend/internal/service/openai_image_generation_controls_test.go b/backend/internal/service/openai_image_generation_controls_test.go index 6061a0bf9c..31edd36097 100644 --- a/backend/internal/service/openai_image_generation_controls_test.go +++ b/backend/internal/service/openai_image_generation_controls_test.go @@ -152,6 +152,45 @@ func TestOpenAIGatewayServiceForward_ExplicitImageToolWorksWithBridgeDisabled(t require.NotContains(t, instructions, "image_generation") } +func TestOpenAIGatewayServiceForward_AccountPolicyStripsExplicitImageTool(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_stripped_image","model":"gpt-5.4","usage":{"input_tokens":2,"output_tokens":1}}`)), + }, + } + svc := newOpenAIImageGenerationControlTestService(upstream) + c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.98.0") + account := newOpenAIImageGenerationControlTestAccount() + account.Extra = map[string]any{ + featureKeyCodexImageGenerationExplicitToolPolicy: codexImageGenerationExplicitToolPolicyStrip, + } + body := []byte(`{ + "model":"gpt-5.4", + "input":"draw", + "stream":false, + "tools":[ + {"type":"function","name":"shell","parameters":{"type":"object"}}, + {"type":"image_generation","format":"jpeg"} + ], + "tool_choice":{"type":"image_generation"} + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists()) + require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="function")`).Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice").Exists()) + instructions := gjson.GetBytes(upstream.lastBody, "instructions").String() + require.NotContains(t, instructions, "image_generation") +} + func TestOpenAIGatewayServiceForward_ChannelBridgeOverrideEnablesCodexInjection(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 067eeb6029..fcc4b98064 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -2448,11 +2448,15 @@ func stripCodexSparkImageGenerationToolFromRawPayload(payload []byte, model stri 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 !stripCodexSparkImageGenerationTools(payloadMap) { + if !stripOpenAIImageGenerationTools(payloadMap) { return payload, false, nil } rebuilt, err := json.Marshal(payloadMap) @@ -2671,7 +2675,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } apiKey := getAPIKeyFromContext(c) imageGenerationAllowed := GroupAllowsImageGeneration(apiKeyGroup(apiKey)) - codexBridgeEnabled := isCodexCLI && imageGenerationAllowed && s.isCodexImageGenerationBridgeEnabled(ctx, account, 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 { @@ -2709,6 +2717,14 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } 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 { @@ -4309,87 +4325,8 @@ func (s *OpenAIGatewayService) selectAccountByPreviousResponseIDForCapability( if s == nil { return nil, nil } - responseID := strings.TrimSpace(previousResponseID) - if responseID == "" { - return nil, nil - } - store := s.getOpenAIWSStateStore() - if store == nil { - return nil, nil - } - - accountID, err := store.GetResponseAccount(ctx, derefGroupID(groupID), responseID) - if err != nil || accountID <= 0 { - return nil, nil - } - if excludedIDs != nil { - if _, excluded := excludedIDs[accountID]; excluded { - return nil, nil - } - } - - account, err := s.getSchedulableAccount(ctx, accountID) - if err != nil || account == nil { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return nil, nil - } - // 非 WSv2 场景(如 force_http/全局关闭)不应使用 previous_response_id 粘连, - // 以保持“回滚到 HTTP”后的历史行为一致性。 - if s.getOpenAIWSProtocolResolver().Resolve(account).Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { - return nil, nil - } - if shouldClearStickySession(account, requestedModel) || !account.IsOpenAI() || !account.IsSchedulable() { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return nil, nil - } - if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return nil, nil - } - if requestedModel != "" && !account.IsModelSupported(requestedModel) { - return nil, nil - } - if !account.SupportsOpenAIEndpointCapability(requiredCapability) { - return 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 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 nil, nil - } - if shouldClearStickySession(latest, requestedModel) || !latest.IsOpenAI() || !latest.IsSchedulable() { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return nil, nil - } - if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return nil, nil - } - if requestedModel != "" && !latest.IsModelSupported(requestedModel) { - return nil, nil - } - if !latest.SupportsOpenAIEndpointCapability(requiredCapability) { - return nil, nil - } - if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, latest); paused { - return nil, nil - } - if s.isOpenAIAccountRuntimeBlocked(latest) { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return nil, nil - } - account = latest - } - if requireCompact && openAICompactSupportTier(account) == 0 { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + accountID, account, responseID, store := s.resolveAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, requiredCapability, requireCompact) + if accountID <= 0 || account == nil || store == nil { return nil, nil } @@ -4423,6 +4360,117 @@ func (s *OpenAIGatewayService) selectAccountByPreviousResponseIDForCapability( 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" diff --git a/backend/internal/service/openai_ws_forwarder_ingress_test.go b/backend/internal/service/openai_ws_forwarder_ingress_test.go index 0d19a189b0..ca7c36aaa7 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_test.go @@ -169,6 +169,26 @@ func TestStripCodexSparkImageGenerationToolFromRawPayload(t *testing.T) { }) } +func TestStripOpenAIImageGenerationToolFromRawPayload(t *testing.T) { + payload := []byte(`{ + "type":"response.create", + "model":"gpt-5.4", + "tools":[ + {"type":"function","name":"shell"}, + {"type":"image_generation","output_format":"png"} + ], + "tool_choice":{"type":"image_generation"} + }`) + + updated, changed, err := stripOpenAIImageGenerationToolFromRawPayload(payload) + + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(updated, `tools.#(type=="image_generation")`).Exists()) + require.True(t, gjson.GetBytes(updated, `tools.#(type=="function")`).Exists()) + require.False(t, gjson.GetBytes(updated, "tool_choice").Exists()) +} + func TestAlignStoreDisabledPreviousResponseID(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/ops_models.go b/backend/internal/service/ops_models.go index 4fc6a9266e..e33dcf82a8 100644 --- a/backend/internal/service/ops_models.go +++ b/backend/internal/service/ops_models.go @@ -1,6 +1,9 @@ package service -import "time" +import ( + "strings" + "time" +) type OpsSystemLog struct { ID int64 `json:"id"` @@ -65,17 +68,22 @@ type OpsErrorLog struct { RequestedModel string `json:"requested_model"` UpstreamModel string `json:"upstream_model"` RequestType *int16 `json:"request_type"` + UserAgent string `json:"user_agent"` // 关联 api_key 名称(LEFT JOIN api_keys 取得;软删只覆盖 key 列,name 保留,故已删 key 仍有原名)。 APIKeyName string `json:"api_key_name,omitempty"` APIKeyDeleted bool `json:"api_key_deleted,omitempty"` + + // 已删除 KEY 所有者(INVALID_API_KEY 且该 key 曾存在时的归因快照)。 + // 认证失败行 user_id 为空,列表用户列以此回退显示所有者。 + DeletedKeyOwnerUserID *int64 `json:"deleted_key_owner_user_id,omitempty"` + DeletedKeyOwnerEmail string `json:"deleted_key_owner_email,omitempty"` } type OpsErrorLogDetail struct { OpsErrorLog ErrorBody string `json:"error_body"` - UserAgent string `json:"user_agent"` // Upstream context (optional) UpstreamStatusCode *int `json:"upstream_status_code,omitempty"` @@ -93,11 +101,10 @@ type OpsErrorLogDetail struct { // vNext metric semantics IsBusinessLimited bool `json:"is_business_limited"` - // Deleted key owner info (populated when INVALID_API_KEY and key was previously deleted) - AttemptedKeyPrefix string `json:"attempted_key_prefix,omitempty"` - DeletedKeyOwnerUserID *int64 `json:"deleted_key_owner_user_id,omitempty"` - DeletedKeyOwnerEmail string `json:"deleted_key_owner_email,omitempty"` - DeletedKeyName string `json:"deleted_key_name,omitempty"` + // Deleted key owner info (populated when INVALID_API_KEY and key was previously deleted). + // OwnerUserID/OwnerEmail 已上移到 OpsErrorLog(列表用户列回退需要)。 + AttemptedKeyPrefix string `json:"attempted_key_prefix,omitempty"` + DeletedKeyName string `json:"deleted_key_name,omitempty"` // Bound (non-deleted) key prefix, snapshotted at error time; mutually exclusive with AttemptedKeyPrefix. APIKeyPrefix string `json:"api_key_prefix,omitempty"` @@ -142,8 +149,14 @@ type OpsErrorLogFilter struct { // ExcludeCountTokens drops count_tokens probe errors (is_count_tokens=true). ExcludeCountTokens bool + // IncludeRecoveredUpstream 显式豁免 status>=400 守卫(仅在 Phase=="upstream" 时生效): + // ops 专用上游错误列表需要看到 status<400 的 recovered upstream 行。 + // 请求错误语义的端点不设此开关,phase=upstream 过滤照常生效且守卫保留。 + IncludeRecoveredUpstream bool + // ErrorPhasesAny / ErrorTypesAny add plain ANY() filters WITHOUT touching the - // special-cased single `Phase` field (only Phase=="upstream" bypasses the status>=400 clause). + // special-cased single `Phase` field (only Phase=="upstream" with + // IncludeRecoveredUpstream bypasses the status>=400 clause). // NOTE: these ANY filters do NOT bypass status>=400; records with error_phase='upstream' // but status_code<400 (recovered upstream errors) remain excluded. // Used to map user-facing coarse categories to backend conditions. @@ -158,6 +171,19 @@ type OpsErrorLogFilter struct { Page int PageSize int + + // SortBy/SortOrder: server-side sorting aligned with the usage-log list. + // Repo whitelists columns (created_at/model/status_code); anything else + // falls back to created_at. SortOrder is "asc"/"desc" (default desc). + SortBy string + SortOrder string +} + +// SetSort normalizes raw sort_by/sort_order query values into the filter. +// Shared by the admin and user-facing error list handlers. +func (f *OpsErrorLogFilter) SetSort(sortBy, sortOrder string) { + f.SortBy = strings.TrimSpace(sortBy) + f.SortOrder = strings.TrimSpace(sortOrder) } type OpsErrorLogList struct { diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index a8c8a4bb5c..61f85ef904 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -359,10 +359,12 @@ func (s *OpsService) ListUserErrorRequests(ctx context.Context, userID int64, fi filter.UserQuery = "" filter.Owner = "" filter.Source = "" - // 清空 Phase 是防御:Phase 是单值特殊字段,仅当其 == "upstream" 时 buildOpsErrorLogsWhere 才跳过 status>=400 子句。 - // 用户端一律改走 category→ErrorPhasesAny/ErrorTypesAny(纯 ANY 过滤,不影响 status>=400 子句), - // 因此 recovered upstream(error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。 + // 清空 Phase 是防御:用户端一律改走 category→ErrorPhasesAny/ErrorTypesAny + //(纯 ANY 过滤,不影响 status>=400 子句)。守卫豁免现在还需要 + // IncludeRecoveredUpstream(用户端永不设置),recovered upstream + //(error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。 filter.Phase = "" + filter.IncludeRecoveredUpstream = false list, err := s.opsRepo.ListErrorLogs(ctx, filter) if err != nil { diff --git a/backend/internal/service/ops_service_user_error_test.go b/backend/internal/service/ops_service_user_error_test.go index 9027ff0788..c3b0967b67 100644 --- a/backend/internal/service/ops_service_user_error_test.go +++ b/backend/internal/service/ops_service_user_error_test.go @@ -184,16 +184,16 @@ func TestGetUserErrorRequestDetail_DeletedKeyOwnerAccess(t *testing.T) { mk := func() *OpsErrorLogDetail { return &OpsErrorLogDetail{ OpsErrorLog: OpsErrorLog{ - ID: 55, - Phase: "auth", - Type: "api_error", - StatusCode: 401, - Message: "Invalid API key", - UserID: nil, - APIKeyName: "my-old-key", - APIKeyDeleted: true, + ID: 55, + Phase: "auth", + Type: "api_error", + StatusCode: 401, + Message: "Invalid API key", + UserID: nil, + APIKeyName: "my-old-key", + APIKeyDeleted: true, + DeletedKeyOwnerUserID: &ownerUID, }, - DeletedKeyOwnerUserID: &ownerUID, } } diff --git a/backend/internal/service/ops_user_error.go b/backend/internal/service/ops_user_error.go index 7dd128afa7..e3055c2392 100644 --- a/backend/internal/service/ops_user_error.go +++ b/backend/internal/service/ops_user_error.go @@ -3,9 +3,12 @@ package service import "time" // UserErrorRequest 是面向终端用户的"错误请求"精简脱敏视图(白名单)。 -// 严禁包含 client_ip / user_agent / account / api_key_prefix / upstream_endpoint / -// user_email 等敏感或内部字段。注:message(网关标准化错误描述)与 key_name +// 严禁包含 account / api_key_prefix / upstream_endpoint / user_email 等 +// 敏感或内部字段。注:message(网关标准化错误描述)与 key_name // (用户自有 API Key 名称,KeysView 中本就可见)经产品决策对该用户开放; +// client_ip / user_agent / group_name / request_type / stream 均为该用户 +// 自己请求的属性,经产品决策(2026-07-03)开放, +// 与用量明细已向用户展示自身 ip_address/user_agent/分组/类型 的口径对齐; // error_body 仅在详情接口(GetUserErrorRequestDetail)按归属校验后返回。 type UserErrorRequest struct { ID int64 `json:"id"` @@ -18,6 +21,11 @@ type UserErrorRequest struct { Message string `json:"message"` KeyName string `json:"key_name"` KeyDeleted bool `json:"key_deleted"` + ClientIP string `json:"client_ip,omitempty"` + GroupName string `json:"group_name,omitempty"` + RequestType *int16 `json:"request_type,omitempty"` + Stream bool `json:"stream"` + UserAgent string `json:"user_agent,omitempty"` } // UserErrorRequestList 是用户错误请求分页结果。 @@ -90,6 +98,10 @@ func ToUserErrorRequest(e *OpsErrorLog) *UserErrorRequest { if model == "" { model = e.Model } + clientIP := "" + if e.ClientIP != nil { + clientIP = *e.ClientIP + } return &UserErrorRequest{ ID: e.ID, CreatedAt: e.CreatedAt, @@ -101,6 +113,11 @@ func ToUserErrorRequest(e *OpsErrorLog) *UserErrorRequest { Message: e.Message, KeyName: e.APIKeyName, KeyDeleted: e.APIKeyDeleted, + ClientIP: clientIP, + GroupName: e.GroupName, + RequestType: e.RequestType, + Stream: e.Stream, + UserAgent: e.UserAgent, } } diff --git a/backend/internal/service/ops_user_error_test.go b/backend/internal/service/ops_user_error_test.go index 31b0c26933..9e0bc164b4 100644 --- a/backend/internal/service/ops_user_error_test.go +++ b/backend/internal/service/ops_user_error_test.go @@ -122,9 +122,11 @@ func TestToUserErrorRequestDetail_WhitelistAndRedacts(t *testing.T) { UserEmail: "secret@example.com", ClientIP: func() *string { s := "1.2.3.4"; return &s }(), UpstreamEndpoint: "https://api.openai.com/v1/chat/completions", + UserAgent: "codex_cli_rs/0.125.0", + GroupName: "grp-a", + Stream: true, }, ErrorBody: `{"error":{"message":"upstream failed","type":"server_error"}}`, - UserAgent: "Mozilla/5.0 secret-agent", UpstreamStatusCode: &upstreamStatus, } @@ -147,13 +149,27 @@ func TestToUserErrorRequestDetail_WhitelistAndRedacts(t *testing.T) { t.Errorf("UpstreamStatusCode mismatch") } + // client_ip / user_agent / group_name / stream 经产品决策开放(与用量明细口径对齐) + if out.ClientIP != "1.2.3.4" { + t.Errorf("want client_ip=1.2.3.4, got %q", out.ClientIP) + } + if out.UserAgent != "codex_cli_rs/0.125.0" { + t.Errorf("want user_agent=codex_cli_rs/0.125.0, got %q", out.UserAgent) + } + if out.GroupName != "grp-a" { + t.Errorf("want group_name=grp-a, got %q", out.GroupName) + } + if !out.Stream { + t.Errorf("want stream=true") + } + // 序列化后不含敏感字段 b, err := json.Marshal(out) if err != nil { t.Fatalf("json.Marshal failed: %v", err) } raw := string(b) - for _, forbidden := range []string{"user_email", "client_ip", "upstream_endpoint", "user_agent"} { + for _, forbidden := range []string{"user_email", "upstream_endpoint"} { if strings.Contains(raw, forbidden) { t.Errorf("sensitive field %q leaked in JSON output: %s", forbidden, raw) } diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 29fe40b1b6..154159b932 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -453,6 +453,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen } return nil, classifyCreatePaymentError(req, sel.ProviderKey, err) } + sanitizeCreatePaymentResponseDetails(pr) _, err = s.entClient.PaymentOrder.UpdateOneID(order.ID). SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)). SetNillablePayURL(psNilIfEmpty(pr.PayURL)). @@ -480,6 +481,22 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen return resp, nil } +func sanitizeCreatePaymentResponseDetails(pr *payment.CreatePaymentResponse) { + if pr == nil { + return + } + pr.TradeNo = removePostgresTextNUL(pr.TradeNo) + pr.PayURL = removePostgresTextNUL(pr.PayURL) + pr.QRCode = removePostgresTextNUL(pr.QRCode) +} + +func removePostgresTextNUL(value string) string { + if !strings.ContainsRune(value, 0) { + return value + } + return strings.ReplaceAll(value, "\x00", "") +} + func buildProviderCreatePaymentRequest(req CreateOrderRequest, sel *payment.InstanceSelection, orderID, amount, subject string) payment.CreatePaymentRequest { return payment.CreatePaymentRequest{ OrderID: orderID, diff --git a/backend/internal/service/payment_order_result_test.go b/backend/internal/service/payment_order_result_test.go index b7545ee45d..14192bd6cd 100644 --- a/backend/internal/service/payment_order_result_test.go +++ b/backend/internal/service/payment_order_result_test.go @@ -91,6 +91,41 @@ func TestBuildCreateOrderResponseCopiesJSAPIPayload(t *testing.T) { } } +func TestSanitizeCreatePaymentResponseDetailsRemovesNULBytes(t *testing.T) { + t.Parallel() + + resp := &payment.CreatePaymentResponse{ + TradeNo: "trade\x00-no", + PayURL: "https://pay.example.com/\x00checkout", + QRCode: "wxp://payment-token\x00", + ClientSecret: "secret\x00unchanged", + } + + sanitizeCreatePaymentResponseDetails(resp) + + if strings.ContainsRune(resp.TradeNo, 0) { + t.Fatalf("trade_no still contains NUL: %q", resp.TradeNo) + } + if strings.ContainsRune(resp.PayURL, 0) { + t.Fatalf("pay_url still contains NUL: %q", resp.PayURL) + } + if strings.ContainsRune(resp.QRCode, 0) { + t.Fatalf("qr_code still contains NUL: %q", resp.QRCode) + } + if resp.TradeNo != "trade-no" { + t.Fatalf("trade_no = %q, want trade-no", resp.TradeNo) + } + if resp.PayURL != "https://pay.example.com/checkout" { + t.Fatalf("pay_url = %q, want sanitized URL", resp.PayURL) + } + if resp.QRCode != "wxp://payment-token" { + t.Fatalf("qr_code = %q, want sanitized QR code", resp.QRCode) + } + if resp.ClientSecret != "secret\x00unchanged" { + t.Fatalf("client_secret = %q, should not be touched by payment detail sanitization", resp.ClientSecret) + } +} + func TestValidateSelectedCreateOrderAmountCurrencyRejectsFractionalZeroDecimal(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 9e968853f4..50d38e7d13 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -116,6 +116,14 @@ func (s *RateLimitService) SetAccountRuntimeBlocker(blocker AccountRuntimeBlocke s.runtimeBlocker = blocker } +func (s *RateLimitService) IsOpenAIAdvancedSchedulerStickyWeightedEnabled(ctx context.Context) bool { + if s == nil || s.settingService == nil { + return false + } + gateway := &OpenAIGatewayService{rateLimitService: s} + return gateway.isOpenAIAdvancedSchedulerStickyWeightedEnabled(ctx) +} + func (s *RateLimitService) notifyAccountSchedulingBlocked(account *Account, until time.Time, reason string) { if s == nil || s.runtimeBlocker == nil || account == nil { return @@ -186,9 +194,14 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc // otherwise a broad "rate limit" keyword rule can shorten a multi-hour // cooldown to a local temporary pause. if statusCode == http.StatusTooManyRequests && account.Platform == PlatformAnthropic { + // 7d_oi 是 Fable 模型专属的 7d 窗口:只标记模型级限流,账号对其他模型仍可调度。 + fableLimited := s.persistAnthropicFableWindowLimit(ctx, account, headers) if s.persistAnthropicExhaustedWindowLimit(ctx, account, headers) { return false } + if fableLimited { + return false + } } // 先尝试临时不可调度规则(401除外) @@ -287,6 +300,20 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc if upstreamMsg != "" { msg = "OAuth 401: " + upstreamMsg } + if authAccount.Platform == PlatformAntigravity { + extraUpdates := antigravityForceTokenRefreshExtra("401_invalid") + if err := s.accountRepo.UpdateExtra(ctx, authAccount.ID, extraUpdates); err != nil { + slog.Warn("antigravity_401_force_refresh_mark_failed", "account_id", authAccount.ID, "error", err) + } else { + if authAccount.Extra == nil { + authAccount.Extra = make(map[string]any, len(extraUpdates)) + } + for k, v := range extraUpdates { + authAccount.Extra[k] = v + } + slog.Info("antigravity_401_force_refresh_marked", "account_id", authAccount.ID) + } + } cooldownMinutes := s.cfg.RateLimit.OAuth401CooldownMinutes if cooldownMinutes <= 0 { cooldownMinutes = 10 @@ -1140,11 +1167,25 @@ func selectAnthropicExhaustedWindow(headers http.Header, now time.Time) *anthrop } func isAnthropic5hRejected(headers http.Header) bool { - return strings.EqualFold(strings.TrimSpace(headers.Get("anthropic-ratelimit-unified-5h-status")), "rejected") + return isAnthropicWindowRejected(headers, "5h") +} + +func isAnthropicWindowRejected(headers http.Header, window string) bool { + return strings.EqualFold(strings.TrimSpace(headers.Get("anthropic-ratelimit-unified-"+window+"-status")), "rejected") } func parseAnthropicWindowReset(headers http.Header, window string, now time.Time) (time.Time, bool) { - raw := strings.TrimSpace(headers.Get("anthropic-ratelimit-unified-" + window + "-reset")) + maxAge := 8 * 24 * time.Hour + if window == "5h" { + maxAge = 6 * time.Hour + } + return parseAnthropicResetTimestamp(headers.Get("anthropic-ratelimit-unified-"+window+"-reset"), now, maxAge) +} + +// parseAnthropicResetTimestamp 解析 Anthropic reset 头的 Unix 时间戳(自动识别毫秒), +// 并校验落在 (now, now+maxAge] 的合理区间内。 +func parseAnthropicResetTimestamp(raw string, now time.Time, maxAge time.Duration) (time.Time, bool) { + raw = strings.TrimSpace(raw) if raw == "" { return time.Time{}, false } @@ -1156,15 +1197,7 @@ func parseAnthropicWindowReset(headers http.Header, window string, now time.Time ts = ts / 1000 } resetAt := time.Unix(ts, 0) - if !resetAt.After(now) { - return time.Time{}, false - } - - maxAge := 8 * 24 * time.Hour - if window == "5h" { - maxAge = 6 * time.Hour - } - if resetAt.After(now.Add(maxAge)) { + if !resetAt.After(now) || resetAt.After(now.Add(maxAge)) { return time.Time{}, false } return resetAt, true @@ -1218,6 +1251,76 @@ func (s *RateLimitService) persistAnthropicExhaustedWindowLimit(ctx context.Cont return true } +const anthropicFableWindowReason = "anthropic_7d_oi_window_exhausted" + +// selectAnthropicFableWindowLimit parses the Anthropic 7d_oi per-model window +// headers (the Fable-only 7d window, e.g. anthropic-ratelimit-unified-7d_oi-*). +// Unlike 5h/7d, exhaustion of this window only limits the Fable model family — +// the account must stay schedulable for other models. +// +// The 7d_oi surpassed-threshold header carries a float ("1.0") rather than +// "true", so exhaustion is detected via status=rejected or utilization >= 1.0. +// When the 7d_oi reset header is missing, the aggregated +// anthropic-ratelimit-unified-reset is used (it mirrors the binding claim's +// reset when 7d_oi is the representative claim). +func selectAnthropicFableWindowLimit(headers http.Header, now time.Time) *anthropicWindowLimit { + if !isAnthropicWindowRejected(headers, "7d_oi") && !isAnthropicWindowExceeded(headers, "7d_oi") { + return nil + } + resetAt, ok := parseAnthropicWindowReset(headers, "7d_oi", now) + if !ok { + resetAt, ok = parseAnthropicAggregateReset(headers, now) + } + if !ok { + return nil + } + return &anthropicWindowLimit{ + window: "7d_oi", + resetAt: resetAt, + reason: anthropicFableWindowReason, + } +} + +// parseAnthropicAggregateReset parses the aggregated +// anthropic-ratelimit-unified-reset header with the same sanity checks as the +// per-window variant (7d scale). +func parseAnthropicAggregateReset(headers http.Header, now time.Time) (time.Time, bool) { + return parseAnthropicResetTimestamp(headers.Get("anthropic-ratelimit-unified-reset"), now, 8*24*time.Hour) +} + +// persistAnthropicFableWindowLimit marks the Fable model family as rate limited +// when the 7d_oi window is exhausted. Returns true when the 7d_oi window was the +// (or a) trigger of this 429, so the caller must not fall through to logic that +// would mark the whole account as rate limited. +func (s *RateLimitService) persistAnthropicFableWindowLimit(ctx context.Context, account *Account, headers http.Header) bool { + if s == nil || s.accountRepo == nil || account == nil { + return false + } + now := time.Now() + limit := selectAnthropicFableWindowLimit(headers, now) + if limit == nil { + return false + } + // 429 响应头本身携带最新的窗口用量(7d_oi utilization=1.0)。限流期内 + // Fable 请求不再调度到该账号,若不在此处采样,7d F 进度条会冻结在 + // 限流前的旧值直到窗口重置。 + s.samplePassiveUsageFromHeaders(ctx, account, headers) + if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, anthropicFableRateLimitKey, limit.resetAt, limit.reason); err != nil { + slog.Warn("anthropic_fable_window_rate_limit_set_failed", + "account_id", account.ID, + "scope", anthropicFableRateLimitKey, + "reset_at", limit.resetAt, + "error", err) + return true + } + slog.Info("anthropic_fable_window_model_rate_limited", + "account_id", account.ID, + "scope", anthropicFableRateLimitKey, + "reset_at", limit.resetAt, + "reset_in", time.Until(limit.resetAt).Truncate(time.Second)) + return true +} + // calculateAnthropic429ResetTime parses Anthropic's per-window rate-limit headers // to determine which window (5h or 7d) actually triggered the 429. // @@ -1541,10 +1644,12 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc // 窗口重置时清除旧的 utilization 和被动采样数据,避免残留上个窗口的数据 if windowEnd != nil && needInitWindow { _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ - "session_window_utilization": nil, - "passive_usage_7d_utilization": nil, - "passive_usage_7d_reset": nil, - "passive_usage_sampled_at": nil, + "session_window_utilization": nil, + "passive_usage_7d_utilization": nil, + "passive_usage_7d_reset": nil, + "passive_usage_7d_oi_utilization": nil, + "passive_usage_7d_oi_reset": nil, + "passive_usage_sampled_at": nil, }) } @@ -1552,8 +1657,21 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc slog.Warn("session_window_update_failed", "account_id", account.ID, "error", err) } - // 被动采样:从响应头收集 5h + 7d utilization,合并为一次 DB 写入 - extraUpdates := make(map[string]any, 4) + // 被动采样:从响应头收集 5h + 7d + 7d_oi utilization,合并为一次 DB 写入 + s.samplePassiveUsageFromHeaders(ctx, account, headers) + + // 如果状态为allowed且之前有限流,说明窗口已重置,清除限流状态 + if status == "allowed" && account.IsRateLimited() { + if err := s.ClearRateLimit(ctx, account.ID); err != nil { + slog.Warn("rate_limit_clear_failed", "account_id", account.ID, "error", err) + } + } +} + +// samplePassiveUsageFromHeaders 从 Anthropic 响应头收集 5h/7d/7d_oi 的 +// utilization 与 reset 被动采样数据,合并为一次 Extra 写入。无数据时不写。 +func (s *RateLimitService) samplePassiveUsageFromHeaders(ctx context.Context, account *Account, headers http.Header) { + extraUpdates := make(map[string]any, 6) // 5h utilization(0-1 小数),供 estimateSetupTokenUsage 使用 if utilStr := headers.Get("anthropic-ratelimit-unified-5h-utilization"); utilStr != "" { if util, err := strconv.ParseFloat(utilStr, 64); err == nil { @@ -1575,19 +1693,27 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc extraUpdates["passive_usage_7d_reset"] = ts } } + // 7d_oi (Fable 专属 7d 窗口) utilization(0-1 小数) + if utilStr := headers.Get("anthropic-ratelimit-unified-7d_oi-utilization"); utilStr != "" { + if util, err := strconv.ParseFloat(utilStr, 64); err == nil { + extraUpdates["passive_usage_7d_oi_utilization"] = util + } + } + // 7d_oi reset timestamp + if resetStr := headers.Get("anthropic-ratelimit-unified-7d_oi-reset"); resetStr != "" { + if ts, err := strconv.ParseInt(resetStr, 10, 64); err == nil { + if ts > 1e11 { + ts = ts / 1000 + } + extraUpdates["passive_usage_7d_oi_reset"] = ts + } + } if len(extraUpdates) > 0 { extraUpdates["passive_usage_sampled_at"] = time.Now().UTC().Format(time.RFC3339) if err := s.accountRepo.UpdateExtra(ctx, account.ID, extraUpdates); err != nil { slog.Warn("passive_usage_update_failed", "account_id", account.ID, "error", err) } } - - // 如果状态为allowed且之前有限流,说明窗口已重置,清除限流状态 - if status == "allowed" && account.IsRateLimited() { - if err := s.ClearRateLimit(ctx, account.ID); err != nil { - slog.Warn("rate_limit_clear_failed", "account_id", account.ID, "error", err) - } - } } // ClearRateLimit 清除账号的限流状态 diff --git a/backend/internal/service/ratelimit_service_401_test.go b/backend/internal/service/ratelimit_service_401_test.go index 09afb9314b..48e6a41def 100644 --- a/backend/internal/service/ratelimit_service_401_test.go +++ b/backend/internal/service/ratelimit_service_401_test.go @@ -18,7 +18,9 @@ type rateLimitAccountRepoStub struct { setErrorCalls int tempCalls int updateCredentialsCalls int + updateExtraCalls int lastCredentials map[string]any + lastExtraUpdates map[string]any lastErrorMsg string lastTempReason string lastErrorID int64 @@ -45,6 +47,12 @@ func (r *rateLimitAccountRepoStub) UpdateCredentials(ctx context.Context, id int return nil } +func (r *rateLimitAccountRepoStub) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error { + r.updateExtraCalls++ + r.lastExtraUpdates = shallowCopyMap(updates) + return nil +} + type tokenCacheInvalidatorRecorder struct { accounts []*Account err error @@ -133,6 +141,10 @@ func TestRateLimitService_HandleUpstreamError_OAuth401SetsTempUnschedulable(t *t require.Equal(t, 1, repo.tempCalls) require.Equal(t, int64(100), repo.lastTempID) require.Contains(t, repo.lastTempReason, "invalid or expired credentials") + require.Equal(t, 1, repo.updateExtraCalls) + require.Equal(t, true, repo.lastExtraUpdates[antigravityForceTokenRefreshExtraKey]) + require.Equal(t, "401_invalid", repo.lastExtraUpdates[antigravityForceTokenRefreshReasonExtraKey]) + require.Equal(t, true, account.Extra[antigravityForceTokenRefreshExtraKey]) require.Len(t, invalidator.accounts, 1) require.Equal(t, int64(100), invalidator.accounts[0].ID) }) @@ -245,6 +257,7 @@ func TestRateLimitService_HandleUpstreamError_OAuth401DoesNotOverwriteCredential require.True(t, shouldDisable) require.Equal(t, 0, repo.updateCredentialsCalls, "401 handler must not write credentials back from the request-start snapshot") + require.Equal(t, 0, repo.updateExtraCalls, "OpenAI 401 must not set Antigravity force-refresh marker") require.Equal(t, 1, repo.tempCalls, "401 handler should still set temp-unschedulable cooldown") require.Nil(t, repo.lastCredentials, "no credentials should have been persisted") } diff --git a/backend/internal/service/ratelimit_service_anthropic_test.go b/backend/internal/service/ratelimit_service_anthropic_test.go index eaeaf30e60..0e75b4f914 100644 --- a/backend/internal/service/ratelimit_service_anthropic_test.go +++ b/backend/internal/service/ratelimit_service_anthropic_test.go @@ -2,6 +2,7 @@ package service import ( "net/http" + "strconv" "testing" "time" ) @@ -181,6 +182,142 @@ func TestIsAnthropicWindowExceeded(t *testing.T) { } } +func TestSelectAnthropicFableWindowLimit_RejectedStatus(t *testing.T) { + now := time.Now() + reset := now.Add(80 * time.Hour).Truncate(time.Second) + + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-7d_oi-status", "rejected") + headers.Set("anthropic-ratelimit-unified-7d_oi-utilization", "1.0") + headers.Set("anthropic-ratelimit-unified-7d_oi-surpassed-threshold", "1.0") + headers.Set("anthropic-ratelimit-unified-7d_oi-reset", strconv.FormatInt(reset.Unix(), 10)) + + limit := selectAnthropicFableWindowLimit(headers, now) + if limit == nil { + t.Fatal("expected non-nil limit") + } + if !limit.resetAt.Equal(reset) { + t.Errorf("expected resetAt=%v, got %v", reset, limit.resetAt) + } + if limit.reason != anthropicFableWindowReason { + t.Errorf("expected reason=%q, got %q", anthropicFableWindowReason, limit.reason) + } +} + +func TestSelectAnthropicFableWindowLimit_UtilizationOnly(t *testing.T) { + // 无 status 头时,utilization >= 1.0 也应视为超限 + now := time.Now() + reset := now.Add(3 * 24 * time.Hour).Truncate(time.Second) + + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-7d_oi-utilization", "1.0") + headers.Set("anthropic-ratelimit-unified-7d_oi-reset", strconv.FormatInt(reset.Unix(), 10)) + + limit := selectAnthropicFableWindowLimit(headers, now) + if limit == nil { + t.Fatal("expected non-nil limit") + } + if !limit.resetAt.Equal(reset) { + t.Errorf("expected resetAt=%v, got %v", reset, limit.resetAt) + } +} + +func TestSelectAnthropicFableWindowLimit_AllowedReturnsNil(t *testing.T) { + now := time.Now() + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-7d_oi-status", "allowed") + headers.Set("anthropic-ratelimit-unified-7d_oi-utilization", "0.56") + headers.Set("anthropic-ratelimit-unified-7d_oi-reset", strconv.FormatInt(now.Add(80*time.Hour).Unix(), 10)) + + if limit := selectAnthropicFableWindowLimit(headers, now); limit != nil { + t.Errorf("expected nil limit for allowed window, got %+v", limit) + } +} + +func TestSelectAnthropicFableWindowLimit_NoHeadersReturnsNil(t *testing.T) { + if limit := selectAnthropicFableWindowLimit(http.Header{}, time.Now()); limit != nil { + t.Errorf("expected nil limit for empty headers, got %+v", limit) + } +} + +func TestSelectAnthropicFableWindowLimit_FallsBackToAggregateReset(t *testing.T) { + // 7d_oi-reset 缺失时回退聚合 anthropic-ratelimit-unified-reset + now := time.Now() + reset := now.Add(80 * time.Hour).Truncate(time.Second) + + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-7d_oi-status", "rejected") + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(reset.Unix(), 10)) + + limit := selectAnthropicFableWindowLimit(headers, now) + if limit == nil { + t.Fatal("expected non-nil limit via aggregate reset fallback") + } + if !limit.resetAt.Equal(reset) { + t.Errorf("expected resetAt=%v, got %v", reset, limit.resetAt) + } +} + +func TestSelectAnthropicFableWindowLimit_RejectedWithoutAnyResetReturnsNil(t *testing.T) { + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-7d_oi-status", "rejected") + + if limit := selectAnthropicFableWindowLimit(headers, time.Now()); limit != nil { + t.Errorf("expected nil limit when no reset time available, got %+v", limit) + } +} + +func TestParseAnthropicAggregateReset(t *testing.T) { + now := time.Now() + future := now.Add(80 * time.Hour).Truncate(time.Second) + + tests := []struct { + name string + value string + want time.Time + wantOK bool + }{ + {"valid seconds", strconv.FormatInt(future.Unix(), 10), future, true}, + {"valid milliseconds", strconv.FormatInt(future.UnixMilli(), 10), future, true}, + {"empty", "", time.Time{}, false}, + {"garbage", "abc", time.Time{}, false}, + {"in the past", strconv.FormatInt(now.Add(-time.Hour).Unix(), 10), time.Time{}, false}, + {"too far in the future", strconv.FormatInt(now.Add(30*24*time.Hour).Unix(), 10), time.Time{}, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + headers := http.Header{} + if tc.value != "" { + headers.Set("anthropic-ratelimit-unified-reset", tc.value) + } + got, ok := parseAnthropicAggregateReset(headers, now) + if ok != tc.wantOK { + t.Fatalf("expected ok=%v, got %v", tc.wantOK, ok) + } + if ok && !got.Equal(tc.want) { + t.Errorf("expected %v, got %v", tc.want, got) + } + }) + } +} + +func TestIsAnthropicWindowRejected(t *testing.T) { + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-7d_oi-status", "Rejected") + headers.Set("anthropic-ratelimit-unified-5h-status", "allowed") + + if !isAnthropicWindowRejected(headers, "7d_oi") { + t.Error("expected 7d_oi to be rejected (case insensitive)") + } + if isAnthropicWindowRejected(headers, "5h") { + t.Error("expected 5h not rejected") + } + if isAnthropicWindowRejected(headers, "7d") { + t.Error("expected missing 7d status not rejected") + } +} + // assertAnthropicResult is a test helper that verifies the result is non-nil and // has the expected resetAt unix timestamp. func assertAnthropicResult(t *testing.T, result *anthropic429Result, wantUnix int64) { diff --git a/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go b/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go index 6168b250a6..a13e8fe333 100644 --- a/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go +++ b/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go @@ -14,9 +14,14 @@ import ( type anthropicWindowLimitRepo struct { mockAccountRepoForGemini - rateLimitCalls int - tempUnschedCalls int - lastRateLimitReset time.Time + rateLimitCalls int + tempUnschedCalls int + lastRateLimitReset time.Time + modelRateLimitCalls int + lastModelRateLimitScope string + lastModelRateLimitReset time.Time + sessionWindowCalls int + lastExtraUpdates map[string]any } func (r *anthropicWindowLimitRepo) SetRateLimited(_ context.Context, _ int64, resetAt time.Time) error { @@ -30,6 +35,23 @@ func (r *anthropicWindowLimitRepo) SetTempUnschedulable(_ context.Context, _ int return nil } +func (r *anthropicWindowLimitRepo) SetModelRateLimit(_ context.Context, _ int64, scope string, resetAt time.Time, _ ...string) error { + r.modelRateLimitCalls++ + r.lastModelRateLimitScope = scope + r.lastModelRateLimitReset = resetAt + return nil +} + +func (r *anthropicWindowLimitRepo) UpdateSessionWindow(_ context.Context, _ int64, _, _ *time.Time, _ string) error { + r.sessionWindowCalls++ + return nil +} + +func (r *anthropicWindowLimitRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error { + r.lastExtraUpdates = updates + return nil +} + func TestHandleUpstreamError_AnthropicWindowLimitPreemptsTempUnschedRule(t *testing.T) { resetAt := time.Now().Add(3 * time.Hour).Truncate(time.Second) headers := http.Header{} @@ -66,3 +88,142 @@ func TestHandleUpstreamError_AnthropicWindowLimitPreemptsTempUnschedRule(t *test require.Equal(t, 1, repo.rateLimitCalls) require.Equal(t, resetAt, repo.lastRateLimitReset) } + +// fable429Headers 构造 7d_oi(Fable 专属 7d 窗口)触发 429 的完整响应头, +// 数值取自真实抓包(5h/7d 均 allowed,仅 7d_oi rejected)。 +func fable429Headers(reset5h, resetOI time.Time) http.Header { + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-reset", strconv.FormatInt(reset5h.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-5h-status", "allowed") + headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.41") + headers.Set("anthropic-ratelimit-unified-7d-reset", strconv.FormatInt(resetOI.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-7d-status", "allowed") + headers.Set("anthropic-ratelimit-unified-7d-utilization", "0.56") + headers.Set("anthropic-ratelimit-unified-7d_oi-reset", strconv.FormatInt(resetOI.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-7d_oi-status", "rejected") + headers.Set("anthropic-ratelimit-unified-7d_oi-surpassed-threshold", "1.0") + headers.Set("anthropic-ratelimit-unified-7d_oi-utilization", "1.0") + headers.Set("anthropic-ratelimit-unified-fallback-percentage", "0.5") + headers.Set("anthropic-ratelimit-unified-overage-disabled-reason", "org_level_disabled") + headers.Set("anthropic-ratelimit-unified-overage-status", "rejected") + headers.Set("anthropic-ratelimit-unified-representative-claim", "seven_day_overage_included") + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(resetOI.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-status", "rejected") + return headers +} + +func TestHandleUpstreamError_Anthropic7dOiOnlyMarksModelRateLimit(t *testing.T) { + now := time.Now() + reset5h := now.Add(2 * time.Hour).Truncate(time.Second) + resetOI := now.Add(80 * time.Hour).Truncate(time.Second) + headers := fable429Headers(reset5h, resetOI) + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ + ID: 42, + Type: AccountTypeOAuth, + Platform: PlatformAnthropic, + Credentials: map[string]any{ + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{ + map[string]any{ + "error_code": float64(http.StatusTooManyRequests), + "keywords": []any{"rate limit"}, + "duration_minutes": float64(10), + }, + }, + }, + } + + shouldDisable := svc.HandleUpstreamError( + context.Background(), + account, + http.StatusTooManyRequests, + headers, + []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."}}`), + "claude-fable-5", + ) + + require.False(t, shouldDisable) + require.Zero(t, repo.rateLimitCalls, "7d_oi (Fable-only) window must not mark the whole account rate limited") + require.Zero(t, repo.tempUnschedCalls, "7d_oi window must not trigger local temp-unsched rules") + require.Zero(t, repo.sessionWindowCalls, "7d_oi window must not rewrite the 5h session window as rejected") + require.Equal(t, 1, repo.modelRateLimitCalls) + require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope) + require.Equal(t, resetOI, repo.lastModelRateLimitReset) + + // 429 响应头也要被动采样,避免 7d F 进度条在限流期内冻结在旧值 + require.NotNil(t, repo.lastExtraUpdates) + require.Equal(t, 1.0, repo.lastExtraUpdates["passive_usage_7d_oi_utilization"]) + require.Equal(t, resetOI.Unix(), repo.lastExtraUpdates["passive_usage_7d_oi_reset"]) + require.Equal(t, 0.41, repo.lastExtraUpdates["session_window_utilization"]) +} + +func TestHandleUpstreamError_Anthropic5hWindowStillWinsOver7dOi(t *testing.T) { + // 5h 窗口 rejected 时必须仍按账号级限流处理(用 5h reset),同时记录 Fable 模型限流。 + now := time.Now() + reset5h := now.Add(2 * time.Hour).Truncate(time.Second) + resetOI := now.Add(80 * time.Hour).Truncate(time.Second) + headers := fable429Headers(reset5h, resetOI) + headers.Set("anthropic-ratelimit-unified-5h-status", "rejected") + headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.0") + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, nil, "claude-fable-5") + + require.Equal(t, 1, repo.rateLimitCalls, "exhausted 5h window must still rate limit the account") + require.Equal(t, reset5h, repo.lastRateLimitReset) + require.Equal(t, 1, repo.modelRateLimitCalls) + require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope) +} + +func TestHandleUpstreamError_AnthropicAccountWindowStillWinsOver7dOi(t *testing.T) { + // 7d 窗口真超限时必须仍按账号级限流处理,同时记录 Fable 模型限流。 + now := time.Now() + reset5h := now.Add(2 * time.Hour).Truncate(time.Second) + resetOI := now.Add(80 * time.Hour).Truncate(time.Second) + headers := fable429Headers(reset5h, resetOI) + headers.Set("anthropic-ratelimit-unified-7d-status", "rejected") + headers.Set("anthropic-ratelimit-unified-7d-utilization", "1.02") + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, nil, "claude-fable-5") + + require.Equal(t, 1, repo.rateLimitCalls, "exhausted 7d window must still rate limit the account") + require.Equal(t, resetOI, repo.lastRateLimitReset) + require.Equal(t, 1, repo.modelRateLimitCalls, "Fable model rate limit should also be recorded") + require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope) +} + +func TestHandleUpstreamError_Anthropic429Without7dOiKeepsLegacyBehavior(t *testing.T) { + // 无 7d_oi 头、5h/7d 均未超限的 429:保持旧行为(按较早 reset 标记账号限流)。 + now := time.Now() + reset5h := now.Add(2 * time.Hour).Truncate(time.Second) + reset7d := now.Add(80 * time.Hour).Truncate(time.Second) + + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-reset", strconv.FormatInt(reset5h.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-5h-status", "allowed") + headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.41") + headers.Set("anthropic-ratelimit-unified-7d-reset", strconv.FormatInt(reset7d.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-7d-status", "allowed") + headers.Set("anthropic-ratelimit-unified-7d-utilization", "0.56") + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, nil, "claude-fable-5") + + require.Zero(t, repo.modelRateLimitCalls, "no 7d_oi signal → no model rate limit") + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, reset5h, repo.lastRateLimitReset, "legacy path picks the sooner reset") + require.Equal(t, 1, repo.sessionWindowCalls) +} diff --git a/backend/internal/service/ratelimit_session_window_test.go b/backend/internal/service/ratelimit_session_window_test.go index 9337ae5f8f..cb19227e54 100644 --- a/backend/internal/service/ratelimit_session_window_test.go +++ b/backend/internal/service/ratelimit_session_window_test.go @@ -367,6 +367,59 @@ func TestUpdateSessionWindow_NoClearUtilizationOnCorrection(t *testing.T) { } } +func TestUpdateSessionWindow_SamplesFable7dOiHeaders(t *testing.T) { + // 被动采样应收集 7d_oi(Fable 专属 7d 窗口)的 utilization 和 reset。 + existingEnd := time.Now().Add(3 * time.Hour) + resetOIUnix := time.Now().Add(80 * time.Hour).Unix() + + repo := &sessionWindowMockRepo{} + svc := newRateLimitServiceForTest(repo) + + account := &Account{ID: 90, SessionWindowEnd: &existingEnd} // needInitWindow=false + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-status", "allowed") + headers.Set("anthropic-ratelimit-unified-7d_oi-utilization", "0.87") + headers.Set("anthropic-ratelimit-unified-7d_oi-reset", fmt.Sprintf("%d", resetOIUnix)) + + svc.UpdateSessionWindow(context.Background(), account, headers) + + if len(repo.updateExtraCalls) != 1 { + t.Fatalf("expected 1 UpdateExtra call, got %d", len(repo.updateExtraCalls)) + } + updates := repo.updateExtraCalls[0].Updates + if val, ok := updates["passive_usage_7d_oi_utilization"].(float64); !ok || val != 0.87 { + t.Errorf("expected passive_usage_7d_oi_utilization=0.87, got %v", updates["passive_usage_7d_oi_utilization"]) + } + if val, ok := updates["passive_usage_7d_oi_reset"].(int64); !ok || val != resetOIUnix { + t.Errorf("expected passive_usage_7d_oi_reset=%d, got %v", resetOIUnix, updates["passive_usage_7d_oi_reset"]) + } +} + +func TestUpdateSessionWindow_ClearsFable7dOiOnWindowReset(t *testing.T) { + // 5h 窗口重置时应连同清除 7d_oi 被动采样数据,与 7d 行为一致。 + resetUnix := time.Now().Add(3 * time.Hour).Unix() + + repo := &sessionWindowMockRepo{} + svc := newRateLimitServiceForTest(repo) + + account := &Account{ID: 91} // no existing window → needInitWindow=true + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-status", "allowed") + headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", resetUnix)) + + svc.UpdateSessionWindow(context.Background(), account, headers) + + if len(repo.updateExtraCalls) != 1 { + t.Fatalf("expected 1 UpdateExtra (clear) call, got %d", len(repo.updateExtraCalls)) + } + clearUpdates := repo.updateExtraCalls[0].Updates + for _, key := range []string{"passive_usage_7d_oi_utilization", "passive_usage_7d_oi_reset"} { + if val, present := clearUpdates[key]; !present || val != nil { + t.Errorf("expected %s cleared to nil on window reset, got present=%v val=%v", key, present, val) + } + } +} + func TestUpdateSessionWindow_NoStatusHeader(t *testing.T) { // Should return immediately if no status header. repo := &sessionWindowMockRepo{} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 1a5e677a12..1024243bea 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -1921,6 +1921,9 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting if err != nil { return nil, err } + if err := normalizeOpenAIAdvancedSchedulerOverrides(settings); err != nil { + return nil, err + } settings.PaymentVisibleMethodAlipaySource = alipaySource settings.PaymentVisibleMethodWxpaySource = wxpaySource settings.WeChatConnectAppID = strings.TrimSpace(settings.WeChatConnectAppID) @@ -2229,6 +2232,18 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting 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) @@ -2376,7 +2391,21 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { }) openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey) openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ - enabled: settings.OpenAIAdvancedSchedulerEnabled, + 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. @@ -3196,17 +3225,29 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { 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", + 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", } @@ -3769,6 +3810,29 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin 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" @@ -3841,6 +3905,93 @@ func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (s 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 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 + } + return nil +} + +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 == "" { diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index 379bf9bc04..a47e1379f7 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -49,6 +49,42 @@ func (s *settingUpdateRepoStub) Delete(ctx context.Context, key string) error { panic("unexpected Delete call") } +type settingGetAllRepoStub struct { + values map[string]string +} + +func (s *settingGetAllRepoStub) Get(ctx context.Context, key string) (*Setting, error) { + panic("unexpected Get call") +} + +func (s *settingGetAllRepoStub) GetValue(ctx context.Context, key string) (string, error) { + panic("unexpected GetValue call") +} + +func (s *settingGetAllRepoStub) Set(ctx context.Context, key, value string) error { + panic("unexpected Set call") +} + +func (s *settingGetAllRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + panic("unexpected GetMultiple call") +} + +func (s *settingGetAllRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { + panic("unexpected SetMultiple call") +} + +func (s *settingGetAllRepoStub) GetAll(ctx context.Context) (map[string]string, error) { + out := make(map[string]string, len(s.values)) + for key, value := range s.values { + out[key] = value + } + return out, nil +} + +func (s *settingGetAllRepoStub) Delete(ctx context.Context, key string) error { + panic("unexpected Delete call") +} + type settingAntigravityUARepoStub struct { values map[string]string } @@ -261,15 +297,30 @@ func TestSettingService_UpdateSettings_TablePreferences(t *testing.T) { } func TestSettingService_UpdateSettings_PaymentVisibleMethodsAndAdvancedScheduler(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + defer resetOpenAIAdvancedSchedulerSettingCacheForTest() + repo := &settingUpdateRepoStub{} svc := NewSettingService(repo, &config.Config{}) err := svc.UpdateSettings(context.Background(), &SystemSettings{ - PaymentVisibleMethodAlipaySource: "alipay", - PaymentVisibleMethodWxpaySource: "easypay", - PaymentVisibleMethodAlipayEnabled: true, - PaymentVisibleMethodWxpayEnabled: false, - OpenAIAdvancedSchedulerEnabled: true, + PaymentVisibleMethodAlipaySource: "alipay", + PaymentVisibleMethodWxpaySource: "easypay", + PaymentVisibleMethodAlipayEnabled: true, + PaymentVisibleMethodWxpayEnabled: false, + OpenAIAdvancedSchedulerEnabled: true, + OpenAIAdvancedSchedulerStickyWeightedEnabled: true, + OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: true, + OpenAIAdvancedSchedulerLBTopK: " 3 ", + OpenAIAdvancedSchedulerWeightPriority: "2.50", + OpenAIAdvancedSchedulerWeightLoad: "0", + OpenAIAdvancedSchedulerWeightQueue: "0.75", + OpenAIAdvancedSchedulerWeightErrorRate: "1.25", + OpenAIAdvancedSchedulerWeightTTFT: "0.5", + OpenAIAdvancedSchedulerWeightReset: "", + OpenAIAdvancedSchedulerWeightQuotaHeadroom: "0.2", + OpenAIAdvancedSchedulerWeightPreviousResponse: "8", + OpenAIAdvancedSchedulerWeightSessionSticky: "4", }) require.NoError(t, err) require.Equal(t, VisibleMethodSourceOfficialAlipay, repo.updates[SettingPaymentVisibleMethodAlipaySource]) @@ -277,6 +328,49 @@ func TestSettingService_UpdateSettings_PaymentVisibleMethodsAndAdvancedScheduler require.Equal(t, "true", repo.updates[SettingPaymentVisibleMethodAlipayEnabled]) require.Equal(t, "false", repo.updates[SettingPaymentVisibleMethodWxpayEnabled]) require.Equal(t, "true", repo.updates[openAIAdvancedSchedulerSettingKey]) + require.Equal(t, "true", repo.updates[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled]) + require.Equal(t, "true", repo.updates[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled]) + require.Equal(t, "3", repo.updates[SettingKeyOpenAIAdvancedSchedulerLBTopK]) + require.Equal(t, "2.5", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightPriority]) + require.Equal(t, "0", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightLoad]) + require.Equal(t, "0.75", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightQueue]) + require.Equal(t, "1.25", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate]) + require.Equal(t, "0.5", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightTTFT]) + require.Equal(t, "", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightReset]) + require.Equal(t, "0.2", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom]) + require.Equal(t, "8", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse]) + require.Equal(t, "4", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky]) +} + +func TestSettingService_GetAllSettings_OpenAIAdvancedSchedulerEffectiveValuesUseConfig(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.LBTopK = 13 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights = config.GatewayOpenAIWSSchedulerScoreWeights{ + Priority: 2, + Load: 3, + Queue: 4, + ErrorRate: 5, + TTFT: 6, + Reset: 7, + QuotaHeadroom: 8, + PreviousResponse: 9, + SessionSticky: 10, + } + svc := NewSettingService(&settingGetAllRepoStub{values: map[string]string{ + SettingKeyOpenAIAdvancedSchedulerLBTopK: "3", + SettingKeyOpenAIAdvancedSchedulerWeightPriority: "99", + SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: "88", + }}, cfg) + + settings, err := svc.GetAllSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, "3", settings.OpenAIAdvancedSchedulerLBTopK) + require.Equal(t, "99", settings.OpenAIAdvancedSchedulerWeightPriority) + require.Equal(t, "88", settings.OpenAIAdvancedSchedulerWeightSessionSticky) + require.Equal(t, "13", settings.OpenAIAdvancedSchedulerEffectiveLBTopK) + require.Equal(t, "2", settings.OpenAIAdvancedSchedulerEffectiveWeightPriority) + require.Equal(t, "3", settings.OpenAIAdvancedSchedulerEffectiveWeightLoad) + require.Equal(t, "10", settings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky) } func TestSettingService_UpdateSettings_AntigravityUserAgentVersion(t *testing.T) { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index ac225e1b14..465760a445 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -218,7 +218,29 @@ type SystemSettings struct { PaymentVisibleMethodWxpayEnabled bool // OpenAI 账号调度 - OpenAIAdvancedSchedulerEnabled bool + OpenAIAdvancedSchedulerEnabled bool + OpenAIAdvancedSchedulerStickyWeightedEnabled bool + OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool + OpenAIAdvancedSchedulerLBTopK string + OpenAIAdvancedSchedulerWeightPriority string + OpenAIAdvancedSchedulerWeightLoad string + OpenAIAdvancedSchedulerWeightQueue string + OpenAIAdvancedSchedulerWeightErrorRate string + OpenAIAdvancedSchedulerWeightTTFT string + OpenAIAdvancedSchedulerWeightReset string + OpenAIAdvancedSchedulerWeightQuotaHeadroom string + OpenAIAdvancedSchedulerWeightPreviousResponse string + OpenAIAdvancedSchedulerWeightSessionSticky string + OpenAIAdvancedSchedulerEffectiveLBTopK string + OpenAIAdvancedSchedulerEffectiveWeightPriority string + OpenAIAdvancedSchedulerEffectiveWeightLoad string + OpenAIAdvancedSchedulerEffectiveWeightQueue string + OpenAIAdvancedSchedulerEffectiveWeightErrorRate string + OpenAIAdvancedSchedulerEffectiveWeightTTFT string + OpenAIAdvancedSchedulerEffectiveWeightReset string + OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom string + OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse string + OpenAIAdvancedSchedulerEffectiveWeightSessionSticky string // 余额不足提醒 BalanceLowNotifyEnabled bool diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go index 08761f8220..2d4a026692 100644 --- a/backend/internal/service/token_refresh_service.go +++ b/backend/internal/service/token_refresh_service.go @@ -312,6 +312,7 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc if isNonRetryableRefreshError(err) { errorMsg := "Token refresh failed (non-retryable): " + logredact.RedactText(err.Error()) s.notifyAccountSchedulingBlocked(account, time.Time{}, "token_refresh_non_retryable") + s.clearAntigravityForceTokenRefresh(ctx, account, "non_retryable") if setErr := s.accountRepo.SetError(ctx, account.ID, errorMsg); setErr != nil { slog.Error("token_refresh.set_error_status_failed", "account_id", account.ID, @@ -369,6 +370,8 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc // postRefreshActions 刷新成功后的后续动作(清除错误状态、缓存失效、调度器同步等) func (s *TokenRefreshService) postRefreshActions(ctx context.Context, account *Account) { + s.clearAntigravityForceTokenRefresh(ctx, account, "success") + // Antigravity 账户:如果之前是因为缺少 project_id 而标记为 error,现在成功获取到了,清除错误状态 if account.Platform == PlatformAntigravity && account.Status == StatusError && @@ -432,6 +435,30 @@ func (s *TokenRefreshService) postRefreshActions(ctx context.Context, account *A s.ensureAntigravityPrivacy(ctx, account) } +func (s *TokenRefreshService) clearAntigravityForceTokenRefresh(ctx context.Context, account *Account, outcome string) { + if s == nil || account == nil || !accountNeedsAntigravityForceTokenRefresh(account) { + return + } + updates := clearAntigravityForceTokenRefreshExtra() + if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil { + slog.Warn("token_refresh.clear_antigravity_force_refresh_failed", + "account_id", account.ID, + "outcome", outcome, + "error", err, + ) + return + } + if account.Extra != nil { + for k, v := range updates { + account.Extra[k] = v + } + } + slog.Info("token_refresh.cleared_antigravity_force_refresh", + "account_id", account.ID, + "outcome", outcome, + ) +} + // errRefreshSkipped 表示刷新被跳过(锁竞争或已被其他路径刷新),不计入 failed 或 refreshed var errRefreshSkipped = fmt.Errorf("refresh skipped") @@ -446,6 +473,7 @@ func isNonRetryableRefreshError(err error) bool { nonRetryable := []string{ "invalid_grant", // refresh_token 已失效 "invalid_refresh_token", // refresh_token 无效, team 账号工作区被删除会出现 + "token_expired", // OpenAI refresh_token 已过期,需要重新授权 "app_session_terminated", // refresh_token team 账号工作区被删除 "refresh_token_reused", // OpenAI refresh_token 已被使用,必须重新授权 "refresh_token_invalidated", // OpenAI session ended; refresh token invalidated diff --git a/backend/internal/service/token_refresh_service_test.go b/backend/internal/service/token_refresh_service_test.go index d2315c1db5..bd6a521576 100644 --- a/backend/internal/service/token_refresh_service_test.go +++ b/backend/internal/service/token_refresh_service_test.go @@ -20,8 +20,10 @@ type tokenRefreshAccountRepo struct { setErrorCalls int clearTempCalls int setTempUnschedCalls int + updateExtraCalls int lastErrorMessage string lastTempUnschedReason string + lastExtraUpdates map[string]any lastAccount *Account updateErr error } @@ -68,6 +70,22 @@ func (r *tokenRefreshAccountRepo) SetTempUnschedulable(ctx context.Context, id i return nil } +func (r *tokenRefreshAccountRepo) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error { + r.updateExtraCalls++ + r.lastExtraUpdates = shallowCopyMap(updates) + if r.accountsByID != nil { + if acc, ok := r.accountsByID[id]; ok && acc != nil { + if acc.Extra == nil { + acc.Extra = make(map[string]any, len(updates)) + } + for k, v := range updates { + acc.Extra[k] = v + } + } + } + return nil +} + type tokenCacheInvalidatorStub struct { calls int err error @@ -233,6 +251,121 @@ func TestTokenRefreshService_RefreshWithRetry_Antigravity(t *testing.T) { require.Equal(t, 1, invalidator.calls) // Antigravity 也应触发缓存失效 } +func TestAntigravityTokenRefresher_NeedsRefresh_ForceRefreshMarker(t *testing.T) { + refresher := NewAntigravityTokenRefresher(nil) + account := &Account{ + ID: 3675, + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "expires_at": time.Now().Add(time.Hour).Format(time.RFC3339), + }, + Extra: map[string]any{ + antigravityForceTokenRefreshExtraKey: true, + }, + } + + require.True(t, refresher.NeedsRefresh(account, 0), "server-invalidated token must refresh even before expires_at") +} + +func TestAntigravityTokenRefresher_NeedsRefresh_NormalExpiryRulesUnchanged(t *testing.T) { + refresher := NewAntigravityTokenRefresher(nil) + + t.Run("normal_unexpired_without_marker_does_not_refresh", func(t *testing.T) { + account := &Account{ + ID: 3707, + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "expires_at": time.Now().Add(time.Hour).Format(time.RFC3339), + }, + } + + require.False(t, refresher.NeedsRefresh(account, 0)) + }) + + t.Run("normal_expiring_refreshes", func(t *testing.T) { + account := &Account{ + ID: 3708, + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "expires_at": time.Now().Add(5 * time.Minute).Format(time.RFC3339), + }, + } + + require.True(t, refresher.NeedsRefresh(account, 0)) + }) +} + +func TestTokenRefreshService_RefreshWithRetry_AntigravityClearsForceRefreshOnSuccess(t *testing.T) { + repo := &tokenRefreshAccountRepo{} + cfg := &config.Config{ + TokenRefresh: config.TokenRefreshConfig{ + MaxRetries: 1, + RetryBackoffSeconds: 0, + }, + } + service := NewTokenRefreshService(repo, nil, nil, nil, nil, nil, nil, cfg, nil) + until := time.Now().Add(10 * time.Minute) + account := &Account{ + ID: 3709, + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + TempUnschedulableUntil: &until, + Extra: map[string]any{ + antigravityForceTokenRefreshExtraKey: true, + antigravityForceTokenRefreshReasonExtraKey: "401_invalid", + "privacy_mode": AntigravityPrivacySet, + }, + } + refresher := &tokenRefresherStub{ + credentials: map[string]any{ + "access_token": "new-ag-token", + }, + } + + err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour) + require.NoError(t, err) + require.Equal(t, 1, repo.updateCredentialsCalls) + require.Equal(t, 1, repo.updateExtraCalls) + require.Equal(t, false, repo.lastExtraUpdates[antigravityForceTokenRefreshExtraKey]) + require.Equal(t, "", repo.lastExtraUpdates[antigravityForceTokenRefreshReasonExtraKey]) + require.Equal(t, false, account.Extra[antigravityForceTokenRefreshExtraKey]) + require.Equal(t, 1, repo.clearTempCalls, "successful refresh should restore schedulability") +} + +func TestTokenRefreshService_RefreshWithRetry_AntigravityForceRefreshInvalidGrantSetsError(t *testing.T) { + repo := &tokenRefreshAccountRepo{} + cfg := &config.Config{ + TokenRefresh: config.TokenRefreshConfig{ + MaxRetries: 3, + RetryBackoffSeconds: 0, + }, + } + service := NewTokenRefreshService(repo, nil, nil, nil, nil, nil, nil, cfg, nil) + account := &Account{ + ID: 3710, + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Extra: map[string]any{ + antigravityForceTokenRefreshExtraKey: true, + antigravityForceTokenRefreshReasonExtraKey: "401_invalid", + }, + } + refresher := &tokenRefresherStub{ + err: errors.New("invalid_grant: token revoked"), + } + + err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour) + require.Error(t, err) + require.Equal(t, 1, repo.setErrorCalls) + require.Equal(t, 0, repo.setTempUnschedCalls) + require.Equal(t, 1, repo.updateExtraCalls) + require.Equal(t, false, repo.lastExtraUpdates[antigravityForceTokenRefreshExtraKey]) + require.Contains(t, repo.lastErrorMessage, "non-retryable") +} + // TestTokenRefreshService_RefreshWithRetry_NonOAuthAccount 测试非 OAuth 账号不触发缓存失效 func TestTokenRefreshService_RefreshWithRetry_NonOAuthAccount(t *testing.T) { repo := &tokenRefreshAccountRepo{} @@ -541,6 +674,7 @@ func TestIsNonRetryableRefreshError(t *testing.T) { {name: "invalid_grant", err: errors.New("invalid_grant"), expected: true}, {name: "invalid_client", err: errors.New("invalid_client"), expected: true}, {name: "invalid_refresh_token", err: errors.New(`OPENAI_OAUTH_TOKEN_REFRESH_FAILED: token refresh failed: status 401, body: {"error":{"code":"invalid_refresh_token"}}`), expected: true}, + {name: "token_expired", err: errors.New(`OPENAI_OAUTH_TOKEN_REFRESH_FAILED: token refresh failed: status 401, body: {"error":{"code":"token_expired"}}`), expected: true}, {name: "refresh_token_reused", err: errors.New(`OPENAI_OAUTH_TOKEN_REFRESH_FAILED: token refresh failed: status 401, body: {"error":{"code":"refresh_token_reused"}}`), expected: true}, {name: "app_session_terminated", err: errors.New(`OPENAI_OAUTH_TOKEN_REFRESH_FAILED: token refresh failed: status 401, body: {"error": {"code": "app_session_terminated"}}`), expected: true}, {name: "unauthorized_client", err: errors.New("unauthorized_client"), expected: true}, diff --git a/backend/internal/service/usage_record_worker_pool.go b/backend/internal/service/usage_record_worker_pool.go index 5da0b89023..bb5ae452c8 100644 --- a/backend/internal/service/usage_record_worker_pool.go +++ b/backend/internal/service/usage_record_worker_pool.go @@ -15,10 +15,11 @@ import ( ) const ( - defaultUsageRecordWorkerCount = 128 - defaultUsageRecordQueueSize = 16384 - defaultUsageRecordTaskTimeoutSeconds = 5 - defaultUsageRecordOverflowPolicy = config.UsageRecordOverflowPolicySample + defaultUsageRecordWorkerCount = 128 + defaultUsageRecordQueueSize = 16384 + defaultUsageRecordTaskTimeoutSeconds = 5 + // 默认 sync:溢出时提交方内联执行,保证计费任务不被静默丢弃(issue #3656)。 + defaultUsageRecordOverflowPolicy = config.UsageRecordOverflowPolicySync defaultUsageRecordOverflowSampleRatio = 10 defaultUsageRecordAutoScaleEnabled = true defaultUsageRecordAutoScaleMinWorkers = 128 diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index 51baf3dfe6..a2c4e2847a 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -28,6 +28,7 @@ const ( InstallLockFile = ".installed" defaultUserConcurrency = 5 simpleModeAdminConcurrency = 30 + defaultMigrationTimeout = 60 * time.Second ) func setupDefaultAdminConcurrency() int { @@ -73,12 +74,13 @@ func GetInstallLockPath() string { // SetupConfig holds the setup configuration type SetupConfig struct { - Database DatabaseConfig `json:"database" yaml:"database"` - Redis RedisConfig `json:"redis" yaml:"redis"` - Admin AdminConfig `json:"admin" yaml:"-"` // Not stored in config file - Server ServerConfig `json:"server" yaml:"server"` - JWT JWTConfig `json:"jwt" yaml:"jwt"` - Timezone string `json:"timezone" yaml:"timezone"` // e.g. "Asia/Shanghai", "UTC" + Database DatabaseConfig `json:"database" yaml:"database"` + Redis RedisConfig `json:"redis" yaml:"redis"` + Admin AdminConfig `json:"admin" yaml:"-"` // Not stored in config file + Server ServerConfig `json:"server" yaml:"server"` + JWT JWTConfig `json:"jwt" yaml:"jwt"` + Timezone string `json:"timezone" yaml:"timezone"` // e.g. "Asia/Shanghai", "UTC" + MigrationTimeoutSeconds int `json:"migration_timeout_seconds" yaml:"migration_timeout_seconds,omitempty"` } type DatabaseConfig struct { @@ -350,11 +352,18 @@ func initializeDatabase(cfg *SetupConfig) error { } }() - migrationCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + migrationCtx, cancel := context.WithTimeout(context.Background(), cfg.migrationTimeout()) defer cancel() return repository.ApplyMigrations(migrationCtx, db) } +func (cfg *SetupConfig) migrationTimeout() time.Duration { + if cfg != nil && cfg.MigrationTimeoutSeconds > 0 { + return time.Duration(cfg.MigrationTimeoutSeconds) * time.Second + } + return defaultMigrationTimeout +} + func createAdminUser(cfg *SetupConfig) (bool, string, error) { dsn := fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", @@ -578,7 +587,8 @@ func AutoSetupFromEnv() error { Secret: getEnvOrDefault("JWT_SECRET", ""), ExpireHour: getEnvIntOrDefault("JWT_EXPIRE_HOUR", 24), }, - Timezone: tz, + Timezone: tz, + MigrationTimeoutSeconds: getEnvIntOrDefault("SETUP_MIGRATION_TIMEOUT_SECONDS", 0), } // Generate JWT secret if not provided diff --git a/backend/internal/setup/setup_test.go b/backend/internal/setup/setup_test.go index a2aa2f4cc1..b95c162bda 100644 --- a/backend/internal/setup/setup_test.go +++ b/backend/internal/setup/setup_test.go @@ -4,6 +4,7 @@ import ( "os" "strings" "testing" + "time" ) func TestDecideAdminBootstrap(t *testing.T) { @@ -70,6 +71,22 @@ func TestSetupDefaultAdminConcurrency(t *testing.T) { }) } +func TestSetupMigrationTimeout(t *testing.T) { + t.Run("uses default timeout when unset", func(t *testing.T) { + cfg := &SetupConfig{} + if got := cfg.migrationTimeout(); got != 60*time.Second { + t.Fatalf("migrationTimeout()=%s, want 60s", got) + } + }) + + t.Run("uses configured timeout", func(t *testing.T) { + cfg := &SetupConfig{MigrationTimeoutSeconds: 300} + if got := cfg.migrationTimeout(); got != 300*time.Second { + t.Fatalf("migrationTimeout()=%s, want 300s", got) + } + }) +} + func TestWriteConfigFileKeepsDefaultUserConcurrency(t *testing.T) { t.Setenv("RUN_MODE", "simple") t.Setenv("DATA_DIR", t.TempDir()) diff --git a/backend/internal/testutil/stubs.go b/backend/internal/testutil/stubs.go index bc572e1137..a0bd4bc669 100644 --- a/backend/internal/testutil/stubs.go +++ b/backend/internal/testutil/stubs.go @@ -76,6 +76,9 @@ func (c StubConcurrencyCache) GetAccountConcurrencyBatch(_ context.Context, acco func (c StubConcurrencyCache) CleanupExpiredAccountSlots(_ context.Context, _ int64) error { return nil } +func (c StubConcurrencyCache) CleanupExpiredAccountSlotKeys(_ context.Context) error { + return nil +} func (c StubConcurrencyCache) CleanupStaleProcessSlots(_ context.Context, _ string) error { return nil } diff --git a/deploy/.env.example b/deploy/.env.example index 59e4b44b91..5925f0abb4 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -196,6 +196,13 @@ JWT_EXPIRE_HOUR=24 # - =0: 回退使用 JWT_EXPIRE_HOUR JWT_ACCESS_TOKEN_EXPIRE_MINUTES=0 +# ----------------------------------------------------------------------------- +# Setup Configuration +# ----------------------------------------------------------------------------- +# Database migration timeout during initial setup, in seconds. +# Leave 0 to use the built-in default of 60 seconds. +SETUP_MIGRATION_TIMEOUT_SECONDS=0 + # ----------------------------------------------------------------------------- # TOTP (2FA) Configuration # TOTP(双因素认证)配置 @@ -352,11 +359,11 @@ DASHBOARD_AGGREGATION_RETENTION_DAILY_DAYS=730 # 启用 URL 白名单验证(false 则跳过白名单检查,仅做基本格式校验) SECURITY_URL_ALLOWLIST_ENABLED=false -# 关闭白名单时,是否允许 http:// URL(默认 false,只允许 https://) -# ⚠️ 警告:允许 HTTP 存在安全风险(明文传输),仅建议在开发/测试环境或可信内网中使用 -# Allow insecure HTTP URLs when allowlist is disabled (default: false, requires https) +# 关闭白名单时,是否允许 http:// URL(默认 true,设为 false 则只允许 https://) +# ⚠️ 警告:允许 HTTP 存在安全风险(明文传输),生产环境建议设为 false +# Allow insecure HTTP URLs when allowlist is disabled (default: true; set to false to require https) # ⚠️ WARNING: Allowing HTTP has security risks (plaintext transmission) -# Only recommended for dev/test environments or trusted networks +# Recommended to set false in production SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true # 是否允许本地/私有 IP 地址用于上游/定价/CRS(仅在可信网络中使用) diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 5a3afb0315..eff4bfb598 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -108,8 +108,8 @@ security: # Allow localhost/private IPs for upstream/pricing/CRS (use only in trusted networks) # 允许本地/私有 IP 地址用于上游/定价/CRS(仅在可信网络中使用) allow_private_hosts: true - # Allow http:// URLs when allowlist is disabled (default: false, require https) - # 白名单禁用时是否允许 http:// URL(默认: false,要求 https) + # Allow http:// URLs when allowlist is disabled (default: true; set to false to require https) + # 白名单禁用时是否允许 http:// URL(默认: true,设为 false 则仅允许 https) allow_insecure_http: true response_headers: # Enable configurable response header filtering (default: true) diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index e7b8b64c79..6f5b3f56f3 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -40,6 +40,7 @@ services: - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} - JWT_SECRET=${JWT_SECRET:-} + - SETUP_MIGRATION_TIMEOUT_SECONDS=${SETUP_MIGRATION_TIMEOUT_SECONDS:-0} - TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY:-} - TZ=${TZ:-Asia/Shanghai} # Local mainland-China development proxy. Containers cannot use diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index b15be2402d..042752e857 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -94,6 +94,11 @@ services: - JWT_SECRET=${JWT_SECRET:-} - JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24} + # ======================================================================= + # Setup Configuration + # ======================================================================= + - SETUP_MIGRATION_TIMEOUT_SECONDS=${SETUP_MIGRATION_TIMEOUT_SECONDS:-0} + # ======================================================================= # TOTP (2FA) Configuration # ======================================================================= @@ -134,10 +139,10 @@ services: # ======================================================================= # Enable URL allowlist validation (false to skip allowlist checks) - SECURITY_URL_ALLOWLIST_ENABLED=${SECURITY_URL_ALLOWLIST_ENABLED:-false} - # Allow insecure HTTP URLs when allowlist is disabled (default: false, requires https) - - SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=${SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP:-false} - # Allow private IP addresses for upstream/pricing/CRS (for internal deployments) - - SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false} + # Allow insecure HTTP URLs when allowlist is disabled (default: true; set to false to require https) + - SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=${SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP:-true} + # Allow private IP addresses for upstream/pricing/CRS (default: true; set to false to block private hosts) + - SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-true} # Upstream hosts whitelist (comma-separated, only used when enabled=true) - SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-} diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index 32afb28d6c..2e1d335624 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -76,6 +76,11 @@ services: - JWT_SECRET=${JWT_SECRET:-} - JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24} + # ======================================================================= + # Setup Configuration + # ======================================================================= + - SETUP_MIGRATION_TIMEOUT_SECONDS=${SETUP_MIGRATION_TIMEOUT_SECONDS:-0} + # ======================================================================= # Timezone Configuration # ======================================================================= diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index fd682a87e6..22713c59aa 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -90,6 +90,11 @@ services: - JWT_SECRET=${JWT_SECRET:-} - JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24} + # ======================================================================= + # Setup Configuration + # ======================================================================= + - SETUP_MIGRATION_TIMEOUT_SECONDS=${SETUP_MIGRATION_TIMEOUT_SECONDS:-0} + # ======================================================================= # TOTP (2FA) Configuration # ======================================================================= @@ -130,10 +135,10 @@ services: # ======================================================================= # Enable URL allowlist validation (false to skip allowlist checks) - SECURITY_URL_ALLOWLIST_ENABLED=${SECURITY_URL_ALLOWLIST_ENABLED:-false} - # Allow insecure HTTP URLs when allowlist is disabled (default: false, requires https) - - SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=${SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP:-false} - # Allow private IP addresses for upstream/pricing/CRS (for internal deployments) - - SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false} + # Allow insecure HTTP URLs when allowlist is disabled (default: true; set to false to require https) + - SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=${SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP:-true} + # Allow private IP addresses for upstream/pricing/CRS (default: true; set to false to block private hosts) + - SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-true} # Upstream hosts whitelist (comma-separated, only used when enabled=true) - SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-} diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index b777b22e8e..9dad8c1f12 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -57,6 +57,20 @@ if (typeof globalThis.cancelIdleCallback === 'undefined') { }) as unknown as typeof cancelIdleCallback } +// Mock matchMedia (jsdom 未实现;DataTable 等组件依赖它做桌面/移动分支) +if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') { + window.matchMedia = ((query: string) => ({ + matches: true, // 测试默认按桌面视口渲染表格 + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia +} + // Mock IntersectionObserver class MockIntersectionObserver { observe = vi.fn() diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index b3e53893ce..c7cbc64a4b 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -930,11 +930,16 @@ export interface OpsErrorLog { requested_model?: string upstream_model?: string request_type?: number | null + user_agent?: string + + // 已删除 KEY 所有者(INVALID_API_KEY 归因快照):认证失败行 user_id 为空, + // 用户列以此回退显示所有者 + deleted_key_owner_user_id?: number | null + deleted_key_owner_email?: string | null } export interface OpsErrorDetail extends OpsErrorLog { error_body: string - user_agent: string // Upstream context (optional; enriched by gateway services) upstream_status_code?: number | null @@ -950,10 +955,9 @@ export interface OpsErrorDetail extends OpsErrorLog { is_business_limited: boolean - // Deleted key owner info (INVALID_API_KEY attribution) + // Deleted key owner info (INVALID_API_KEY attribution); + // owner user_id/email 已上移到 OpsErrorLog(列表用户列回退) attempted_key_prefix?: string | null - deleted_key_owner_user_id?: number | null - deleted_key_owner_email?: string | null deleted_key_name?: string | null // Bound (non-deleted) key prefix, snapshotted at error time @@ -1098,6 +1102,8 @@ export type OpsErrorListQueryParams = { model?: string phase?: string + // 分类(用户侧粗分类码,如 auth/rate_limit/upstream),后端反查为 phase/type ANY 条件 + category?: string error_owner?: string error_source?: string resolved?: string @@ -1106,6 +1112,10 @@ export type OpsErrorListQueryParams = { q?: string status_codes?: string status_codes_other?: string + + // 服务端排序,列白名单见后端 opsErrorLogsOrderBy(created_at/model/status_code) + sort_by?: string + sort_order?: 'asc' | 'desc' } // Legacy unified endpoints diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 44fbe29187..457775870d 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -606,6 +606,28 @@ export interface SystemSettings { payment_visible_method_alipay_enabled?: boolean; payment_visible_method_wxpay_enabled?: boolean; openai_advanced_scheduler_enabled?: boolean; + openai_advanced_scheduler_sticky_weighted_enabled?: boolean; + openai_advanced_scheduler_subscription_priority_enabled?: boolean; + openai_advanced_scheduler_lb_top_k?: string; + openai_advanced_scheduler_weight_priority?: string; + openai_advanced_scheduler_weight_load?: string; + openai_advanced_scheduler_weight_queue?: string; + openai_advanced_scheduler_weight_error_rate?: string; + openai_advanced_scheduler_weight_ttft?: string; + openai_advanced_scheduler_weight_reset?: string; + openai_advanced_scheduler_weight_quota_headroom?: string; + openai_advanced_scheduler_weight_previous_response?: string; + openai_advanced_scheduler_weight_session_sticky?: string; + openai_advanced_scheduler_effective_lb_top_k?: string; + openai_advanced_scheduler_effective_weight_priority?: string; + openai_advanced_scheduler_effective_weight_load?: string; + openai_advanced_scheduler_effective_weight_queue?: string; + openai_advanced_scheduler_effective_weight_error_rate?: string; + openai_advanced_scheduler_effective_weight_ttft?: string; + openai_advanced_scheduler_effective_weight_reset?: string; + openai_advanced_scheduler_effective_weight_quota_headroom?: string; + openai_advanced_scheduler_effective_weight_previous_response?: string; + openai_advanced_scheduler_effective_weight_session_sticky?: string; // 余额、订阅到期与账号限额通知 balance_low_notify_enabled: boolean; @@ -855,6 +877,18 @@ export interface UpdateSettingsRequest { payment_visible_method_alipay_enabled?: boolean; payment_visible_method_wxpay_enabled?: boolean; openai_advanced_scheduler_enabled?: boolean; + openai_advanced_scheduler_sticky_weighted_enabled?: boolean; + openai_advanced_scheduler_subscription_priority_enabled?: boolean; + openai_advanced_scheduler_lb_top_k?: string; + openai_advanced_scheduler_weight_priority?: string; + openai_advanced_scheduler_weight_load?: string; + openai_advanced_scheduler_weight_queue?: string; + openai_advanced_scheduler_weight_error_rate?: string; + openai_advanced_scheduler_weight_ttft?: string; + openai_advanced_scheduler_weight_reset?: string; + openai_advanced_scheduler_weight_quota_headroom?: string; + openai_advanced_scheduler_weight_previous_response?: string; + openai_advanced_scheduler_weight_session_sticky?: string; // 余额、订阅到期与账号限额通知 balance_low_notify_enabled?: boolean; balance_low_notify_threshold?: number; diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index b37996d61a..83be033c11 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -86,6 +86,10 @@ export interface AdminUsageQueryParams extends UsageQueryParams { billing_mode?: string sort_by?: string sort_order?: 'asc' | 'desc' + // 错误请求 tab 专属筛选(仅传给错误列表接口;共用同一 filters 对象) + error_phase?: string | null + error_category?: string | null + status_code?: number | null } // ==================== API Functions ==================== diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 67d7b165dc..a4cb70a9c3 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -68,6 +68,15 @@ color="purple" /> + + +
- +
@@ -1370,7 +1370,7 @@
- +
- + - {{ codexImageGenerationBridgeBadgeLabel }} + {{ codexImageToolBadgeLabel }}

- {{ t('admin.accounts.openai.codexImageGenerationBridgeDesc') }} + {{ t('admin.accounts.openai.codexImageToolDesc') }}

-
+
- -
+ +
- -
+ +
+
+ + +
+ + +
+
@@ -156,12 +174,14 @@ {{ t('common.reset') }} - - +
@@ -172,6 +192,7 @@ import { ref, onMounted, onUnmounted, toRef, watch, computed } from 'vue' import { useI18n } from 'vue-i18n' import { adminAPI } from '@/api/admin' import Select, { type SelectOption } from '@/components/common/Select.vue' +import { COMMON_ERROR_STATUS_CODES } from '@/utils/errorBadges' import type { SimpleApiKey, SimpleUser } from '@/api/admin/usage' type ModelValue = Record @@ -183,10 +204,13 @@ interface Props { endDate: string showActions?: boolean modelOptions?: string[] + /** errors 模式:隐藏用量专属字段/按钮,显示错误类型+状态码(错误请求 tab 用) */ + mode?: 'usage' | 'errors' } const props = withDefaults(defineProps(), { - showActions: true + showActions: true, + mode: 'usage' }) const emit = defineEmits([ 'update:modelValue', @@ -243,6 +267,29 @@ const billingTypeOptions = ref([ { value: 1, label: t('admin.usage.billingTypeSubscription') } ]) +// 错误类型对应后端 phase 参数(与错误表"类型"徽章同语义) +const errorPhaseOptions = computed(() => [ + { value: null, label: t('admin.usage.allTypes') }, + { value: 'upstream', label: t('admin.ops.errorLog.typeUpstream') }, + { value: 'request', label: t('admin.ops.errorLog.typeRequest') }, + { value: 'auth', label: t('admin.ops.errorLog.typeAuth') }, + { value: 'routing', label: t('admin.ops.errorLog.typeRouting') }, + { value: 'internal', label: t('admin.ops.errorLog.typeInternal') }, +]) + +// 分类码同用户端 /usage 错误筛选;"other" 无法反查为过滤条件,刻意不列 +const errorCategoryCodes = ['auth', 'rate_limit', 'quota', 'invalid_request', 'service_unavailable', 'upstream', 'internal', 'cyber'] + +const errorCategoryOptions = computed(() => [ + { value: null, label: t('usage.errors.allCategories') }, + ...errorCategoryCodes.map((c) => ({ value: c, label: t('usage.errors.categories.' + c) })), +]) + +const statusCodeOptions = computed(() => [ + { value: null, label: t('usage.errors.allStatuses') }, + ...COMMON_ERROR_STATUS_CODES.map((c) => ({ value: c, label: String(c) })), +]) + const billingModeOptions = ref([ { value: null, label: t('admin.usage.allBillingModes') }, { value: 'token', label: t('admin.usage.billingModeToken') }, diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index c674ed4533..d5fe57145f 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -36,6 +36,8 @@ v-for="(row, index) in sortedData" :key="resolveRowKey(row, index)" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900" + :class="{ 'cursor-pointer': clickableRows }" + @click="clickableRows && emit('rowClick', row)" >
proxy4freeProxy4Free は開発者と AI アプリケーション向けのデータプロキシサービスプロバイダーで、住宅プロキシ、静的住宅プロキシ、ISP プロキシ、データセンタープロキシなど多様なプロキシソリューションを提供しており、Web Scraping、Browser Automation、AI Agent などのシナリオに適しています。グローバル IP リソース、安定した接続、柔軟な切り替えをサポートし、開発者のデータ収集成功率の向上と IP ブロックリスクの低減を支援します。こちらのリンクから登録して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。 +Proxy4Free のご支援に感謝します!Proxy4Free は開発者と AI アプリケーション向けのデータプロキシサービスプロバイダーで、住宅プロキシ、静的住宅プロキシ、ISP プロキシ、データセンタープロキシなど多様なプロキシソリューションを提供しており、Web Scraping、Browser Automation、AI Agent などのシナリオに適しています。グローバル IP リソース、安定した接続、柔軟な切り替えをサポートし、開発者のデータ収集成功率の向上と IP ブロックリスクの低減を支援します。こちらのリンクから登録して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。
() // 表格容器引用 @@ -386,6 +391,8 @@ interface Props { * will emit 'sort' events instead of performing client-side sorting. */ serverSideSort?: boolean + /** Emit 'rowClick' on row/card click and show pointer cursor (interactive cells should @click.stop) */ + clickableRows?: boolean /** Estimated row height in px for the virtualizer (default 56) */ estimateRowHeight?: number /** Number of rows to render beyond the visible area (default 5) */ diff --git a/frontend/src/components/common/IpGeoBatchToolbar.vue b/frontend/src/components/common/IpGeoBatchToolbar.vue new file mode 100644 index 0000000000..07199f1b2a --- /dev/null +++ b/frontend/src/components/common/IpGeoBatchToolbar.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index ceecdab7ee..8532ec4279 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -9,13 +9,21 @@