merge: sync batch image foundation with upstream main

This commit is contained in:
Turtle_Li
2026-07-06 13:40:09 +08:00
119 changed files with 7719 additions and 1389 deletions
+11 -24
View File
@@ -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)
<table>
@@ -140,7 +141,7 @@ Model authenticity: no content intervention or secondary filtering — experienc
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>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 <a href="https://www.proxy4free.com/?keyword=4yjqecpc">this link</a> to get started and easily build more stable and efficient automation workflows.
<td>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 <a href="https://www.proxy4free.com/?keyword=4yjqecpc">this link</a> to get started and easily build more stable and efficient automation workflows.
</td>
</tr>
@@ -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
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
+8 -20
View File
@@ -24,10 +24,11 @@
- **🚨 服务条款风险**:使用本项目可能违反 Anthropic 等上游服务商的服务条款。请在使用前仔细阅读相关服务商的用户协议,由此产生的一切风险由用户自行承担。
- **⚖️ 合规使用**:请在符合您所在国家或地区法律法规的前提下使用本项目,严禁将其用于任何违法违规用途。
- **📖 免责声明**:本项目仅供技术学习与研究使用,作者不对因使用本项目导致的账户封禁、服务中断、数据丢失或其他任何直接或间接损失承担责任。
- **🚫 无商业授权**:本项目从未授权任何个人或组织基于本项目开展任何形式的商业化运营。任何以本项目名义或基于本项目从事的商业行为均与本项目及其开发者无关,由此产生的一切纠纷、损失和法律责任由行为主体自行承担。
## ❤️ 赞助商
> [想出现在这里?](mailto:support@pincc.ai)
> [想出现在这里?](mailto:support@sub2api.org)
<table>
@@ -143,10 +144,11 @@
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>Proxy4Free 是面向开发者和 AI 应用的数据代理服务商,提供住宅代理、静态住宅代理、ISP 代理及数据中心代理等多种代理解决方案,适用于 Web Scraping、Browser Automation、AI Agent 等场景。支持全球 IP 资源、稳定连接与灵活切换,帮助开发者提升数据采集成功率,降低 IP 封禁风险。通过<a href="https://www.proxy4free.com/?keyword=4yjqecpc">此链接注册</a>即可开始体验,轻松构建更稳定、高效的自动化工作流。
<td>感谢 Proxy4Free 赞助本项目!Proxy4Free 是面向开发者和 AI 应用的数据代理服务商,提供住宅代理、静态住宅代理、ISP 代理及数据中心代理等多种代理解决方案,适用于 Web Scraping、Browser Automation、AI Agent 等场景。支持全球 IP 资源、稳定连接与灵活切换,帮助开发者提升数据采集成功率,降低 IP 封禁风险。通过<a href="https://www.proxy4free.com/?keyword=4yjqecpc">此链接注册</a>即可开始体验,轻松构建更稳定、高效的自动化工作流。
</td>
</tr>
</table>
## 项目概述
@@ -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
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
+7 -22
View File
@@ -23,10 +23,11 @@
- **🚨 利用規約のリスク**:本プロジェクトの使用は、Anthropic をはじめとする上流プロバイダーの利用規約に違反する可能性があります。ご利用前に各プロバイダーのユーザー規約を必ずご確認ください。使用により生じるすべてのリスクはユーザーご自身が負うものとします。
- **⚖️ 法令遵守**:お住まいの国または地域の法令を遵守した上で本プロジェクトをご利用ください。いかなる違法な目的での使用も固く禁じます。
- **📖 免責事項**:本プロジェクトは技術的な学習および研究の目的でのみ提供されます。本プロジェクトの使用により生じたアカウントの停止、サービスの中断、データの損失、その他一切の直接的または間接的な損害について、作者は一切の責任を負いません。
- **🚫 商用利用の非許諾**:本プロジェクトの開発者は、いかなる個人または組織に対しても、本プロジェクトを利用したいかなる形態の商業運営も一切許諾していません。本プロジェクトの名義で、または本プロジェクトに基づいて行われる商業行為はすべて本プロジェクトおよびその開発者とは無関係であり、それにより生じる一切の紛争、損失、法的責任は行為者自身が負うものとします。
## ❤️ スポンサー
> [こちらに掲載しませんか?](mailto:support@pincc.ai)
> [こちらに掲載しませんか?](mailto:support@sub2api.org)
<table>
@@ -138,7 +139,7 @@
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>Proxy4Free は開発者と AI アプリケーション向けのデータプロキシサービスプロバイダーで、住宅プロキシ、静的住宅プロキシ、ISP プロキシ、データセンタープロキシなど多様なプロキシソリューションを提供しており、Web Scraping、Browser Automation、AI Agent などのシナリオに適しています。グローバル IP リソース、安定した接続、柔軟な切り替えをサポートし、開発者のデータ収集成功率の向上と IP ブロックリスクの低減を支援します。<a href="https://www.proxy4free.com/?keyword=4yjqecpc">こちらのリンクから登録</a>して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。
<td>Proxy4Free のご支援に感謝します!Proxy4Free は開発者と AI アプリケーション向けのデータプロキシサービスプロバイダーで、住宅プロキシ、静的住宅プロキシ、ISP プロキシ、データセンタープロキシなど多様なプロキシソリューションを提供しており、Web Scraping、Browser Automation、AI Agent などのシナリオに適しています。グローバル IP リソース、安定した接続、柔軟な切り替えをサポートし、開発者のデータ収集成功率の向上と IP ブロックリスクの低減を支援します。<a href="https://www.proxy4free.com/?keyword=4yjqecpc">こちらのリンクから登録</a>して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。
</td>
</tr>
@@ -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: **免責事項**: 本プロジェクトは技術的な学習および研究目的のみで提供されています。作者は、本プロジェクトの使用によるアカウント停止、サービス中断、その他の損失について一切の責任を負いません。
---
## スター履歴
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
+1 -1
View File
@@ -1 +1 @@
0.1.143
0.1.144
+12 -2
View File
@@ -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 +
+2 -2
View File
@@ -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)
+8 -3
View File
@@ -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 映射
+22
View File
@@ -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()
@@ -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")
@@ -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{
@@ -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],
}
// 添加窗口费用(仅当启用时)
@@ -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)
}
@@ -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) {
+41 -8
View File
@@ -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)
+482 -361
View File
@@ -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
}
@@ -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) {
+23 -1
View File
@@ -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"`
+37 -1
View File
@@ -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) {
@@ -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()) {
@@ -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
}
@@ -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
}
@@ -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)
@@ -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),
@@ -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)
+98 -1
View File
@@ -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().
@@ -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
@@ -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)
+43
View File
@@ -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)).
@@ -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)
}
}
+54 -6
View File
@@ -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')")
}
@@ -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 {
@@ -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"))
}
+17 -10
View File
@@ -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)
}
})
}
@@ -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) {
+55 -9
View File
@@ -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": []
+68
View File
@@ -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
@@ -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
}
@@ -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")
}
}
@@ -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
@@ -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))
}
+23 -3
View File
@@ -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
}
@@ -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)
@@ -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
}
@@ -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() {
@@ -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
}
@@ -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:
}
}
}
@@ -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"
@@ -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
}
@@ -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) {
+10 -3
View File
@@ -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)
}
}
@@ -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 {
@@ -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)
}
@@ -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
@@ -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(""))
}
@@ -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 {
@@ -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 时不应影响打分")
}
@@ -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)
@@ -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
@@ -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{}
@@ -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,
@@ -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{
@@ -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)
+131 -83
View File
@@ -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"
@@ -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()
+34 -8
View File
@@ -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 {
+5 -3
View File
@@ -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 {
@@ -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,
}
}
+19 -2
View File
@@ -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,
}
}
@@ -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)
}
+17
View File
@@ -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,
@@ -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()
+150 -24
View File
@@ -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 清除账号的限流状态
@@ -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")
}
@@ -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) {
@@ -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)
}
@@ -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{}
+163 -12
View File
@@ -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 == "" {
@@ -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) {
+23 -1
View File
@@ -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
@@ -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
@@ -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},
@@ -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
+18 -8
View File
@@ -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
+17
View File
@@ -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())
+3
View File
@@ -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
}
+11 -4
View File
@@ -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(仅在可信网络中使用)
+2 -2
View File
@@ -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)
+1
View File
@@ -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
+9 -4
View File
@@ -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:-}
+5
View File
@@ -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
# =======================================================================
+9 -4
View File
@@ -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:-}
+14
View File
@@ -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()
+14 -4
View File
@@ -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
+34
View File
@@ -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;
+4
View File
@@ -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 ====================
@@ -68,6 +68,15 @@
color="purple"
/>
<!-- 7d Fable Window (7d_oi) -->
<UsageProgressBar
v-if="usageInfo.seven_day_fable"
label="7d F"
:utilization="usageInfo.seven_day_fable.utilization"
:resets-at="usageInfo.seven_day_fable.resets_at"
color="amber"
/>
<!-- Passive sampling label + active query button -->
<div class="flex items-center gap-1.5 mt-0.5">
<span
@@ -419,9 +419,9 @@
</div>
<!-- OpenAI OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
<!-- OpenAI/Grok OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
<div
v-if="account.platform === 'openai' && account.type === 'oauth'"
v-if="(account.platform === 'openai' || account.platform === 'grok') && account.type === 'oauth'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
@@ -1370,7 +1370,7 @@
</div>
</div>
<!-- OpenAI Codex 图片生成桥接账号级覆盖 -->
<!-- OpenAI Codex 图片工具统一策略(自动注入 + 客户端显式携带) -->
<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
@@ -1382,39 +1382,39 @@
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexImageGenerationBridge') }}</label>
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexImageTool') }}</label>
<span
class="rounded-full px-2 py-0.5 text-[11px] font-medium"
:class="codexImageGenerationBridgeBadgeClass"
:class="codexImageToolBadgeClass"
>
{{ codexImageGenerationBridgeBadgeLabel }}
{{ codexImageToolBadgeLabel }}
</span>
</div>
<p class="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">
{{ t('admin.accounts.openai.codexImageGenerationBridgeDesc') }}
{{ t('admin.accounts.openai.codexImageToolDesc') }}
</p>
</div>
</div>
<div class="border-t border-sky-100 bg-white/70 p-2 dark:border-sky-900/50 dark:bg-dark-800/70">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
v-for="option in codexImageGenerationBridgeOptions"
v-for="option in codexImageToolOptions"
:key="option.value"
type="button"
:data-testid="`codex-image-bridge-${option.value}`"
@click="codexImageGenerationBridgeMode = option.value"
:data-testid="`codex-image-tool-${option.value}`"
@click="codexImageToolMode = option.value"
:class="[
'group flex min-h-[68px] items-start gap-2 rounded-md border px-3 py-2 text-left transition-all',
codexImageGenerationBridgeMode === option.value
? 'border-sky-300 bg-sky-50 text-sky-900 shadow-sm ring-1 ring-sky-200 dark:border-sky-700 dark:bg-sky-900/25 dark:text-sky-100 dark:ring-sky-800'
'group flex min-h-[62px] items-start gap-2 rounded-md border px-3 py-2 text-left transition-all',
codexImageToolMode === option.value
? option.selectedCardClass
: 'border-transparent bg-transparent text-slate-600 hover:border-gray-200 hover:bg-gray-50 dark:text-slate-300 dark:hover:border-dark-500 dark:hover:bg-dark-700'
]"
>
<span
:class="[
'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border transition-colors',
codexImageGenerationBridgeMode === option.value
? 'border-sky-500 bg-sky-500 text-white'
codexImageToolMode === option.value
? option.selectedDotClass
: 'border-gray-300 text-transparent group-hover:border-gray-400 dark:border-dark-500'
]"
>
@@ -2627,8 +2627,8 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
const codexCLIOnlyAppServerEnabled = ref(false)
type CodexImageGenerationBridgeMode = 'inherit' | 'enabled' | 'disabled'
const codexImageGenerationBridgeMode = ref<CodexImageGenerationBridgeMode>('inherit')
type CodexImageToolMode = 'inherit' | 'enabled' | 'disabled' | 'block'
const codexImageToolMode = ref<CodexImageToolMode>('inherit')
type AnthropicAPIKeyAuthScheme = 'x_api_key' | 'authorization_bearer'
const anthropicPassthroughEnabled = ref(false)
const anthropicAPIKeyAuthScheme = ref<AnthropicAPIKeyAuthScheme>('x_api_key')
@@ -2682,42 +2682,61 @@ const openaiResponsesWebSocketV2Mode = computed({
const openAIWSModeConcurrencyHintKey = computed(() =>
resolveOpenAIWSModeConcurrencyHintKey(openaiResponsesWebSocketV2Mode.value)
)
const codexImageGenerationBridgeOptions = computed<Array<{
value: CodexImageGenerationBridgeMode
const codexImageToolOptions = computed<Array<{
value: CodexImageToolMode
label: string
description: string
selectedCardClass: string
selectedDotClass: string
}>>(() => [
{
value: 'inherit',
label: t('admin.accounts.openai.codexImageGenerationBridgeInherit'),
description: t('admin.accounts.openai.codexImageGenerationBridgeInheritDesc')
label: t('admin.accounts.openai.codexImageToolInherit'),
description: t('admin.accounts.openai.codexImageToolInheritDesc'),
selectedCardClass: 'border-sky-300 bg-sky-50 text-sky-900 shadow-sm ring-1 ring-sky-200 dark:border-sky-700 dark:bg-sky-900/25 dark:text-sky-100 dark:ring-sky-800',
selectedDotClass: 'border-sky-500 bg-sky-500 text-white'
},
{
value: 'enabled',
label: t('admin.accounts.openai.codexImageGenerationBridgeEnabled'),
description: t('admin.accounts.openai.codexImageGenerationBridgeEnabledDesc')
label: t('admin.accounts.openai.codexImageToolEnabled'),
description: t('admin.accounts.openai.codexImageToolEnabledDesc'),
selectedCardClass: 'border-emerald-300 bg-emerald-50 text-emerald-900 shadow-sm ring-1 ring-emerald-200 dark:border-emerald-700 dark:bg-emerald-900/25 dark:text-emerald-100 dark:ring-emerald-800',
selectedDotClass: 'border-emerald-500 bg-emerald-500 text-white'
},
{
value: 'disabled',
label: t('admin.accounts.openai.codexImageGenerationBridgeDisabled'),
description: t('admin.accounts.openai.codexImageGenerationBridgeDisabledDesc')
label: t('admin.accounts.openai.codexImageToolDisabled'),
description: t('admin.accounts.openai.codexImageToolDisabledDesc'),
selectedCardClass: 'border-amber-300 bg-amber-50 text-amber-900 shadow-sm ring-1 ring-amber-200 dark:border-amber-700 dark:bg-amber-900/25 dark:text-amber-100 dark:ring-amber-800',
selectedDotClass: 'border-amber-500 bg-amber-500 text-white'
},
{
value: 'block',
label: t('admin.accounts.openai.codexImageToolBlock'),
description: t('admin.accounts.openai.codexImageToolBlockDesc'),
selectedCardClass: 'border-rose-300 bg-rose-50 text-rose-900 shadow-sm ring-1 ring-rose-200 dark:border-rose-700 dark:bg-rose-900/25 dark:text-rose-100 dark:ring-rose-800',
selectedDotClass: 'border-rose-500 bg-rose-500 text-white'
}
])
const codexImageGenerationBridgeBadgeLabel = computed(() => {
switch (codexImageGenerationBridgeMode.value) {
const codexImageToolBadgeLabel = computed(() => {
switch (codexImageToolMode.value) {
case 'enabled':
return t('admin.accounts.openai.codexImageGenerationBridgeBadgeEnabled')
return t('admin.accounts.openai.codexImageToolBadgeEnabled')
case 'disabled':
return t('admin.accounts.openai.codexImageGenerationBridgeBadgeDisabled')
return t('admin.accounts.openai.codexImageToolBadgeDisabled')
case 'block':
return t('admin.accounts.openai.codexImageToolBadgeBlock')
default:
return t('admin.accounts.openai.codexImageGenerationBridgeBadgeInherit')
return t('admin.accounts.openai.codexImageToolBadgeInherit')
}
})
const codexImageGenerationBridgeBadgeClass = computed(() => {
switch (codexImageGenerationBridgeMode.value) {
const codexImageToolBadgeClass = computed(() => {
switch (codexImageToolMode.value) {
case 'enabled':
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
case 'disabled':
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
case 'block':
return 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300'
default:
return 'bg-slate-100 text-slate-600 dark:bg-dark-600 dark:text-slate-300'
@@ -3035,7 +3054,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
codexCLIOnlyAppServerEnabled.value = false
codexImageGenerationBridgeMode.value = 'inherit'
codexImageToolMode.value = 'inherit'
anthropicPassthroughEnabled.value = false
anthropicAPIKeyAuthScheme.value = 'x_api_key'
webSearchEmulationMode.value = 'default'
@@ -3054,10 +3073,12 @@ const syncFormFromAccount = (newAccount: Account | null) => {
const codexImageGenerationBridgeValue = typeof extra?.codex_image_generation_bridge === 'boolean'
? extra.codex_image_generation_bridge
: extra?.codex_image_generation_bridge_enabled
if (codexImageGenerationBridgeValue === true) {
codexImageGenerationBridgeMode.value = 'enabled'
if (extra?.codex_image_generation_explicit_tool_policy === 'strip') {
codexImageToolMode.value = 'block'
} else if (codexImageGenerationBridgeValue === true) {
codexImageToolMode.value = 'enabled'
} else if (codexImageGenerationBridgeValue === false) {
codexImageGenerationBridgeMode.value = 'disabled'
codexImageToolMode.value = 'disabled'
}
openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
modeKey: 'openai_oauth_responses_websockets_v2_mode',
@@ -3244,8 +3265,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
: 'https://api.anthropic.com'
editBaseUrl.value = platformDefaultUrl
// Load model mappings for OpenAI OAuth accounts
if (newAccount.platform === 'openai' && newAccount.credentials) {
// Load model mappings for OpenAI/Grok OAuth accounts
if ((newAccount.platform === 'openai' || newAccount.platform === 'grok') && newAccount.credentials) {
const oauthCredentials = newAccount.credentials as Record<string, unknown>
loadModelRestrictionFromMapping(oauthCredentials.model_mapping as Record<string, unknown> | undefined)
} else {
@@ -3972,14 +3993,23 @@ const handleSubmit = async () => {
updatePayload.credentials = newCredentials
}
// OpenAI OAuth: persist model mapping to credentials
if (props.account.platform === 'openai' && props.account.type === 'oauth') {
// OpenAI/Grok OAuth: persist model mapping to credentials
if ((props.account.platform === 'openai' || props.account.platform === 'grok') && props.account.type === 'oauth') {
const currentCredentials = isSparkShadow.value
? {}
: (updatePayload.credentials as Record<string, unknown>) ||
((props.account.credentials as Record<string, unknown>) || {})
const newCredentials: Record<string, unknown> = { ...currentCredentials }
applyOpenAIModelMappingCredentials(newCredentials)
if (props.account.platform === 'openai') {
applyOpenAIModelMappingCredentials(newCredentials)
} else {
const modelMapping = buildModelRestrictionMapping()
if (modelMapping) {
newCredentials.model_mapping = modelMapping
} else {
delete newCredentials.model_mapping
}
}
updatePayload.credentials = newCredentials
}
@@ -4194,10 +4224,19 @@ const handleSubmit = async () => {
}
delete newExtra.codex_image_generation_bridge_enabled
if (codexImageGenerationBridgeMode.value === 'inherit') {
delete newExtra.codex_image_generation_bridge
} else {
newExtra.codex_image_generation_bridge = codexImageGenerationBridgeMode.value === 'enabled'
switch (codexImageToolMode.value) {
case 'enabled':
case 'disabled':
newExtra.codex_image_generation_bridge = codexImageToolMode.value === 'enabled'
delete newExtra.codex_image_generation_explicit_tool_policy
break
case 'block':
newExtra.codex_image_generation_explicit_tool_policy = 'strip'
delete newExtra.codex_image_generation_bridge
break
default:
delete newExtra.codex_image_generation_bridge
delete newExtra.codex_image_generation_explicit_tool_policy
}
if (props.account.type === 'oauth' || props.account.type === 'setup-token') {
@@ -655,4 +655,102 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('A $0.00')
expect(wrapper.text()).toContain('U $0.00')
})
it('Anthropic OAuth 会渲染 7d F (Fable) 进度条,且 7d S 逻辑保留', async () => {
getUsage.mockResolvedValue({
source: 'passive',
five_hour: {
utilization: 41,
resets_at: '2026-07-03T10:00:00Z',
remaining_seconds: 3600
},
seven_day: {
utilization: 56,
resets_at: '2026-07-06T22:00:00Z',
remaining_seconds: 300000
},
seven_day_sonnet: {
utilization: 30,
resets_at: '2026-07-06T22:00:00Z',
remaining_seconds: 300000
},
seven_day_fable: {
utilization: 100,
resets_at: '2026-07-06T22:00:00Z',
remaining_seconds: 300000
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 3001,
platform: 'anthropic',
type: 'oauth',
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('5h|41')
expect(wrapper.text()).toContain('7d|56')
expect(wrapper.text()).toContain('7d S|30')
expect(wrapper.text()).toContain('7d F|100')
})
it('Anthropic OAuth 无 Fable 数据时不渲染 7d F 进度条', async () => {
getUsage.mockResolvedValue({
source: 'passive',
five_hour: {
utilization: 41,
resets_at: '2026-07-03T10:00:00Z',
remaining_seconds: 3600
},
seven_day: {
utilization: 56,
resets_at: '2026-07-06T22:00:00Z',
remaining_seconds: 300000
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 3002,
platform: 'anthropic',
type: 'oauth',
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('5h|41')
expect(wrapper.text()).toContain('7d|56')
expect(wrapper.text()).not.toContain('7d S')
expect(wrapper.text()).not.toContain('7d F')
})
})
@@ -241,6 +241,32 @@ function buildAntigravityAccount(projectId = 'configured-project') {
} as any
}
function buildGrokOAuthAccount() {
return {
id: 5,
name: 'Grok OAuth',
notes: '',
platform: 'grok',
type: 'oauth',
credentials: {
refresh_token: 'grok-rt',
base_url: 'https://api.x.ai/v1',
model_mapping: {
'grok-latest': 'grok-4.3'
}
},
extra: {},
proxy_id: null,
concurrency: 1,
priority: 1,
rate_multiplier: 1,
status: 'active',
group_ids: [],
expires_at: null,
auto_pause_on_expired: false
} as any
}
function buildOpenAISetupTokenAccount() {
return {
...buildAccount(),
@@ -357,6 +383,35 @@ describe('EditAccountModal', () => {
})
})
it('loads and submits Grok OAuth model mapping edits', async () => {
const account = buildGrokOAuthAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
expect(wrapper.text()).toContain('Imagine Image')
expect(wrapper.text()).toContain('Imagine Video')
const inputWithValue = (value: string) => {
const input = wrapper
.findAll('input')
.find((input) => (input.element as HTMLInputElement).value === value)
expect(input).toBeTruthy()
return input!
}
await inputWithValue('grok-latest').setValue('grok')
await inputWithValue('grok-4.3').setValue('grok-build-0.1')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.model_mapping).toEqual({
grok: 'grok-build-0.1'
})
})
it('only submits model mapping credentials when saving an OpenAI spark shadow account', async () => {
authIsSimpleMode.value = false
const account = buildOpenAISparkShadowAccount()
@@ -556,7 +611,7 @@ describe('EditAccountModal', () => {
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_supported).toBe(true)
})
it('submits account-level Codex image generation bridge override', async () => {
it('submits Codex image tool force-inject mode as bridge override', async () => {
const account = buildAccount()
account.extra = {
codex_image_generation_bridge: false,
@@ -569,12 +624,70 @@ describe('EditAccountModal', () => {
const wrapper = mountModal(account)
await wrapper.get('button[data-testid="codex-image-bridge-enabled"]').trigger('click')
await wrapper.get('button[data-testid="codex-image-tool-enabled"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.codex_image_generation_bridge).toBe(true)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge_enabled')
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_explicit_tool_policy')
})
it('submits Codex image tool no-injection mode without strip policy', async () => {
const account = buildAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('button[data-testid="codex-image-tool-disabled"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.codex_image_generation_bridge).toBe(false)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_explicit_tool_policy')
})
it('submits Codex image tool block mode as strip policy and clears bridge override', async () => {
const account = buildAccount()
account.extra = {
codex_image_generation_bridge: true
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('button[data-testid="codex-image-tool-block"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.codex_image_generation_explicit_tool_policy).toBe('strip')
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge')
})
it('loads strip policy as block mode and clears both keys when reset to inherit', async () => {
const account = buildAccount()
account.extra = {
codex_image_generation_explicit_tool_policy: 'strip'
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('button[data-testid="codex-image-tool-inherit"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_explicit_tool_policy')
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge')
})
it('setup-token account can select and submit OAuth WS mode', async () => {
@@ -121,24 +121,42 @@
</div>
</div>
<!-- Request Type Filter -->
<div class="w-full sm:w-auto sm:min-w-[180px]">
<!-- Request Type Filter (usage only) -->
<div v-if="mode !== 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('usage.type') }}</label>
<Select v-model="filters.request_type" :options="requestTypeOptions" @change="emitChange" />
</div>
<!-- Billing Type Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]">
<!-- Billing Type Filter (usage only) -->
<div v-if="mode !== 'errors'" class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.billingType') }}</label>
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
</div>
<!-- Billing Mode Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]">
<!-- Billing Mode Filter (usage only) -->
<div v-if="mode !== 'errors'" class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.billingMode') }}</label>
<Select v-model="filters.billing_mode" :options="billingModeOptions" @change="emitChange" />
</div>
<!-- Error Phase Filter (errors only) -->
<div v-if="mode === 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('admin.ops.errorLog.type') }}</label>
<Select v-model="filters.error_phase" :options="errorPhaseOptions" @change="emitChange" />
</div>
<!-- Error Category Filter (errors only) -->
<div v-if="mode === 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('usage.errors.category') }}</label>
<Select v-model="filters.error_category" :options="errorCategoryOptions" @change="emitChange" />
</div>
<!-- Status Code Filter (errors only) -->
<div v-if="mode === 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('admin.ops.errorLog.status') }}</label>
<Select v-model="filters.status_code" :options="statusCodeOptions" @change="emitChange" />
</div>
<!-- Group Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.group') }}</label>
@@ -156,12 +174,14 @@
{{ t('common.reset') }}
</button>
<slot name="after-reset" />
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
{{ t('admin.usage.cleanup.button') }}
</button>
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
{{ t('usage.exportExcel') }}
</button>
<template v-if="mode !== 'errors'">
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
{{ t('admin.usage.cleanup.button') }}
</button>
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
{{ t('usage.exportExcel') }}
</button>
</template>
</div>
</div>
</div>
@@ -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<string, any>
@@ -183,10 +204,13 @@ interface Props {
endDate: string
showActions?: boolean
modelOptions?: string[]
/** errors 模式:隐藏用量专属字段/按钮,显示错误类型+状态码(错误请求 tab 用) */
mode?: 'usage' | 'errors'
}
const props = withDefaults(defineProps<Props>(), {
showActions: true
showActions: true,
mode: 'usage'
})
const emit = defineEmits([
'update:modelValue',
@@ -243,6 +267,29 @@ const billingTypeOptions = ref<SelectOption[]>([
{ value: 1, label: t('admin.usage.billingTypeSubscription') }
])
// 错误类型对应后端 phase 参数(与错误表"类型"徽章同语义)
const errorPhaseOptions = computed<SelectOption[]>(() => [
{ 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<SelectOption[]>(() => [
{ value: null, label: t('usage.errors.allCategories') },
...errorCategoryCodes.map((c) => ({ value: c, label: t('usage.errors.categories.' + c) })),
])
const statusCodeOptions = computed<SelectOption[]>(() => [
{ value: null, label: t('usage.errors.allStatuses') },
...COMMON_ERROR_STATUS_CODES.map((c) => ({ value: c, label: String(c) })),
])
const billingModeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allBillingModes') },
{ value: 'token', label: t('admin.usage.billingModeToken') },

Some files were not shown because too many files have changed in this diff Show More