Compare commits

..
Author SHA1 Message Date
Wesley Liddick 325213ae66 Revert "fix(batch-image): scope access cache to the current user" 2026-09-15 08:59:52 +08:00
Wesley Liddick 0a215c9318 Merge pull request #7110 from ouchihao/codex/bug-batch-image-access-session
fix(batch-image): scope access cache to the current user
2026-09-15 08:59:23 +08:00
Wesley Liddick 2061508ad8 Merge pull request #7072 from Brisbanehuang/codex/fix-scheduler-metrics
fix(scheduler): 修复选号耗时返回值和粘性命中率重复计数
2026-09-15 08:58:58 +08:00
Wesley Liddick fb90414c05 Merge pull request #7126 from xO-ox-ai/codex/fix-responses-lite-namespace
fix(openai): 保留 Responses Lite namespace 工具调用
2026-09-15 08:58:25 +08:00
Wesley Liddick 9fade3e8c2 Merge pull request #6769 from alfadb/feature/ollama-rate-limit-reset
feat(ollama): 根据用量窗口异步更新限流恢复时间
2026-09-15 08:57:43 +08:00
Wesley Liddick 1920a7d687 Merge pull request #7091 from SavitarC/feat/subscription-bulk-actions
feat: 支持订阅管理批量操作
2026-09-15 08:57:27 +08:00
Wesley Liddick a444551a6d Merge pull request #6654 from ouchihao/codex/issue-2229-register-confirm-password
feat(auth): require password confirmation during registration
2026-09-15 08:56:56 +08:00
Wesley Liddick 0bba06d64a Merge pull request #6691 from yan9651688/feat/api-key-bulk-edit
feat(keys): add bulk editing to API key management
2026-09-15 08:56:07 +08:00
Wesley Liddick 150c52b8de Merge pull request #6689 from tunsuy/fix/antigravity-codex-web-search-mixed-tools
fix(antigravity): drop mixed web_search when client tools present
2026-09-15 08:54:40 +08:00
Wesley Liddick 3e4b6a09b2 Merge pull request #6925 from reed-yang/fix/recover-responses-text
fix(apicompat): recover Responses text from done and terminal events
2026-09-15 08:54:21 +08:00
Wesley Liddick dd2e6b3680 Merge pull request #7076 from Shen-An/fix/antigravity-refresh-warning
fix(admin): surface partial Antigravity refresh warnings
2026-09-15 08:54:06 +08:00
Wesley Liddick d4439129a7 Merge pull request #7094 from liuyuzhou666/fix/responses-chat-developer-role-mapping
fix(apicompat): 修复 Responses 降级 Chat Completions 时的 system 消息顺序问题
2026-09-15 08:53:50 +08:00
Wesley Liddick 1e1d15cca5 Merge pull request #7082 from ggllzz04/fix/antigravity-token-cache-isolation
fix(antigravity): isolate OAuth token cache key by account ID to prevent 429 collision
2026-09-15 08:53:32 +08:00
Wesley Liddick f7e959ae5f Merge pull request #7111 from ouchihao/codex/bug-proxy-clear-credentials
fix(proxy): allow explicitly clearing stored credentials
2026-09-15 08:52:58 +08:00
Wesley Liddick 008391f699 Merge pull request #7085 from r266-tech/codex/sub2api-f352188cb784
fix(openai): strip internal metadata from OAuth input items
2026-09-15 08:52:30 +08:00
Wesley Liddick a4c517917b Merge pull request #7073 from Brisbanehuang/codex/fix-batch-image-priority
fix(batch-image): 修正批量生图账号优先级排序方向
2026-09-15 08:52:19 +08:00
Wesley Liddick e9169901d2 Merge pull request #7112 from ouchihao/codex/bug-monitor-refresh-interval
fix(monitor): honor the selected auto-refresh interval
2026-09-15 08:52:00 +08:00
xO-ox-ai 13c1d4cde2 test(openai): 覆盖 Lite 命名空间工具回传 2026-09-14 11:46:41 +08:00
xO-ox-ai 4bd7132ba9 fix(openai): 保留 Responses Lite namespace 调用 2026-09-14 11:35:03 +08:00
chihao.ou 28a4ea914d fix(proxy): allow explicitly clearing stored credentials 2026-09-13 17:23:24 +08:00
chihao.ou 5ecfaa4837 fix(batch-image): scope access cache to the current user 2026-09-13 17:22:56 +08:00
chihao.ou 06af9f871c fix(monitor): honor the selected auto-refresh interval 2026-09-13 17:20:56 +08:00
liuyuzhou666 52558381c6 fix(apicompat): merge leading system messages and downgrade mid-conversation system from Responses bridge
The Responses to Chat Completions bridge mapped both the instructions field and role developer items to system messages. Codex always sends both, and also injects developer notices mid-conversation when switching models, so the forwarded request contained consecutive leading system messages and mid-conversation system messages. Strict Qwen-family upstreams reject this with HTTP 400 "System message must be at the beginning."

Leading system/developer messages are now merged into a single leading system message, and later system/developer messages are downgraded to user so the content is preserved without violating upstream role ordering.
2026-09-13 14:08:57 +08:00
SavitarC e3cce574d7 fix: serialize subscription extensions 2026-09-13 12:33:35 +08:00
SavitarC 3d6c207723 feat: 支持订阅管理批量操作 2026-09-13 11:49:03 +08:00
alfadb a1d5968b24 feat(ollama): async rate-limit reset for Ollama Cloud usage windows
Real Ollama Cloud 429s are driven by ollama.com usage windows, not by
response headers that OpenAI/Anthropic/CN rate-limit parsing understands.
Handle them separately: apply a never-shortening temporary cooldown first,
then schedule an async probe that learns the true usage-window reset point
with a controlled, coalesced per-api_key refresh.

- ratelimit_service: route real Ollama Cloud usage accounts to a dedicated
  429 path before the CN-provider branch.
- ollama_cloud_usage: add a background probe coordinator (started in Start,
  waited by Stop) that fans out model-429 probes to one controlled refresh
  per api_key group, reusing the recent group snapshot for coalesced members.
- repository/account_repo: add SetRateLimitedIfUnchanged, a write-back CAS
  counterpart to ClearRateLimitIfObserved, so an async reset never overwrites
  a newer 429, an admin clear, a re-armed generation, or a key/state change
  observed by another writer between read and write.
- wire: inject OllamaCloudUsageService into RateLimitService.

Tests: unit tests for service and repository pass; targeted race tests pass;
build and vet clean; git diff --check clean. The CAS integration test compiles
but is not run (no Docker available).
2026-09-13 10:41:47 +08:00
r266-tech e5272c130c test(openai): check input type before metadata assertion 2026-09-13 07:13:31 +08:00
r266-tech 196c15b5cd fix(openai): strip internal metadata from OAuth input items 2026-09-13 07:00:50 +08:00
郭兰鑫 62635532a6 fix(antigravity): explicitly invalidate legacy project-based token cache 2026-09-13 03:45:12 +08:00
郭兰鑫 bc8da78150 fix(antigravity): isolate OAuth token cache key by account ID to prevent collision 2026-09-13 03:40:47 +08:00
Brisbanehuang d2c2b46c9e fix(batch-image): 修正批量生图账号优先级排序方向
按较小 priority 优先选择账号,保留同优先级 ID 排序及现有资格过滤。补充选择回归,并将定价夹具对齐实际选中的账号,不改变计费公式。
2026-09-12 09:06:33 -04:00
Brisbanehuang 7e152e4279 fix(scheduler): 修复选号耗时返回值和粘性命中率重复计数
使用命名返回值传回 defer 中记录的选号耗时;按每次选号累计任意粘性命中,保留两类独立计数。增加成功、失败及双粘性回归,不改变调度策略。
2026-09-12 09:03:52 -04:00
Wesley Liddick bdb42e22f8 Merge pull request #7064 from xlplbo/codex/openai-ws-v2-pool-capacity
fix(openai-ws): 对齐新版 ctx_pool 连接容量计算
2026-09-12 20:13:03 +08:00
Wesley Liddick 9383e0b154 Merge pull request #7057 from Sjeary/codex/issue-7042-max-context-window
fix(codex): 保留本地模型目录的最大上下文窗口
2026-09-12 20:11:40 +08:00
Wesley Liddick c7ed614240 Merge pull request #7001 from SavitarC/feat/ops-token-stats-all-platforms
feat(ops): 扩展 Token 请求统计至所有平台
2026-09-12 19:34:45 +08:00
Wesley Liddick a59f0c7fac Merge pull request #5833 from xuhaihan/fix/openai-fast-missing-tier
fix(openai): force priority when fast tier is omitted
2026-09-12 19:31:21 +08:00
Wesley Liddick 0a378f3432 Merge pull request #7026 from ouchihao/codex/bug-key-quota-reset-state
fix(keys): sync status after resetting quota
2026-09-12 19:30:40 +08:00
Wesley Liddick f2b51e3b24 Merge pull request #7023 from ouchihao/codex/bug-profile-api-errors
fix(profile): show normalized API error messages
2026-09-12 19:30:29 +08:00
Wesley Liddick 329641a86f Merge pull request #7025 from ouchihao/codex/bug-proxy-partial-import-refresh
fix(proxies): refresh the list after partial imports
2026-09-12 19:30:09 +08:00
Wesley Liddick f0dd497780 Merge pull request #7054 from ouchihao/codex/bug-usage-export-filter-snapshot
fix(usage): keep export filters consistent across pages
2026-09-12 19:29:57 +08:00
Wesley Liddick 99b93b2985 Merge pull request #7053 from ouchihao/codex/bug-proxy-filter-pagination
fix(proxies): reset pagination when changing filters
2026-09-12 19:29:41 +08:00
Wesley Liddick 67845665d6 Merge pull request #7050 from ouchihao/codex/bug-balance-email-verification-race
fix(profile): remove verified notification emails by identity
2026-09-12 19:29:24 +08:00
Wesley Liddick 3a070ec1bf Merge pull request #7010 from xpflying/fix/openai-privacy-client-cf-challenge
fix(openai): 隐私/账号检查客户端改用 Firefox 指纹,规避 chatgpt.com Cloudflare 质询
2026-09-12 19:28:09 +08:00
Wesley Liddick 749cd7c354 Merge pull request #7062 from wc4440222/fix/channel-monitor-minimax-provider
fix(channel-monitor): add missing minimax to provider allowlists
2026-09-12 19:27:46 +08:00
Wesley Liddick eaa4083e23 Merge pull request #6995 from alfadb/fix/claude-mid-conversation-output-config
fix(claude): 修复对话中 output_config 的 beta 兼容问题
2026-09-12 19:27:21 +08:00
Wesley Liddick 3873215908 Merge pull request #7049 from xlplbo/perf/openai-ws-pool-queue-wake
perf(openai): WS 连接池排队等待者按账号池变更重选连接
2026-09-12 19:26:56 +08:00
Wesley Liddick 2a4f3d1a71 Merge pull request #7043 from xlplbo/fix/openai-ws-max-conns-factor-default
fix(openai-ws): ctx_pool 每账号连接上限系数默认值改为 5.0
2026-09-12 19:26:26 +08:00
Wesley Liddick 4ff3e6dfb9 Merge pull request #7055 from ouchihao/codex/bug-redeem-profile-refresh
fix(redeem): preserve success when account refresh fails
2026-09-12 19:26:02 +08:00
Wesley Liddick f78c4b241e Merge pull request #7011 from ouchihao/codex/issue-7003-compact-default-model
fix(config): use GPT-5.5 as the default compact model
2026-09-12 19:25:43 +08:00
Wesley Liddick e67ffda7ae Merge pull request #7012 from ouchihao/codex/issue-6982-easypay-upstream-type-dots
fix(payment): allow periods in EasyPay upstream types
2026-09-12 19:24:48 +08:00
Wesley Liddick d2067668d3 Merge pull request #7024 from ouchihao/codex/fix-subscription-assignment-selection
fix(subscriptions): clear assignment user before search debounce
2026-09-12 19:24:00 +08:00
Wesley Liddick ba57ea914b Merge pull request #7052 from ouchihao/codex/issue-2912-transient-auth-failures
fix(auth): preserve sessions during temporary service failures
2026-09-12 19:23:12 +08:00
Wesley Liddick 5948988aae Merge pull request #7022 from ouchihao/codex/issue-3623-accept-encoding-header
fix(gateway): avoid duplicate Accept-Encoding headers
2026-09-12 19:22:44 +08:00
xlplbo 76b3f3c7cf docs(ui): 精简 OpenAI WS 模式提示 2026-09-12 17:38:55 +08:00
xlplbo db15ddf07f docs(ui): 区分 OpenAI WS 开关与连接模式说明 2026-09-12 16:54:22 +08:00
xlplbo 5d5b395d00 docs(ui): 完善 OpenAI WS 模式说明 2026-09-12 16:32:17 +08:00
xlplbo cf3aca6e3b fix(ui): 按 OpenAI WS 模式显示链路提示 2026-09-12 16:28:04 +08:00
xlplbo 956f4672e4 fix(openai-ws): 对齐 OpenAI 新版上下文池容量计算 2026-09-12 16:13:16 +08:00
Wesley Liddick 6206ce940e Merge pull request #6929 from feeeei/fix/gemini-inband-response-signals
fix(gemini): 原生路径登记 2xx 响应里的带内错误信号
2026-09-12 16:08:26 +08:00
Zichen Wang 5968fd0ed9 fix(channel-monitor): add missing minimax to provider allowlists
Fixes #7061

monitorProviders and probeCapableProviders were missing
MonitorProviderMiniMax even though the constant, adapter, ent schema and
error message all already included minimax, so provider=minimax was
rejected by validateProvider.
2026-09-12 15:30:43 +08:00
Shen-AnandClaude Sonnet 5 c6727ed4c9 fix(admin): surface partial Antigravity refresh warnings
Return the updated account alongside partial refresh warnings so the admin UI can keep its state current and tell operators when project ID retrieval must retry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:19:19 +08:00
chihao.ou bb0b50d8bc fix(redeem): preserve success when account refresh fails 2026-09-12 13:50:12 +08:00
chihao.ou 429d6f0480 fix(usage): keep export filters consistent across pages 2026-09-12 13:44:33 +08:00
chihao.ou 5bafffed51 fix(proxies): reset pagination when changing filters 2026-09-12 13:44:05 +08:00
chihao.ou 781a02aea7 fix(auth): preserve sessions during temporary service failures 2026-09-12 13:41:16 +08:00
chihao.ou 3645ce5f43 fix(profile): remove verified notification emails by identity 2026-09-12 13:38:11 +08:00
xlplbo 7cf90c79d6 perf(openai): WS 连接池排队等待者按账号池变更重选连接 2026-09-12 13:01:43 +08:00
Sjeary 195a331322 fix(codex): preserve maximum context window in model catalogs 2026-09-11 21:56:25 -07:00
xlplbo 29371b0819 fix(openai-ws): ctx_pool 每账号连接上限系数默认值改为 5.0 2026-09-12 11:39:05 +08:00
xuhaihan (cc) 8e7954438d fix(openai): force priority when service tier is omitted 2026-09-11 21:16:12 +08:00
chihao.ou a65d476f25 fix(keys): sync status after resetting quota 2026-09-11 19:32:20 +08:00
chihao.ou 961d9e94f3 fix(proxies): refresh the list after partial imports 2026-09-11 19:29:36 +08:00
chihao.ou 51c5d03888 fix(subscriptions): clear assignment user before search debounce 2026-09-11 19:29:07 +08:00
chihao.ou 9b6a49b8e7 fix(profile): show normalized API error messages 2026-09-11 19:27:47 +08:00
chihao.ou 213de0797e fix(gateway): avoid duplicate Accept-Encoding headers 2026-09-11 19:19:40 +08:00
alfadb d8326fccfc fix(claude): preserve mid-conversation output config beta 2026-09-11 18:44:41 +08:00
xpflyingandClaude Fable 5.1 9eb120dd40 fix(openai): 隐私/账号检查客户端改用 Firefox 指纹,规避 chatgpt.com Cloudflare 质询
chatgpt.com 的 Cloudflare 会对 req 内置的 ImpersonateChrome(TLS/HTTP2 指纹与 UA 均为
Chrome/120)返回 403 cf-mitigated=challenge,导致 accounts/check、subscriptions、
account_user_setting 三个 backend-api 调用长期失败:订阅到期时间不再更新、隐私开关显示 Fail。
仅改 UA 版本号无效(指纹仍是 120),Firefox 伪装在同一出口下稳定通过,故切换。

同时:
- 用 cf-mitigated 响应头识别质询页,隐私设置失败时正确标记为 training_set_cf_blocked
- accounts/check / subscriptions 失败日志由 Debug 提升为 Warn 并附 cf_challenge 字段,不再静默

Closes #4825

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 18:10:15 +08:00
Wesley Liddick 4726bdd08b Merge pull request #6890 from slovx2/feat/openai-images-75be
feat(openai): route OAuth image requests through native Codex Images
2026-09-11 17:29:54 +08:00
feeeei 8ea4dc56f0 fix(gemini): 模型侧 finishReason 不再按上游失败登记
MALFORMED_FUNCTION_CALL / UNEXPECTED_TOOL_CALL 是模型生成结果,此前按生成异常
登记为上游 502:写带账号归因的上游错误事件、计入 SLA、严重度 P1,并落进渠道
监控的 upstream_5xx 桶。

- detectGeminiResponseSignal 的 finishReason 只登记内容过滤类枚举,其余一律不登记
- geminiSignalAbnormalStop 仅承载 EMPTY_STREAM / EMPTY_RESPONSE
- 新增原生流式 MALFORMED_FUNCTION_CALL 不留 ops 标记的回归用例
2026-09-11 17:06:01 +08:00
song 23eef9ecc3 test(openai): restore image model regression coverage 2026-09-11 16:44:56 +08:00
Wesley Liddick cdb5cfaf6c Merge pull request #6747 from DuskLin/feat/opencode-go-platform
feat(opencode): 增加 OpenCode 平台支持(Zen / GO)
2026-09-11 16:42:05 +08:00
Wesley Liddick dfa83fbe93 Merge pull request #6986 from feeeei/feat/site-billing-mode
feat(settings): 新增「站点类型」开关(充值 & 订阅 / 仅充值 / 仅订阅)
2026-09-11 16:34:46 +08:00
Wesley Liddick 2dff7af0f9 Merge pull request #6954 from feeeei/fix/quota
fix(quota): 修复仪表盘默认会给所有人分配显示所有平台卡片的问题
2026-09-11 16:34:18 +08:00
song 1067e89fab fix(openai): address image route review findings 2026-09-11 16:30:32 +08:00
song 04c45e9dc2 test: remove incompatible upstream image model assertions 2026-09-11 15:57:49 +08:00
song 5ee436d8a2 fix(pricing): set image cache fallback rate 2026-09-11 15:57:49 +08:00
song e5e04de137 fix: sync upstream config and model tests 2026-09-11 15:57:49 +08:00
song fa565eafb1 fix(pricing): add image cache pricing field 2026-09-11 15:57:38 +08:00
song eacff0a629 fix: retain upstream config and image pricing updates 2026-09-11 15:57:38 +08:00
song c0d5119375 feat(openai): route OAuth image requests through native Codex Images
Use shared model routing for image forwarding and account tests, retain Luna orchestration for legacy models, and add Image 2.5 pricing and usage accounting.
2026-09-11 15:57:09 +08:00
刘佳林 22dffa1bb9 fix(opencode): apply De Morgan for staticcheck QF1001
golangci-lint failed on the CN/OpenCode billing-candidate guard.
2026-09-11 15:47:23 +08:00
刘佳林 efcc2252e5 fix(opencode): address review on dispatch, billing, session, and 402
Skip OpenAI messages-dispatch gpt-5.x defaults for OpenCode groups and
composite OpenCode targets. Filter unpriced claude-* billing candidates,
estimate count_tokens locally, sanitize body session IDs before setting
X-OpenCode-Session, and treat Zen 402 as recoverable payg cooldown.

Stop rewriting the inbound body before protocol routing so account
model_mapping applies once and usage_logs.model stays the client name.
2026-09-11 15:39:13 +08:00
刘佳林 981279c996 fix(opencode): rewrite mapped model onto all three upstream protocols
Apply account model_mapping to the request body before OpenCode
protocol routing so /v1/responses, /v1/chat/completions, and
/v1/messages all send the mapped ID as the real upstream model.
2026-09-11 15:07:43 +08:00
chihao.ou c3b3072a6f fix(payment): allow periods in EasyPay upstream types 2026-09-11 14:51:34 +08:00
chihao.ou 489968fd7a fix(config): use GPT-5.5 as the default compact model 2026-09-11 14:50:39 +08:00
刘佳林 7c008bd8c1 fix(opencode): apply account model mapping in connection tests
Responses probes used the requested model as-is, so aliases like
opencode/muse-spark-* were sent upstream instead of the mapped ID.
2026-09-11 14:19:23 +08:00
Wesley Liddick 9c30951e4a Merge pull request #6973 from ouchihao/codex/issue-4210-renewal-modal-scroll
fix(payment): allow scrolling through renewal plans
2026-09-11 14:01:48 +08:00
Wesley Liddick 40cf3f50a6 Merge pull request #6968 from ouchihao/codex/issue-6903-deepseek-vision-modality
fix(codex): advertise DeepSeek vision image input
2026-09-11 14:01:36 +08:00
Wesley Liddick 75b7dd1e0b Merge pull request #6969 from ouchihao/codex/issue-5882-peak-rate-validation
fix(groups): return bad request for invalid peak-rate settings
2026-09-11 14:01:14 +08:00
Wesley Liddick 642d20b8ac Merge pull request #6988 from ZeroClover/fix/codex-manifest-display-name
fix(openai): keep Codex manifest display names in account model lists
2026-09-11 14:00:42 +08:00
Wesley Liddick 623c32e391 Merge pull request #6989 from ZeroClover/fix/openai-plan-type-labels
fix(openai): map new ChatGPT plan tiers to friendly labels
2026-09-11 14:00:07 +08:00
Wesley Liddick 310f8b7fa2 Merge pull request #6964 from xlplbo/fix/apicompat-agent-message-chat-bridge
fix(apicompat): chat 桥保留 Codex agent_message 任务正文
2026-09-11 13:59:41 +08:00
Wesley Liddick 6254aba5e9 Merge pull request #6575 from doublepi123/feat/antigravity-gemini-3.8-flash
feat(antigravity): support Gemini 3.7 and 3.8 Flash models
2026-09-11 13:58:54 +08:00
Wesley Liddick 7145484fde Merge pull request #6971 from ouchihao/codex/issue-1890-ttft-request-details
fix(ops): show first-token latency in TTFT request details
2026-09-11 13:58:37 +08:00
Wesley Liddick fd300ab6f3 Merge pull request #6974 from alfadb/fix/deepseek-v41-flash-pricing
fix(billing): 适配 DeepSeek V4.1-Flash 新价与 pro→Flash 计费切换
2026-09-11 13:58:23 +08:00
Wesley Liddick 88011a6a2e Merge pull request #6783 from asmoyou/fix/large-image-request-memory
perf(gateway): reduce memory amplification for large Responses image requests
2026-09-11 13:58:11 +08:00
Wesley Liddick 14029e50aa Merge pull request #6661 from whhphd/fix/grok-media-slot-release
fix(grok): release abandoned media slots and preserve video ownership
2026-09-11 13:57:07 +08:00
Wesley Liddick 3fb03cdac1 Merge pull request #6960 from xlplbo/fix/openai-ws-exec-scope-preemption
fix(openai-ws): ctx_pool 会话抢占与会话状态按 codex 线程隔离
2026-09-11 13:56:52 +08:00
Wesley Liddick 8f9a9a255b Merge pull request #6965 from xlplbo/fix/openai-ws-pool-reader-loop
fix(openai-ws): 连接池常驻读循环应答上游 ping,根治空闲连接 1011 keepalive ping timeout
2026-09-11 13:56:23 +08:00
Wesley Liddick bae37a00fb Merge pull request #7006 from Wei-Shaw/fix/setup-test-errcheck-close
fix(ci): 修复 main 分支 golangci-lint 红灯(setup_test.go errcheck)
2026-09-11 13:46:15 +08:00
xlplbo 4543ddc5c1 fix(openai): WS 执行作用域按 request_kind 分道 2026-09-11 13:37:59 +08:00
matt 5af7e7438a fix(ci): check deferred sql.DB.Close errors in setup tests
PR #6499 merged without CI ever running on its head commit (fork run stayed
action_required), landing three bare `defer db.Close()` calls in
internal/setup/setup_test.go. The repo's errcheck config sets
disable-default-exclusions: true, so those are reported and golangci-lint has
been red on main ever since, failing every subsequent PR run.

Switch them to the codebase's existing convention
`defer func() { _ = db.Close() }()`.
2026-09-11 11:45:59 +08:00
xlplbo 613722eee4 fix(openai): HTTP 转发的执行作用域改从原始请求计算 2026-09-11 11:36:29 +08:00
SavitarC 66a10d4939 fix(setup): handle mock database cleanup errors in tests 2026-09-11 10:31:03 +08:00
xlplbo d0ca057ca4 fix(openai): WS 会话被抢占时先发关闭帧再取消 2026-09-11 10:25:50 +08:00
xlplbo e4c369bd56 fix(openai): HTTP 经 WS 池转发按执行作用域取会话状态键 2026-09-11 10:25:50 +08:00
SavitarC 1035a4daf0 feat(ops): support token request stats across all platforms 2026-09-11 10:16:32 +08:00
刘佳林 242907854d feat(opencode): add OpenCode platform with Zen and GO account types
Keep the internal platform ID opencode_go. Product surface is OpenCode
with account_mode zen|go, model-aware routing across Chat Completions,
Responses, and Anthropic Messages, and X-OpenCode-Session derived from
prompt_cache_key / metadata.user_id so prompt cache can hit.

Unmatched models fall back to /v1/chat/completions. Related to #6742.
2026-09-11 10:05:24 +08:00
Wesley Liddick 0116c5a1e1 Merge pull request #6915 from ouchihao/codex/issue-2734-async-processed-label
fix(risk-control): clarify the async processed counter label
2026-09-11 09:35:50 +08:00
Wesley Liddick 67d3a896bd Merge pull request #6912 from ouchihao/codex/issue-2106-redeem-subscription-duration
fix(redeem): align subscription duration limit with the backend
2026-09-11 09:35:29 +08:00
Wesley Liddick 501cc19d0e Merge pull request #6902 from anguobao123/codex/apple-container-self-update
fix(deploy): support Web UI updates with Apple container
2026-09-11 09:35:11 +08:00
Wesley Liddick 8efe2fd8cf Merge pull request #6843 from ouchihao/codex/issue-6841-usage-cost-precision
fix(usage): show eight decimal places in cost details
2026-09-11 09:34:36 +08:00
Wesley Liddick 0aac71c6ee Merge pull request #6845 from ouchihao/codex/issue-6808-ops-error-summary-columns
fix(ops): prioritize time and response in error details
2026-09-11 09:34:23 +08:00
Wesley Liddick f4dd88b001 Merge pull request #6847 from ouchihao/codex/issue-4064-custom-page-button-visibility
feat(settings): allow hiding the custom page open button
2026-09-11 09:34:11 +08:00
Wesley Liddick 0be30886a5 Merge pull request #6916 from ouchihao/codex/issue-3640-subscription-user-search
fix(subscriptions): exclude deleted users from assignment search
2026-09-11 09:33:57 +08:00
Wesley Liddick 324a47e2b7 Merge pull request #6913 from ouchihao/codex/issue-6732-bulk-delete-users
feat(admin): support deleting selected users
2026-09-11 09:33:45 +08:00
Wesley Liddick 2493b0dc31 Merge pull request #6924 from smyyyyy2025/fix/codex-user-agent-validation
fix: validate Codex User-Agent before identity parsing
2026-09-11 09:33:19 +08:00
Wesley Liddick 264cbbec26 Merge pull request #6499 from soulxyz/fix/setup-database-bootstrap
fix(setup): avoid hardcoded postgres connection
2026-09-11 09:32:01 +08:00
Wesley Liddick 87e01596b7 Merge pull request #6850 from reallyfeishi/fix/channel-monitor-utc-buckets
fix: anchor channel monitor date_bin buckets to UTC
2026-09-11 09:31:22 +08:00
Wesley Liddick 43f9383d40 Merge pull request #6869 from Garfielk/codex/fix-heartbeat-envelope-fields
fix(openai): accept full heartbeat bootstrap envelopes
2026-09-11 09:30:45 +08:00
Wesley Liddick 685e97a3a6 Merge pull request #6904 from m11y/fix-scheduling-threshold-snapshot
fix(scheduler): preserve threshold metadata
2026-09-11 09:30:00 +08:00
Wesley Liddick e71d291b39 Merge pull request #6917 from ouchihao/codex/issue-2149-monitor-base-path
fix(monitor): support upstream base URL paths
2026-09-11 09:29:25 +08:00
Wesley Liddick 4adcef4ff4 Merge pull request #6943 from xiuai-labs/fix/keep-client-system-cache-control
fix(gateway): 不再剥离客户端 system 上的 cache_control / stop stripping client system cache_control
2026-09-11 09:28:55 +08:00
Wesley Liddick 799e9938fb Merge pull request #6945 from r266-tech/feat/model-retrieval-6931
feat(gateway): retrieve models from the visible catalogue
2026-09-11 09:26:41 +08:00
Zero Clover 8f2cba0c20 fix(openai): map new ChatGPT plan tiers to friendly labels
prolite -> Pro 5x (violet, same as Pro), pro/chatgptpro -> Pro 20x, self_serve_business_prolite -> Business Premium (indigo, same as Team), team -> Business Standard.

The tier naming stays OpenAI-only so Antigravity Pro and Grok pro keep their own labels; planTypeDisplayLabel now shares openAIPlanTypeLabel with PlatformTypeBadge instead of duplicating the mapping, and the manual plan_type dropdown gains the Pro 5x / Business Premium presets.
2026-09-11 03:25:44 +08:00
Zero Clover 6feefb7aec fix(openai): keep Codex manifest display names in account model lists 2026-09-11 03:18:00 +08:00
feeeei 9d475f9edb feat(settings): 新增「站点类型」三态开关,关闭订阅后用户端收起所有「订阅」入口与文案
后台功能开关页新增「站点类型」单选:充值 & 订阅(默认,即现状)/ 仅充值 / 仅订阅。
它由两个后端开关派生并在保存时同时写回:
- subscription_enabled(新增;默认 true,缺省/空值视为开启,仅显式 false 关闭),按既有
  开关模式贯通常量、公共设置、SSR 注入载荷、默认值、更新写入、DTO、审计 diff 与前端注册表;
- payment_balance_disabled(支付配置里既有的 BALANCE_PAYMENT_DISABLED,此前只能走接口/数据库
  改),补进公共设置与注入载荷,让用户端在任何鉴权请求之前就能推导站点类型。
两者都关闭是接口层可表达但 UI 不提供的组合,前端归为「仅充值」,后台保存时会把余额充值重新打开。

「仅充值」时的表现:
- 用户侧边栏隐藏「我的订阅」,「充值/订阅」入口收窄为「充值」;管理端侧边栏隐藏「订阅管理」
  入口(页面仍可通过地址访问);
- 购买页不再渲染「订阅」tab,只剩单个 tab 时顶部切换器随之隐藏;tab 列表变化时当前 tab
  自动收敛到可用项(关闭订阅回到充值、仅订阅站点重新打开订阅进入订阅),列表为空时展示
  「充值与订阅均暂未开放」而不是一个必定被后端拒绝的充值表单;
- /purchase 的页头标题/描述与 document.title 改走 resolveRouteMetaKeys 按站点类型切换;
- /subscriptions 路由新增 requiresSubscription 守卫,直达时重定向到仪表盘;
- 顶栏订阅进度徽章不再挂载,App.vue、PaymentView 与徽章组件都不再预加载/轮询订阅接口;
- 用量页隐藏「计费类型(余额/订阅)」筛选,Key 用量公开页「订阅类型」行改为「计费方式」。
「仅订阅」时侧边栏入口与页头显示为「订阅」,购买页只剩订阅 tab(余额下单由后端既有逻辑拒绝)。

购买订阅接口、已有订阅的计费、兑换码发放订阅与管理端订阅管理页面本身均不受影响。
featureFlags 抽出纯函数 resolveFeatureFlag,新增 utils/siteBillingMode 承载三态派生与反解,
PaymentView 测试改用响应式 store mock 覆盖开关双向切换。
2026-09-11 00:50:17 +08:00
alfadb ab9bd9e877 fix(billing): 适配 DeepSeek V4.1-Flash 新价与 pro→Flash 切换
- flash 低谷价更新为 $0.15/$0.60/$0.003 per MTok(2026-09-10 官方降价)
- deepseek-v4-pro 自 2026-09-14 04:00 UTC 起按 Flash 价计费(上游路由变更)
- 计费时点显式驱动 pro→Flash 切换,定价展示与计费两条路径同源
- deepseek-flash 保留 reasoning_effort=max;补 Codex 模型清单与前端白名单
- 价表 JSON 新增 deepseek-flash 条目并同步 flash 系价格
2026-09-10 22:10:29 +08:00
chihao.ou b112805ffa test(ops): align query matcher formatting with gofmt 2026-09-10 21:21:08 +08:00
chihao.ou 8c56eabcdc fix(payment): allow scrolling through renewal plans 2026-09-10 21:20:20 +08:00
chihao.ou d3bd614ba7 test(groups): initialize a valid rate multiplier in peak validation case 2026-09-10 21:17:42 +08:00
chihao.ou e9f5add97c fix(ops): show first-token latency in TTFT request details 2026-09-10 21:13:17 +08:00
chihao.ou ab3b398b5f fix(groups): return bad request for invalid peak-rate settings 2026-09-10 21:05:47 +08:00
chihao.ou 5032d06697 fix(codex): advertise DeepSeek vision image input 2026-09-10 21:04:56 +08:00
xlplbo bed1e3c362 fix(openai): 重试强制新建连接并放宽轮次预检超时 2026-09-10 20:35:53 +08:00
xlplbo a5b07b2960 feat(openai): WS 连接池常驻读循环应答上游 ping 2026-09-10 20:35:53 +08:00
xlplbo df8eceba0c fix(apicompat): chat 桥保留 Codex agent_message 任务正文 2026-09-10 20:25:38 +08:00
xlplbo 95a04c8dff test(apicompat): additional_tools 走 chat 桥的回归用例 2026-09-10 19:50:26 +08:00
xlplbo cd1ee1d1ad fix(openai): WS 接入会话状态改用执行作用域键 2026-09-10 17:50:24 +08:00
xlplbo 9e7c2d713c fix(openai): WS 会话抢占改用执行作用域键 2026-09-10 17:50:24 +08:00
xlplbo 06b61ba96f feat(openai): 解析 codex 线程标识派生 WS 执行作用域 2026-09-10 17:50:23 +08:00
feeeei 4a4b50d807 fix(quota): 平台限额表只保存已配置限额的记录,仪表盘按用量与限额展示平台
user_platform_quotas 只保存至少配置了一档限额的记录;不存在的行等价于不限额。

- 注册播种只为配置了限额的平台建行,一个都没有时不写入
- IncrementUsageWithReset 无行时不再 fail-open 建行
- BatchSnapshotUsage 改为只更新既有活跃行,不再插入无限额的新行
- 仓储 BulkInsertInitial/UpsertForUser 把三档全空的记录视为未配置;
  管理员全量替换接口同样剔除,清空限额即软删并放弃累计用量
- 迁移 238 删除三档全 NULL 的历史行
- 用户仪表盘平台卡片 = 有用量的平台 ∪ 配置了限额的平台,计数改为卡片数
- 管理员限额弹窗对未保存限额的平台禁用"重置窗口"
2026-09-10 16:41:58 +08:00
r266-tech 1c0932e173 feat(gateway): retrieve models from the visible catalogue 2026-09-10 15:57:17 +08:00
nekocode a4edda36d5 fix(gateway): 不再剥离客户端 system 上的 cache_control
OAuth mimic 路径无条件删除 system[*].cache_control,不打日志也不报错。
把稳定前缀锚在 system 上的客户端因此拿到 cache_creation 与 cache_read
双 0 —— 不是没命中,是连写都没写。

这个动作是 46e5ac9 引进时「system 必然被整个重写」的配套:客户端的
system 内容都搬进 messages 了,残留断点指着空气,删它是对的。后来
system 注入变成可配置,于是长出了原设计里不存在的组合 —— system 没被
重写,断点照删。剥离失去了赖以成立的前提,代码却还在。

三处调用点里 count_tokens 那处还是硬编码 true,与另两处的
!systemRewritten 语义不一致。

直接移除该选项而不是再加一个开关:注入开启时 system 已被整个替换、
留在上面的断点是我们自己拼进去的稳定锚点;注入关闭时断点就是客户端的
缓存意图。两种情形都没有删它的理由,而需要开关的是有取舍的行为。

顺带补上 count_tokens 的 4 块上限兜底:五条出口里只有它没在自己的转发
路径上调过 enforceCacheControlLimit,原先那句 strip 是这条路上唯一压制
断点数量的东西。拿掉之后「客户端 system + 客户端 messages + 刚注入的
tools[-1]」实测顶到 5 块,而上游对超限是 400。

messages 层的同类改写见 #2369,那次只落地了 messages 一半。

此前该行为零测试覆盖(stripSystemCacheControl 在 _test.go 里零命中),
本次补 4 个用例,其中上限兜底那条撤掉修复即红。

Claude-Session: https://claude.ai/code/session_01WnTW52pmELGy3g7KPEUXj2
2026-09-10 15:48:17 +08:00
reed 1ed36679bd fix(apicompat): recover Responses text from done and terminal events
The streaming Responses-to-Anthropic converter emitted assistant text only
from output_text.delta, so a stream carrying its answer on
response.output_text.done or in the terminal response.output produced a
protocol-valid message with no text.

Record emitted text per output_text part in a ledger that survives content
block closure, and recover only the suffix a finished payload adds, as #6629
does for function-call arguments. Where the part a payload finishes cannot be
established, decline to recover rather than repeat delivered text: terminal
recovery runs only when nothing was emitted, and a done payload indexed
differently from every delta is ignored.

Give output_text.done its own handler so the shared block-closing helper and
its tool-path caller stay unchanged, and so a payload arriving after
message_stop cannot open a content block past the end of the message.
2026-09-09 18:55:16 -05:00
smyyyyy 4e5632c3e3 fix: validate Codex User-Agent before identity parsing 2026-09-10 03:18:31 +08:00
feeeei 44bc47a3e3 fix(gemini): 原生路径登记 2xx 响应里的带内错误信号
Gemini API-key 原生转发对上游 2xx 只抽 usage 原样透传:finishReason 内容拦截、
promptFeedback.blockReason、事件里的 Google 错误信封、空响应都被当作成功,
ops 错误日志、用户错误页、渠道监控全部没有记录。

- 新增 gemini_response_signal:逐事件判定三类带内信号,流式按优先级登记最高者;
  没有 data 事件时对非 SSE 的兜底正文整段判定,空体登记 EMPTY_STREAM / EMPTY_RESPONSE
- 内容策略类按请求级结果登记(invalid_request_error / 400,business limited,
  不做上游归因、不计 SLA);错误信封、生成异常、空响应按上游失败登记并计 SLA
- OpsStreamError 新增 RequestScoped / NonStream:请求级带内行不受残留上游上下文与
  skip_monitoring 影响,落库状态取 IntendedStatus;中间件保留此前尝试的恢复行
- 客户端字节、wire 状态、用量与计费不变
2026-09-10 02:59:15 +08:00
chihao.ou 21323c31f8 fix(monitor): support upstream base URL paths 2026-09-09 20:02:25 +08:00
chihao.ou c76db386c0 fix(subscriptions): exclude deleted users from assignment search 2026-09-09 20:01:34 +08:00
chihao.ou 58e6e6b325 fix(risk-control): clarify the async processed counter label 2026-09-09 19:57:23 +08:00
chihao.ou 567ea21dc8 feat(admin): support deleting selected users 2026-09-09 19:50:05 +08:00
chihao.ou b9d0728687 fix(redeem): align subscription duration limit with the backend 2026-09-09 19:47:22 +08:00
wangjunwei de76baaa0d fix(scheduler): preserve Anthropic threshold metadata 2026-09-09 17:13:37 +08:00
anguobao123 26798773cb fix(deploy): support web updates with Apple container 2026-09-09 17:05:45 +08:00
github-actions[bot] 98d86915be chore: sync VERSION to 0.2.4 [skip ci] 2026-09-09 07:05:11 +00:00
Wesley Liddick 5de5e2bed0 Merge pull request #6874 from hansnow/fix/gpt-image-2-5-oauth
fix(openai): 支持 Image 2.5 并修复 OAuth 生图主控模型
2026-09-09 14:43:55 +08:00
刘世俊 a0babc93dd fix(openai): accept full heartbeat bootstrap envelopes 2026-09-09 12:48:57 +08:00
hansnow 7ccc8a6f50 fix(openai): 支持 Image 2.5 并修复 OAuth 生图主控模型 2026-09-09 11:04:59 +08:00
lcy 29a36a10be Merge upstream/main into feat/antigravity-gemini-3.8-flash
Resolve comment-wording conflict in normalizeGeminiThinkingTierAlias by
keeping upstream's phrasing. Function body and behavior are unchanged.
2026-09-09 01:49:32 +08:00
reallyfeishi f180fbb884 fix: anchor channel monitor buckets to UTC 2026-09-08 23:02:06 +08:00
chihao.ou 1d4e0436e4 feat(settings): allow hiding the custom page open button 2026-09-08 21:18:56 +08:00
shaw 270eac6973 chore: update sponsors 2026-09-08 21:18:24 +08:00
chihao.ou 266ac9c11b fix(ops): prioritize time and response in error details 2026-09-08 21:17:13 +08:00
chihao.ou fd880839f6 fix(usage): show eight decimal places in cost details 2026-09-08 21:15:55 +08:00
shaw 570106ac17 chore: update sponsors 2026-09-08 20:57:43 +08:00
Wesley Liddick 775348b402 Merge pull request #6706 from ouchihao/codex/issue-3679-draggable-custom-page-button
feat(custom-page): make the open-link button draggable
2026-09-08 20:47:42 +08:00
Wesley Liddick d9efbf8229 Merge pull request #6839 from mason0510/fix/grok-external-web-access
fix(grok): 移除 Chat Completions 中不支持的 external_web_access 参数
2026-09-08 20:47:25 +08:00
Wesley Liddick 233f3dffe6 Merge pull request #6281 from wuji-labs/feat/http2-long-stream-keepalive
feat(upstream): add HTTP/2 PING keepalive for long LLM generation streams
2026-09-08 20:47:07 +08:00
Wesley Liddick f9d7826711 Merge pull request #6513 from anguobao123/codex/optional-apple-container-network-subnet
feat(deploy): support optional Apple network subnet
2026-09-08 20:46:51 +08:00
Wesley Liddick c54897a59d Merge pull request #6815 from Pluviobyte/codex/fix-directed-proxy-backups
fix(proxies): make backup references directed and shareable
2026-09-08 20:46:32 +08:00
Wesley Liddick 29646ba6b4 Merge pull request #6488 from aofee/codex/feat-grok-media-eligibility-ui
feat: add Grok media eligibility controls
2026-09-08 20:46:20 +08:00
Wesley Liddick 6f0d0ababc Merge pull request #6320 from spongehah/fix/runtime-block-honor-persisted-cooldown
fix(schedule): 选号按持久化冷却条件清除本机账号级阻断
2026-09-08 20:46:07 +08:00
Wesley Liddick 52f7bcaedd Merge pull request #6759 from ssharkkky/feat/cmv2-user-ranking-toggle
feat(channel-monitor-v2): add user ranking toggle and keep missing TTFT neutral
2026-09-08 20:44:07 +08:00
Wesley Liddick 95acbf1f03 Merge pull request #6424 from cyhhao/fix/bound-ops-system-log-storage
fix(ops): bound database-backed system log growth
2026-09-08 20:43:54 +08:00
Wesley Liddick 43569bb44c Merge pull request #6434 from swjturay/codex/upstream-client-disconnect-drain
fix(openai-ws): drain upstream after client disconnect
2026-09-08 20:42:59 +08:00
Wesley Liddick 15557e7796 Merge pull request #6235 from StarryKira/codex/fix-6232-channel-cache-invalidation
fix: broadcast channel cache invalidations across instances
2026-09-08 20:42:45 +08:00
Wesley Liddick e2fd418a96 Merge pull request #6372 from clansty/fix/openai-oauth-429-fallback-disabled
fix: 修复关闭 429 默认回避后仍触发 OAuth 运行时冷却
2026-09-08 20:42:35 +08:00
Wesley Liddick 4e9b01fd59 Merge pull request #6838 from atogumo/fix/claude-code-probe-any-model
fix(gateway): accept Claude Code max_tokens=1 probes for any model
2026-09-08 20:42:24 +08:00
Wesley Liddick aa167a25a7 Merge pull request #6775 from xlplbo/fix/plugin-zip-handle-windows
fix(plugin): 提交插件包前先关闭 ZIP 读取器,修正 Windows 下两例单测
2026-09-08 20:12:42 +08:00
Wesley Liddick 5cbb9191a7 Merge pull request #6836 from r266-tech/fix-proxy-expiry-json-range
fix(proxies): reject malformed list responses
2026-09-08 20:12:30 +08:00
Wesley Liddick ae4cc14b28 Merge pull request #6819 from LeeeeeeM/codex/fix-registration-visibility
fix: respect registration visibility on login
2026-09-08 20:12:14 +08:00
Wesley Liddick fa556700c2 Merge pull request #6762 from ouchihao/codex/issue-2045-payment-help-markdown
feat(payment): render help text as sanitized Markdown
2026-09-08 20:11:56 +08:00
Wesley Liddick 68773aab98 Merge pull request #6764 from ouchihao/codex/issue-1499-select-failed-token-refresh
fix(accounts): keep failed token refresh accounts selected
2026-09-08 20:11:08 +08:00
Wesley Liddick 1e6114c270 Merge pull request #6812 from Pluviobyte/codex/fix-model-plaza-subscriptions
fix(model-plaza): include active subscription groups
2026-09-08 20:10:42 +08:00
Wesley Liddick 9e7039ebca Merge pull request #6811 from Pluviobyte/codex/fix-proxy-partial-update
fix(proxies): preserve omitted expiry and fallback settings
2026-09-08 20:10:20 +08:00
Wesley Liddick 1923d1c27d Merge pull request #6814 from feeeei/fix/go-redis-pool-nil-ctx-panic
fix(redis): 升级 go-redis 至 v9.22.0,修复连接池 nil-ctx panic 竞态
2026-09-08 20:09:59 +08:00
Wesley Liddick b638445234 Merge pull request #6754 from ccemoji/codex/minimal-upstream-fix
fix: cancel streaming upstream before closing body
2026-09-08 20:09:37 +08:00
Wesley Liddick 9bcf8a5b80 Merge pull request #6760 from wucm667/feat/issue-6752-weekly-cost-estimate
feat(accounts): show estimated weekly account cost
2026-09-08 20:09:25 +08:00
Wesley Liddick 979fe247f9 Merge pull request #6821 from 22zyfeng-collab/fix/antigravity-oauth-plan-type
fix(antigravity): preserve OAuth plan_type when saving accounts
2026-09-08 20:09:12 +08:00
Wesley Liddick 6378fb0cba Merge pull request #6798 from savvym/codex/fix-account-menu-overflow
fix(frontend): 修复账号操作菜单在视口底部被截断
2026-09-08 20:08:53 +08:00
Wesley Liddick 3ee92b40c5 Merge pull request #6659 from ouchihao/codex/issue-77-remove-inactive-groups
fix(accounts): allow removing inactive group assignments
2026-09-08 20:08:40 +08:00
Wesley Liddick 4892b8f17e Merge pull request #6810 from Pluviobyte/codex/fix-announcement-proxy-date-range
fix: validate announcement and proxy date ranges
2026-09-08 20:08:24 +08:00
Wesley Liddick 28b807a735 Merge pull request #6816 from Pluviobyte/codex/fix-repeated-proxy-fallback
fix(proxies): allow repeated expiry fallback and preserve origin
2026-09-08 20:07:08 +08:00
atogumo 4e5d67df35 fix(gateway): accept Claude Code max_tokens=1 probes for any model
The claude_code_only probe exemption only fired for models containing
"haiku". Claude Code also sends max_tokens=1 requests without a system
prompt to the model currently in use, e.g. when switching models or
refreshing the context usage, so on sonnet/opus groups those requests failed
the system-prompt check and were rejected as non-Claude-Code clients.

Recognise the probe from the request body itself (max_tokens == 1) once the
User-Agent check has passed, regardless of model, and carry max_tokens into
the ParsedRequest projection so the hot path sees it too. The haiku context
flag and the local haiku interception are unchanged; non-haiku probes reach
the pool as ordinary one-token requests. One output token offers nothing to
a client that already forges the CLI User-Agent, so this widens no attack
surface.

Fixes #6591
2026-09-08 19:12:37 +08:00
Wesley Liddick dbe92a1c24 Merge pull request #6758 from DuskLin/feat/minimax-platform
feat(minimax): 增加 MiniMax 平台支持
2026-09-08 18:50:06 +08:00
r266-tech 188e3a9f90 fix(proxies): reject malformed list responses 2026-09-08 18:29:17 +08:00
刘佳林 10940ecf39 fix(minimax): gofmt MiniMax allowlist comments
golangci-lint gofmt failed on aligned comments in the default upstream host list.
2026-09-08 17:49:11 +08:00
刘佳林 3495635a52 fix(minimax): 补齐阈值停调与额度监控接线
调度阈值评估和渠道监控 quota 取数补上 MiniMax 分支;GetCodingPlanProvider 改为只认官方域名,避免把中转 Key 发到厂商额度端点。composite /v1/models 与 Codex 目录纳入 MiniMax;迁移改名为 237;allowlist 去掉未使用的 www.* / api.minimax.cn,abab 前缀收窄到 5/6/7。
2026-09-08 17:41:29 +08:00
mason0510 c8deeb0b05 fix(grok): 移除 Chat Completions 中不支持的 external_web_access 参数
模型:Claude Sonnet 5
修复内容:在 Grok Chat Completions raw 转发流程中添加 external_web_access 参数过滤,修复 Grok API 返回 400 "Argument not supported: external_web_access" 错误
修改理由:Grok xAI API 不支持 external_web_access 参数,Sub2API Responses API 路径已有过滤,但 Chat Completions 路径缺失,导致 grok-4.5 等模型调用失败返回 502
验证结果:代码已通过本地 Demo 验证递归删除逻辑正常,待远端编译部署后验证真实 API 调用
影响范围:仅影响 Grok 平台 Chat Completions API 请求体处理,不影响 Responses API 和其他平台
回滚方式:git revert 本次提交,或手动移除 openai_gateway_chat_completions_raw.go line 159-163 的 sanitizeGrokUnsupportedFields 调用

[AI: 梁心语/Claude Sonnet 5 @ 2026-09-08T17:30]
2026-09-08 16:41:09 +08:00
22zyfeng-collab b6ee9f0a32 fix(antigravity): preserve OAuth subscription plan across account creation 2026-09-08 15:02:32 +08:00
G.Wood-Sun 6c8ad0bd4a fix: respect registration visibility on login 2026-09-08 14:44:41 +08:00
Rain 7137fae2a6 test(proxies): preserve incoming references when editing backups 2026-09-08 13:58:34 +08:00
Rain 859de4c8fd fix(proxies): allow repeated expiry fallback and preserve origin 2026-09-08 13:56:20 +08:00
Rain e835f79819 fix(proxies): make backup references directed and shareable 2026-09-08 13:56:19 +08:00
feeeei 99eba19ffc fix: 升级 go-redis 至 v9.22.0 修复连接池 nil-ctx panic
上游竞态:调用方超时后 wantConn.ctx 被置 nil,拨号 goroutine 用 nil ctx
创建超时上下文触发 panic(go-redis#3630,v9.18.0 起修复)。
生产环境在连接池饥饿 + 请求超时风暴下每秒触发上万次。

同步将 ZRangeByScore 迁移至 ZRangeArgs:v9.22.0 将其标记为 deprecated,
golangci-lint staticcheck 拦截(SA1019)。ZRangeArgs{ByScore: true} 与原
调用发出的 Redis 命令参数完全等价(LIMIT 编码逻辑相同)。
2026-09-08 13:46:59 +08:00
Rain 38cfd7e2d9 fix(proxies): preserve omitted expiry and fallback settings 2026-09-08 13:38:41 +08:00
Rain 3fc08745a6 fix(model-plaza): include active subscription groups 2026-09-08 13:37:53 +08:00
Rain efc6e4a816 fix: validate announcement and proxy date ranges 2026-09-08 13:37:52 +08:00
savvym df64b5f368 fix(frontend): keep account action menu within viewport 2026-09-08 10:46:56 +08:00
github-actions[bot] 772a0382f0 chore: sync VERSION to 0.2.3 [skip ci] 2026-09-08 02:35:11 +00:00
Wesley Liddick 8fa67d477d Merge pull request #6791 from Wei-Shaw/fix/group-model-allowlist-schema-repair
fix(db): 升级自愈 groups.model_allowlist 列,修复 API 密钥/订阅列表加载失败
2026-09-08 10:03:23 +08:00
tunsuyandCursor ef891643e9 merge(main): resolve antigravity toolConfig conflict for #6689
Keep always-on toolConfig from main (reasoning models without tools) while
retaining the PR behavior that drops mixed built-in tools when client
function tools are present, so includeServerSideToolInvocations stays unused.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 09:48:44 +08:00
shaw 4a7739acdf fix(db): converge groups.model_allowlist on upgrade
235 只在「models_list_config 存在且 model_allowlist 不存在」时重命名,而迁移一旦
写入 schema_migrations 就会按「文件名 + checksum」整份跳过,不再重跑。因此只要数据库
在 235 之后回到旧结构(为回滚旧版本镜像手工改回列名、或按旧结构做了部分恢复),
应用依旧能正常启动,但每个关联 groups 的查询都会失败:

    pq: column groups.model_allowlist does not exist

表现为「API 密钥」「我的订阅」「管理端订阅列表」等页面加载失败。

新增可重放的收敛迁移 236,升级即自愈:只有旧列则重命名(数据原样保留);两列并存
则在新列仍为默认空值时回填旧列配置;两列都没有则按默认值补建。收尾统一把
model_allowlist 归一成 NOT NULL DEFAULT '{}',与 ent schema 对齐。

判定列是否存在改用 attrelid = 'groups'::regclass(跟随 search_path),
不再写死 table_schema = 'public',避免与 ALTER TABLE 的解析路径分叉。

Fixes #6780
2026-09-08 09:15:23 +08:00
Wesley Liddick 14e0a49e17 Merge pull request #6767 from alfadb/fix/ollama-cloud-max-tokens-all-platforms
fix(ollama): 修复 DeepSeek 多协议输出上限并自动选择 Bearer 鉴权
2026-09-08 08:26:51 +08:00
Wesley Liddick b439dddfda Merge pull request #6772 from ZeroClover/fix/account-test-model-names
fix(accounts): populate display names for live test models
2026-09-08 08:25:54 +08:00
Qilin You 7e62731863 perf: reduce allocations for large Responses image payloads 2026-09-08 01:54:32 +08:00
xlplboandClaude Fable 5.1 b42157a725 test(service): 修正 Windows 下两例环境相关用例
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAJmqcVA9TLacTCD1bLh4i
2026-09-07 23:21:29 +08:00
xlplboandClaude Fable 5.1 335fcdc1d6 fix(plugin): 提交插件包前先关闭 ZIP 读取器
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAJmqcVA9TLacTCD1bLh4i
2026-09-07 23:21:29 +08:00
Zero Clover e094a3f40b fix(accounts): populate display names for live test models 2026-09-07 21:59:35 +08:00
alfadb 57387445f3 fix(ollama): select Bearer auth for Anthropic endpoints automatically 2026-09-07 21:54:22 +08:00
alfadb 2fc24d8879 fix(ollama): normalize Messages base URLs before token clamping 2026-09-07 21:54:22 +08:00
alfadb cc91155fe4 fix(ollama): clamp DeepSeek output limits across API protocols 2026-09-07 21:54:21 +08:00
github-actions[bot] b7dba62678 chore: sync VERSION to 0.2.2 [skip ci] 2026-09-07 13:01:57 +00:00
Wesley Liddick 5485f368b2 Merge pull request #6690 from alfadb/fix/astra-pro-mode
fix(openai): 保留 Codex OAuth 请求中的 Astra reasoning.mode
2026-09-07 20:42:37 +08:00
Wesley Liddick f8351e9e33 Merge pull request #6658 from ZeroClover/feat/group-model-allowlist
feat(groups)!: enforce model allowlists across gateway endpoints
2026-09-07 20:25:57 +08:00
tunsuyandCursor 140e172951 chore: retrigger CI after test fix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 20:10:50 +08:00
chihao.ou 941a0487ab fix(accounts): allow removing inactive group assignments 2026-09-07 20:05:08 +08:00
tunsuyandCursor 1d4b9ead45 test(antigravity): align buildTools expectations with mixed-tool drop
Update the leftover buildTools unit test that still expected
googleSearch to coexist with client function declarations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 20:04:33 +08:00
chihao.ou 7f0f579bb0 fix(accounts): keep failed token refresh accounts selected 2026-09-07 20:01:24 +08:00
chihao.ou dfb3978b9d feat(payment): render help text as sanitized Markdown 2026-09-07 19:51:03 +08:00
shaw 2031c1d5ad fix(groups): merge main and resolve model allowlist conflicts
Resolve three conflicts from main's #6680 (supplementUnmappedOpenAIModels):

1. gateway_handler.go — keep PR's allowlist refactoring (ModelAllowlistEnabled,
   writeAllowlistedModelsList, modelListingSource); main's OpenAI branch in
   writeModelsList was auto-merged.
2. group_models_list.go (modify/delete) — delete file per PR intent; relocate
   supplementUnmappedOpenAIModels to group_model_allowlist.go where gateway_service
   and openai_codex_models_service can still call it.
3. openai_codex_models_service.go — combine main's supplementUnmappedOpenAIModels
   wrapping with PR's ModelAllowlistEnabled() guard.

Also fix two auto-merged test files that still referenced the old
GroupModelsListConfig / ModelsListConfig types.
2026-09-07 19:44:03 +08:00
刘佳林and刘佳林 6f2295bfc7 test(minimax): 按平台注册表推导调度器 snapshot 期望数量
新增 MiniMax 后 canonical token / 账号查询数增加,硬编码期望值过期。
改为从 schedulerSnapshotPlatforms 派生 bucket 与 query 数量,避免下次加平台再踩坑。
账号查询计数辅助函数仅用于单测,放在 unit test 文件以免 unused。

Co-authored-by: 刘佳林 <DuskLin@users.noreply.github.com>
2026-09-07 11:40:11 +00:00
Wesley Liddick 8193b80a59 Merge pull request #6743 from MarushiruDonato/fix/gpt6-astra-instructions
fix(openai): 修正 GPT-6 Astra 提示词选择
2026-09-07 19:22:43 +08:00
shaw f6d580200a fix: update #6688 test assertions for #6678 image modality and #6718 ultra level
- TestCodexAliasFailoverMappingHonorsModelRouting/no_routing_rule: expect
  ["text","image"] instead of ["text"] — image input is resolved per-account
  via isOpenAICodexImageInputModel, independent of routing rules
- TestCodexAliasFailoverMappingHonorsModelRouting/routing_rule: expect
  effort levels to include "ultra" (added by #6718)
2026-09-07 19:13:01 +08:00
shaw b491c3271b fix: rename redeemMaxErrorsPerHour to redeemMaxFailedAttempts in tests
#6669 renamed the constant from redeemMaxErrorsPerHour to
redeemMaxFailedAttempts (fixed 10-minute window), but #6670's tests
still referenced the old name, breaking compilation on main.
2026-09-07 19:00:04 +08:00
shaw d106b3b5a0 fix: resolve merge conflict with main and fix #6718 missing group parameter
- Resolve conflict in openai_codex_models_service_test.go: keep both
  GPT-6 Astra instructions template assertion (from this PR) and ultra
  reasoning level + multi_agent fields (from #6718 on main)
- Fix compilation error on main: add missing `group *Group` parameter
  to buildCodexModelsManifestForAccounts calls in
  openai_codex_model_metadata_test.go (lines 24, 32) — caused by #6688
  changing the function signature while #6718 added new callers with
  the old signature
2026-09-07 18:58:00 +08:00
Wesley Liddick 10fe591e54 Merge pull request #6660 from ouchihao/codex/issue-312-onboarding-button-shadow
fix(onboarding): remove button text shadows
2026-09-07 18:51:44 +08:00
Wesley Liddick 66cd8a72bc Merge pull request #6738 from Milesians/codex/fix-interval-multiplier-display
fix: 修正区间倍率展示并补齐模型广场 1h 缓存价格
2026-09-07 18:51:00 +08:00
Wesley Liddick dc46daa690 Merge pull request #6726 from DuskLin/feat/openai-reasoning-effort-mapping-deny
feat(groups): 推理强度映射转发值支持拒绝
2026-09-07 18:50:41 +08:00
Wesley Liddick 65246e69d7 Merge pull request #6643 from MarushiruDonato/fix/codex-manifest-reactive-vmodel
fix(frontend): 修复 Codex Manifest 编辑状态丢失
2026-09-07 18:50:27 +08:00
Wesley Liddick de69bf1e03 Merge pull request #6655 from ouchihao/codex/issue-275-account-expiry-presets
feat(accounts): add month and year expiry presets
2026-09-07 18:50:13 +08:00
Wesley Liddick eba85dc478 Merge pull request #6680 from cyhhao/fix/openai-mixed-account-model-list
fix(openai): retain default models in mixed account catalogs
2026-09-07 18:50:03 +08:00
Wesley Liddick abf70750fd Merge pull request #6669 from ssharkkky/fix/redeem-fixed-failure-window
fix(redeem): use a fixed 10-minute failure window
2026-09-07 18:49:51 +08:00
Wesley Liddick 88b697d5df Merge pull request #6673 from ssharkkky/fix/backup-migrations-advisory-lock
fix(backup): serialize pg_dump/psql with the migrations advisory lock
2026-09-07 18:49:34 +08:00
Wesley Liddick 3eb5a84ea1 Merge pull request #6700 from qiu121/codex/fix/deepseek-account-cost-peak
fix(billing): 修复 DeepSeek 高峰账号成本少计
2026-09-07 18:49:19 +08:00
Wesley Liddick 71e5a4baf7 Merge pull request #6486 from aofee/codex/fix-grok-media-inconclusive-eligibility
fix(grok): do not reject media accounts on incomplete billing
2026-09-07 18:48:39 +08:00
Wesley Liddick 4bfcbd0c78 Merge pull request #6498 from aofee/codex/fix-ws-pending-turn-terminal
fix(openai-ws): track pending passthrough turns
2026-09-07 18:48:04 +08:00
Wesley Liddick 2b1e5e7f5c Merge pull request #6429 from spin6lock/fix/billing-glm-5.3-flash-fallback-pricing
fix(billing): 补上 glm-5.3 / glm-5.3-flash 兜底价,避免被 glm-5 子串匹配抢走
2026-09-07 18:47:46 +08:00
Wesley Liddick cbb4b7e53e Merge pull request #6678 from gebdalaoli-arch/codex/stale-astra-input-modalities-v021
[codex] Fix stale official Astra input modalities
2026-09-07 18:47:31 +08:00
Wesley Liddick 81c0f11d7a Merge pull request #6736 from qiu121/codex/fix/openai-stream-failure-observability
fix(openai): 记录流式失败的上游诊断信息
2026-09-07 18:46:58 +08:00
Wesley Liddick 561fc1c3e4 Merge pull request #6718 from lyy0709/codex/astra-ultra-catalog
fix(codex): preserve Astra Ultra workflow metadata
2026-09-07 18:46:11 +08:00
Wesley Liddick 43491f67a1 Merge pull request #6733 from AndrewZhuCC/fix/codex-allowed-tools
fix(codex): preserve allowed_tools selection constraints
2026-09-07 18:45:48 +08:00
Wesley Liddick c05bc4d3cc Merge pull request #6705 from ouchihao/codex/issue-4827-usage-api-key-filter
fix(usage): load all API keys for record filters
2026-09-07 18:45:35 +08:00
Wesley Liddick b9ebc8f209 Merge pull request #6637 from varrge/fix/gemini-flash-tier-pricing
fix(billing): resolve Gemini 3.7/3.8 Flash thinking-tier prices
2026-09-07 18:44:55 +08:00
Wesley Liddick f7c48ba59a Merge pull request #6674 from wuji-labs/fix/antigravity-toolconfig-always-present
fix(antigravity): always set toolConfig for reasoning models without tools
2026-09-07 18:44:41 +08:00
Wesley Liddick 22c16d0517 Merge pull request #6708 from r266-tech/r266/fix-ws-turn-rate-limit-6679
fix(openai-ws): recover later passthrough turns from account quota exhaustion
2026-09-07 18:44:18 +08:00
Wesley Liddick 2cc1e7ef90 Merge pull request #6677 from Lynricsy/fix/claude-thinking-block-binding
fix(claude): support thinking block binding beta
2026-09-07 18:43:52 +08:00
wucm667 35e69af41b feat(accounts): show estimated OpenAI weekly cost 2026-09-07 18:37:15 +08:00
Zero Clover 81ff9384cb fix(groups): merge main and resolve model allowlist conflicts 2026-09-07 18:33:39 +08:00
TT 0d3dbae710 feat(channel-monitor-v2): add user ranking toggle and keep missing TTFT neutral
Give operators a V2 setting (channel_monitor_hide_user_ranking) to hide
the user ranking tab and the /users payload from non-admin channel
monitor v2 viewers. Default false (ranking stays visible). Admin
endpoints and views always keep the ranking.

Also stop painting first-token '-' as a red/empty failure when traffic
is sync-only and has no first-token samples: a missing TTFT is
"not applicable" (health-unknown), not a breached latency budget.

Changes:
- new setting key + fail-open parse (only literal "true" hides), wired
  through public/admin settings DTOs, handlers, and the V2 runtime
- ChannelMonitorV2Service.Users returns an empty list to non-admin
  viewers when the toggle is on
- frontend: feature flag + SettingsView toggle + user V2 view hides the
  ranking tab and skips the /users call
- monitorFormat: isTtftUnavailable/ttftDisplayState keep missing TTFT
  neutral

Tests:
- setting parse defaults to visible and only literal "true" hides
- V2 Users honors the toggle for non-admin viewers
- frontend MetricCell/monitorFormat keep missing TTFT neutral
2026-09-07 18:31:03 +08:00
刘佳林 19382f275e feat(minimax): 增加 MiniMax 平台支持
把 MiniMax 做成与 Kimi / 智谱 / DeepSeek 同级的国产供应商平台:账号创建/编辑、分组、Composite 路由、原生 Token Plan / Coding Plan 额度,以及 OpenAI 兼容模型列表。

额度走 GET api.minimaxi.com|io /v1/api/openplatform/coding_plan/remains(Bearer)。只解析 model_name=general 的 remaining percent;周窗口仅在 current_weekly_status=1 时计入。

Related to #6742
2026-09-07 18:22:56 +08:00
alfadb 0aaed397cc fix: preserve Astra reasoning mode for Codex OAuth 2026-09-07 17:58:20 +08:00
Wesley Liddick 909b8c7ef4 Merge pull request #6648 from ouchihao/codex/issue-3667-turnstile-loading
fix(auth): show a loading placeholder for Turnstile
2026-09-07 17:38:11 +08:00
Wesley Liddick b8ff74ad70 Merge pull request #6668 from ssharkkky/fix/channel-monitor-gemini-role
fix(monitors): send explicit user role in Gemini monitor probes
2026-09-07 17:38:00 +08:00
Wesley Liddick 96cf8bd76a Merge pull request #6665 from ssharkkky/fix/grok-cross-client-map-default-doc
fix(settings): describe Grok cross-client mapping default as enabled
2026-09-07 17:37:47 +08:00
Wesley Liddick 1f716aa181 Merge pull request #6667 from ssharkkky/fix/antigravity-user-agent-version
fix(antigravity): refresh default user agent version
2026-09-07 17:37:37 +08:00
Wesley Liddick 111c41cf3f Merge pull request #6684 from SakuraPuare/feat/i18n-build-gate
fix(i18n): fail builds on missing locale keys
2026-09-07 17:37:25 +08:00
Wesley Liddick 787a6a33df Merge pull request #6481 from alfadb/fix/claude-cli-version-floor
fix(claude): 让伪装的 Claude Code 版本真正生效,修复 Fable 5.1 仍然 400
2026-09-07 17:37:12 +08:00
Wesley Liddick f8e1e7fed6 Merge pull request #6711 from wucm667/fix/issue-6701-model-not-found-failover
Fix failover for model-not-found responses
2026-09-07 17:36:58 +08:00
Wesley Liddick f09a5b602e Merge pull request #6704 from aofee/codex/fix-http-bridge-session-isolation
fix(openai-ws): 修复 HTTP bridge 并发连接抢占与状态串用
2026-09-07 17:36:36 +08:00
Wesley Liddick 027e3f2d54 Merge pull request #6688 from suixincrazy/fix/codex-manifest-failover-alias
fix(openai): honor model_routing for Codex manifests and scheduling
2026-09-07 17:36:26 +08:00
Wesley Liddick 8116d235ef Merge pull request #6629 from superdav42/fix/preserve-function-arguments-done
fix(apicompat): preserve function arguments from done events
2026-09-07 17:36:10 +08:00
Wesley Liddick 959cbe3d87 Merge pull request #5453 from wucm667/fix/issue-5451-ws-passthrough-before-turn
fix(openai): reacquire passthrough turn slots
2026-09-07 17:35:57 +08:00
Wesley Liddick c0420e2b8a Merge pull request #6484 from aofee/codex/fix-fable-credits-required-scope
fix(anthropic): keep Fable credits_required model-scoped
2026-09-07 17:33:59 +08:00
Wesley Liddick 9578ddbddd Merge pull request #6687 from Chenning-Tao/codex/fix-native-delegation-ws
fix(openai): preserve named standalone Responses inputs
2026-09-07 17:31:17 +08:00
Wesley Liddick e274de45b7 Merge pull request #6702 from a1gaoshanlaiyige/fix/message-cache-json-escaping
fix(gateway): use JSON escaping for message cache promotion
2026-09-07 17:30:54 +08:00
Wesley Liddick ce328fb37a Merge pull request #6670 from ssharkkky/fix/payment-fulfillment-isolation
fix(payment): isolate fulfillment from redeem limits
2026-09-07 17:30:43 +08:00
ccemoji be4ab92b2b fix: cancel streaming upstream before closing body 2026-09-07 16:47:51 +08:00
Wesley Liddick 8f7f2b5d92 Merge pull request #6727 from wucm667/feat/issue-6723-subscription-usage-link
feat(admin): link subscriptions to user usage
2026-09-07 16:45:32 +08:00
alfadb 3efeba33a4 test(claude): 版本覆盖用例字面量随内置基线升至 2.1.259
这三个用例的字面量 2.1.251 写于内置基线为 2.1.220 的时期,本意是取一个
高于基线的补丁位验证"覆盖不得低于 pin"规则。基线升至 2.1.258 后,2.1.251
落在 pin 之下:IsSupportedCLIVersion 应判 false、resolveCLIVersion 应回落
基线,与用例原意相悖,CI 随之失败。

改为真正高于当前 pin 的最小补丁位 2.1.259,用例语义不变;实现未放宽,
constants_cli_version_test.go 中独立的 Fable 5.1 闸门下限 2.1.251 保持不变。
2026-09-07 16:34:35 +08:00
alfadb ce157b32ed fix(claude): 指纹自愈路径同样执行 claude-cli 版本下限
GetOrCreateFingerprint 的 heal 分支(畸形缓存自愈)此前在版本下限逻辑之外:
畸形缓存 + 合法但过旧的客户端 UA 会在首次读取时把过旧 UA 原样落库并返回,
下一次读取才被抬到下限——该窗口内账号仍以过旧版本撞上游客户端版本闸门
(PR #6481 review 指出的边界 case)。

修复:把 floorClaudeCLIUserAgentVersion 后置块从 healthy else 内移到
heal/healthy if-else 之后、TTL 续期检查之前,作为两条缓存命中路径的共同
后置条件;heal 分支 needWrite 本已为 true,单次落库语义不变。

测试:TestGetOrCreateFingerprintHealsPoisonedCacheUsingValidClientUA
期望改为首次返回/落库即 claude-cli/CLICurrentVersion(保留 ClientID 不变、
恰好一次落库断言);floor/UA 全部既有用例保持绿。
2026-09-07 16:34:35 +08:00
alfadb 3cb2381bdc fix(claude): 让伪装的 Claude Code 版本真正生效,修复 Fable 5.1 仍然 400
claude-fable-5-1 已登记进 DefaultModels,但 OAuth 账号实际调用仍然全部失败:

  400 claude_code_version_too_old
  "Claude Code 2.1.220 does not support this model;
   version 2.1.251 or newer is required."

两个独立的原因,缺一不可:

1. CLICurrentVersion 停留在 2.1.220,低于上游对该模型的客户端版本闸门。
   升到 2.1.258(当前官方 CLI 最新版,npm @anthropic-ai/claude-code)。

2. 只升常量对所有已有账号无效。上游请求实际发送的 User-Agent 来自账号级
   持久指纹(IdentityService,Redis fingerprint:<accountID>),不是
   DefaultHeaders;defaultFingerprint 只在首次创建指纹时使用。
   GetOrCreateFingerprint 的缓存命中路径只有两条改写分支:UA 畸形时自愈,
   或客户端送来更新版本时升级。指纹 7 天 TTL 但活跃账号每 24h 续期,实际
   近乎永不过期,系统内也没有重置入口——客户端版本比缓存旧时(例如缓存
   2.1.220、客户端 2.1.75),存量账号会永远对上游声称 2.1.220。

   生产实测:升级 CLICurrentVersion 并部署后仍报同样的 400,读 Redis 确认
   出问题账号的缓存值为 "claude-cli/2.1.220 (external, cli)"。

修复:新增 floorClaudeCLIUserAgentVersion,把 CLICurrentVersion 作为缓存
指纹里 claude-cli 版本的下限,缓存命中与首次创建两条路径共用。

  - 只对 claude-cli 产品生效,其它客户端一律不动
  - 只升不降:版本等于或高于下限(含客户端上报的更新版本)不做任何改动
  - 只替换版本号段,UA 其余部分原样保留,例如
    "(external, claude-desktop-3p, agent-sdk/0.3.100)" 不受影响
  - 与既有的"客户端更新版本"升级分支相互独立、二者取更新者
  - X-Stainless-* 维持既有 merge 语义,不被下限逻辑覆盖

cc_version 无需单独处理:billing attribution block 的版本已由
syncBillingHeaderVersion 跟随实际发送的 User-Agent 同步。

测试:identity_service_version_floor_test.go 覆盖 helper 的 8 种 UA 形态与
5 个 GetOrCreateFingerprint 场景,其中一个直接复现线上故障(缓存 2.1.220 +
客户端 2.1.75 → 写回 2.1.258,Stainless 字段保持不变)。
constants_cli_version_test.go 锁定常量与 DefaultHeaders 的 UA 同源,并断言
不低于 Fable 5.1 的闸门下限。回退实现后对应用例确认转红。

identity_service_user_agent_validation_test.go 中 3 个把版本钉死在旧字面量
的既有用例改为钉 CLICurrentVersion 或更高版本——它们断言的正是本次有意改变
的行为,各自的原始意图(哨兵拒绝、合法升级生效、健康缓存不重写)保持不变。
2026-09-07 16:34:35 +08:00
Wesley Liddick a7d2c782df Merge pull request #5717 from wucm667/feat/issue-2629-current-main
feat(simple-mode): allow basic account grouping
2026-09-07 15:43:22 +08:00
Wesley Liddick a3aa9bae6d Merge pull request #6662 from ZeroClover/fix/codex-manifest-pinned-accounts
fix(openai): reuse pinned accounts for model lists and fix manifest controls
2026-09-07 15:35:49 +08:00
Robert Guo b939fa9d4a fix(openai): select GPT-6 Astra instructions correctly 2026-09-07 14:12:21 +08:00
milesians d03c42d798 fix: size plaza pricing columns to fit cache prices 2026-09-07 11:56:53 +08:00
qiu121 6aabbdf547 fix(openai): 记录流式失败的上游诊断信息
- 在 Responses 流已经输出语义内容后记录 response.failed 的上游事件,使运维可以查询原始错误、账号和上游 request ID,定位 OpenAI 临时处理失败而不丢失诊断上下文。
- 保留已提交语义内容后禁止切号重放的安全边界,避免客户端收到重复或混合响应;补充原生与透传路径的回归测试,确保该错误可观测且行为一致。

Refs: #5165, #6714
2026-09-07 11:27:08 +08:00
lyy0709 ad4b2f30f5 test(codex): assert alias workflow effort before dereferencing 2026-09-07 11:23:11 +08:00
milesians ff758f37d2 fix: preserve plaza 1h pricing and align interval fallbacks 2026-09-07 11:07:35 +08:00
zhuanzhi 2e31d8b702 fix(codex): preserve allowed_tools selection constraints
Keep allowed_tools policies intact for upstream validation and rewrite their
function references alongside reserved tool-name aliases.
2026-09-07 10:47:51 +08:00
wucm667 8c8fd6e298 feat(admin): link subscriptions to user usage 2026-09-07 10:13:18 +08:00
刘佳林 f62ec2e4a0 feat(groups): 推理强度映射转发值支持拒绝
分组推理强度映射的转发值新增 deny。命中对应请求档位时直接拒绝请求,
返回 403 permission_error,而不是改写后转发。
2026-09-07 10:12:42 +08:00
milesians 415a0dc9d0 fix: display multiplier-only interval prices 2026-09-07 10:11:07 +08:00
wucm667 b59f3bf46c fix(simple-mode): enforce basic group boundaries 2026-09-07 09:20:48 +08:00
lyy0709 2db78bd3d6 fix(codex): preserve Astra Ultra workflow metadata 2026-09-07 01:12:55 +08:00
Zero Clover f88d62ad28 fix(openai): use live account models in connection tests 2026-09-07 00:23:49 +08:00
wucm667 8363d537e0 fix(gateway): fail over model-not-found responses 2026-09-06 20:38:17 +08:00
r266-tech b8ed245085 fix(openai-ws): recover later turns from quota exhaustion 2026-09-06 18:42:06 +08:00
chihao.ou 76efc52fb6 feat(custom-page): make the open-link button draggable 2026-09-06 17:52:24 +08:00
chihao.ou dc6b318c34 fix(usage): load all API keys for record filters 2026-09-06 17:35:10 +08:00
aofee 96884dd1ae fix(openai-ws): isolate HTTP bridge connection state 2026-09-06 17:05:06 +08:00
a1gaoshanlaiyige 0b061c5fa1 fix(gateway): use JSON escaping for message cache promotion
Use encoding/json instead of Go string quoting when promoting message content to cached text blocks. Add regression coverage for control characters, Unicode, literal backslashes, text preservation, and idempotence.
2026-09-06 16:42:57 +08:00
qiu121 958a21ad6e fix(billing): 修复 DeepSeek 高峰账号成本少计
- 将两个用量记录入口的请求级定价时刻传递给账号成本统计,使跨峰谷请求的成本与客户计费采用同一时间基准。
- 默认目录成本改用统一计费入口并隔离分组与渠道售价,使 DeepSeek 高峰成本正确应用两倍价格,同时保留服务层级、长上下文和单次推理等级倍率。
- 增加 Flash 与 Pro 的峰谷边界、缓存用量、规则优先级及真实网关回归测试,确保账号成本按最终上游模型计算且客户扣款金额与次数保持一致。

验证:Go 1.27.0 下全量 unit 与 integration 测试命令通过;golangci-lint 2.13.0 报告 0 个问题。定向回归无失败或跳过,全量套件保留原有条件跳过项。
2026-09-06 15:15:21 +08:00
tunsuyandCursor 6c2d2ed043 fix(antigravity): drop mixed web_search when client tools present
Antigravity v1internal rejects googleSearch/codeExecution mixed with
functionDeclarations even with includeServerSideToolInvocations (#6464).
Codex enables web_search by default alongside shell tools, which made
Sub2API return opaque upstream_error. Prefer client function tools so
agent sessions keep working; keep web_search-only fallback intact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 13:23:53 +08:00
yan9651688 58f461b087 feat(keys): add bulk editing to API key management 2026-09-06 11:25:33 +08:00
suixincrazy 28167bbf85 fix(openai): honor model_routing for Codex manifests and scheduling
An OpenAI group that keeps a failover account for a shared public alias
produced a Codex model descriptor with no reasoning levels and text-only
input, which leaves the client's reasoning-effort slider with nothing to
select.

Two accounts explicitly mapping one alias to different upstream models
makes codexExplicitModelTargetsConflictForPlatform true. With no
upstream_model_metadata snapshot to intersect, groupCodexModelMetadata
short-circuits to {reasoningConflict, inputModalitiesConflict}, so
DefaultReasoningLevel is nil (the key vanishes under omitempty) and
SupportedReasoningLevels is an empty array. mergeMissingCodexModelFields
fills only missing-or-null fields, so an empty array is never repaired.

Failing closed is right when the alias target is genuinely ambiguous, but
a group that declares model_routing for the alias has already resolved
that ambiguity: the operator stated which accounts serve it and which is
primary. Treating that as a conflict rejects a deliberate failover setup.

Manifest side: when a routing rule names the alias, restrict capability
declaration to the routed accounts and skip the conflict override.
Capabilities then fall back to the same by-name derivation used for a
single account without a metadata snapshot. Groups with no rule, a
disabled rule, or a rule for another alias keep failing closed.

Scheduling side: model_routing was stored and read platform-agnostically
(admin_group.go, Group.GetRoutingAccountIDs) but four gates in
gateway_scheduling.go only consulted it for Anthropic, so an OpenAI
group's rules were accepted and displayed yet never honored. Without
this, the manifest would honor a rule that scheduling ignores. Both
gates now share one predicate, with the allowed platform set defined in
a single place.

Selection inside a routing list is unchanged: the list is a membership
set, and the winner is still decided by Priority, then load, then
LastUsedAt.

Tests: alias-failover manifest recovery plus the three negative cases,
the platform predicate table, and an OpenAI routed selection that must
beat a higher-priority unrouted account.
2026-09-06 10:12:20 +08:00
Chenning-Tao 985428d9d2 fix(openai): preserve named standalone Responses inputs 2026-09-06 09:33:53 +08:00
wucm667 4a4fd35e0c fix(simple-mode): guard non-empty group deletion 2026-09-06 08:11:23 +08:00
wucm667 f46038820b fix(simple-mode): enforce group capability allowlist 2026-09-06 08:11:23 +08:00
wucm667 05ad6b49a1 fix(simple-mode): enforce basic group capabilities 2026-09-06 08:10:41 +08:00
wucm667 a3675552bd feat(simple-mode): allow basic account grouping 2026-09-06 08:10:41 +08:00
Steven Moder b3ad9b67cf fix(i18n): fail builds on missing locale keys 2026-09-06 04:16:06 +08:00
cyh f49a393566 fix(openai): retain default models in mixed account catalogs 2026-09-06 02:02:49 +08:00
DAlaoli 8a46947865 Fix stale official Astra input modalities 2026-09-06 00:50:01 +08:00
LynricsyandWine Fox 7ae0312091 fix(claude): 🐛 补齐 thinking block binding beta
Co-authored-by: Wine Fox <fox@ling.plus>
2026-09-06 00:34:05 +08:00
WUJI-Labs 58e35a4f32 fix(antigravity): always set toolConfig for reasoning models without tools
Upstream returns 400 INVALID_ARGUMENT when toolConfig is absent. The guard
`if !isReasoning || len(tools) > 0` skipped it for reasoning models called
without tools, contradicting the intent stated in the comment above it.
Set toolConfig unconditionally and cover the three cases with a unit test.
2026-09-06 00:25:21 +08:00
TT 95023e7d49 fix(backup): serialize pg_dump/psql with the migrations advisory lock 2026-09-06 00:11:56 +08:00
TT 7a70de401c fix(payment): isolate fulfillment from redeem limits
Payment and admin fulfillment shared the public Redeem entry point, so
fulfillment retries were blocked by, and contributed to, the user's
public redeem failure counter, and lookup errors were silently treated as
"code does not exist" on a partial re-run.

- resolveRedeemAction now distinguishes a genuine not-found lookup from
  other lookup errors (which now fail the fulfillment instead of being
  swallowed and re-created).
- Add validatePaymentRedeemCode: before reusing an existing code during
  fulfillment, verify it belongs to the order (code id, balance type,
  amount, user, status), so a stale or foreign code can never be
  credited against the wrong order/user.
- Split Redeem into a policy-based core: public redemption enforces the
  failure counter; payment fulfillment (redeemForPaymentFulfillment) and
  admin fulfillment (RedeemForAdminFulfillment) bypass it, while all code
  validation and transactional updates remain identical. Admin
  fulfillment keeps the normal redeem-level affiliate rebate.
- The admin CreateAndRedeem endpoint now uses RedeemForAdminFulfillment.

Tests: updated resolveRedeemAction cases, new TestValidatePaymentRedeemCode,
code-used-by-another-user rejection, payment/admin rate-limit bypass
coverage, and public-path regression coverage.
2026-09-05 23:56:59 +08:00
TT 666a5c6f87 fix(redeem): use a fixed 10-minute failure window
The redeem failure counter used INCR followed by Expire(24h) on every
attempt, so each failed attempt refreshed the key TTL and the window
never actually closed for a user who kept retrying within 24h.

- Count attempts in a fixed 10-minute window via a small Lua script
  (INCR, then PEXPIRE only when the key is created or has no TTL), which
  is atomic and also repairs keys that lost their TTL.
- Move the counter to the redeem:ratelimit:v2: key space so pre-existing
  24h counters do not seed the new window.
- Rename the service threshold to redeemMaxFailedAttempts (30) to reflect
  that it bounds attempts per window, not per hour.

Public redeem behavior is unchanged apart from the window semantics:
the check fails open when the counter read fails, and a failed
increment never masks the underlying domain error.
2026-09-05 23:38:50 +08:00
TT c7343d2aa2 fix(monitors): send explicit user role in Gemini monitor probes
The Antigravity native Gemini route (generateContent) expects each
contents entry to carry an explicit "user" role, matching the request
shape used by the established Antigravity account-test flow. Without it
the probe body is rejected with 400 INVALID_ARGUMENT, so Gemini channel
monitors report false failures.

Add the explicit role to the Gemini provider adapter body and cover it
with a regression test that parses the generated request body.
2026-09-05 23:30:45 +08:00
TT a6430bfe20 fix(antigravity): refresh default user agent version
The Antigravity service fingerprints clients by the version embedded in the User-Agent header. The built-in default (1.23.2) had drifted far behind current real client builds, so requests using the default UA started failing OAuth and probe flows.

Bump DefaultUserAgentVersion from 1.23.2 to 2.9.1 (current client build) and update the constant test accordingly. The default remains overridable via ANTIGRAVITY_USER_AGENT_VERSION and the admin setting.
2026-09-05 23:28:33 +08:00
TT 32bf3d0f33 fix(settings): describe Grok cross-client mapping default as enabled
The Grok cross-client model mapping is enabled by default: InitializeDefaultSettings writes "true", and the parser treats a missing/empty value as enabled ("Default true (missing/empty -> enabled)"). However the comment above the defaults and the admin settings UI hint (EN and ZH) both claimed "disabled by default", contradicting the actual behavior.

Align the comment and both UI hints with the real default. No behavior change.
2026-09-05 23:17:34 +08:00
whhphd b97a798ebc fix(grok): release abandoned media slots and preserve video ownership
Claim scheduler-acquired slots before media eligibility checks and task-owner
validation. Release abandoned attempts immediately and retain once-only
cleanup on cancellation and forwarding completion.

Select only the resolved video owner, reuse bounded account waiting without
sticky escape, and preserve the task binding and TTL. Keep generation
failover and owner/billing isolation intact.

Regression tests reproduce unreleased account slots on upstream ab99d56e9
and pass with this change. Validation: go test ./..., unit and integration
suites, focused race tests, backend build, and golangci-lint (0 issues).
2026-09-05 20:25:08 +08:00
Zero Clover cb31033972 feat(openai): reuse pinned accounts for standard model discovery 2026-09-05 19:58:47 +08:00
chihao.ou 3aeab296d8 fix(onboarding): remove button text shadows 2026-09-05 19:55:03 +08:00
Zero Clover f3bbb95310 fix(groups): react immediately to Codex manifest edits and unify dialog toggles 2026-09-05 19:46:25 +08:00
chihao.ou 00eabe8ab9 feat(accounts): add month and year expiry presets 2026-09-05 19:20:10 +08:00
chihao.ou 017cc62e41 feat(auth): require password confirmation during registration 2026-09-05 19:19:35 +08:00
chihao.ou a16070ccf6 fix(auth): show a loading placeholder for Turnstile 2026-09-05 18:25:51 +08:00
github-actions[bot] ab99d56e96 chore: sync VERSION to 0.2.1 [skip ci] 2026-09-05 09:45:17 +00:00
Robert Guo b1ce821c49 fix(frontend): preserve Codex manifest edit state 2026-09-05 17:18:01 +08:00
Zero Clover 55c5eed9fc test(repository): persist model allowlists in group fixtures 2026-09-05 16:11:46 +08:00
Zero Clover cff3f89850 feat(groups)!: enforce model allowlists across gateway endpoints 2026-09-05 16:05:17 +08:00
Varrge 3bdb869fca fix(billing): resolve Gemini 3.7/3.8 Flash thinking-tier prices 2026-09-05 15:54:38 +08:00
David Stone 6271c517da fix(apicompat): preserve arguments from done events 2026-09-04 21:18:36 -06:00
doublepi123 8ed57b000c feat(antigravity): support Gemini 3.7 Flash models
Google released Gemini 3.7 Flash on 2026-08-13. Register it alongside 3.8
using the same shape as the existing 3.6 entries: bare id plus the
Antigravity -high/-low/-medium/-tiered thinking tiers, the model catalog,
the custom-mapping passthroughs, fallback pricing and the tier alias
normalizer.

Pricing matches 3.8 at $0.75 input / $3.75 output / $0.075 cached input
per MTok. Both are promotional through 2026-12-31 and double on
2027-01-01; the constants note this.

Also corrects the 3.8 catalog timestamps to the documented GA dates.
2026-09-04 16:04:47 +08:00
doublepi123 27bcec7645 feat(antigravity): support Gemini 3.8 Flash models
Google released Gemini 3.8 Flash on 2026-09-02. Register the model and its
Antigravity thinking tiers the same way Gemini 3.6 Flash is registered:

- DefaultAntigravityModelMapping: bare id plus -high/-low/-medium/-tiered
- geminiModels catalog exposed through /antigravity/v1/models
- ensureAntigravityDefaultPassthroughs so accounts with a custom
  model_mapping still resolve the new ids
- fallback pricing and the thinking-tier alias normalizer, so the tiers
  share the base model price card instead of failing pricing lookup

Fallback rates are Google's published prices: $0.75 input / $3.75 output /
$0.075 cached input per MTok. These are promotional through 2026-12-31 and
double on 2027-01-01; the constant carries a note to that effect.

Refs #6523
2026-09-04 16:04:47 +08:00
anguobao123 1859519573 feat(deploy): support optional Apple network subnet 2026-09-02 20:08:57 +08:00
Soulxyz 8ed48e27a6 fix(setup): avoid hardcoded postgres connection 2026-09-02 15:30:59 +08:00
aofee a7f1ed8fa4 fix(openai-ws): track pending passthrough turns 2026-09-02 14:59:13 +08:00
WUJI-Labs f5e4da5e72 style(upstream): gofmt http_upstream_profile.go 2026-09-02 12:40:23 +08:00
WUJI-Labs 9dc4c40bff test(upstream): align HTTP/2 keepalive tests with long-stream profile API 2026-09-02 12:28:37 +08:00
WUJI-Labs 81fd853005 feat(upstream): add HTTP/2 PING keepalive for long LLM generation streams
Long-running LLM inferences (such as high-thinking Gemini models) may experience
silent TCP disconnections across intermediate proxies, NAT gateways, or firewalls
when no data frames are emitted for tens of seconds to minutes.

Go's default http2.Transport sets ReadIdleTimeout=0 (disabling active PING checks).
This commit introduces HTTPUpstreamProfileLongStream with:
- ReadIdleTimeout = 10s
- PingTimeout = 5s

When a pooled H2 connection stays idle for 10s during long streams, active PING frames
are dispatched. Dead connections failing to respond within 5s are proactively evicted,
preventing client generation requests from hanging on dead sockets or failing with EOF/502.

Signed-off-by: WUJI-Labs <zoujincool@gmail.com>
2026-09-02 12:11:17 +08:00
aofee 01bd9b71a2 feat: add Grok media eligibility controls 2026-09-02 11:50:33 +08:00
aofee a774230661 fix(grok): keep inconclusive billing accounts eligible 2026-09-02 11:09:21 +08:00
aofee 222181efd6 fix(anthropic): scope Fable credits errors to model 2026-09-02 11:02:38 +08:00
wucm667 5fb5dfb34c fix(openai): reacquire passthrough turn slots 2026-09-01 08:13:29 +08:00
Ray a10ff1255c fix(openai-ws): classify canceled client streams 2026-08-31 21:04:29 +08:00
spin6lock c74a4f521c fix(billing): 补上 glm-5.3 / glm-5.3-flash 兜底价,避免被 glm-5 子串匹配抢走 2026-08-31 19:51:50 +08:00
cyh 163eb7ffae fix(ops): bound database-backed system logs 2026-08-31 19:04:58 +08:00
Clansty 5fea83dcae fix: 修复429未耗尽额度仍触发账号退避
- 关闭429默认回避时不再创建OAuth运行时冷却

- 仅在x-codex窗口达到100%时使用上游重置时间

- 补充未耗尽额度和关闭开关的回归测试
2026-08-30 00:02:14 +08:00
spongehah 6d339ec93b fix: honor persisted cooldown when dropping in-process account blocks
- Treat TempUnschedulableUntil/RateLimitResetAt/OverloadUntil as source of truth during account selection
- Drop stale local account-level runtime blocks with generation+deadline CAS
- Leave model-scoped transient and Grok quota blocks unchanged
2026-08-28 14:38:15 +08:00
haruka 1ee929e4b9 fix: broadcast channel cache invalidations 2026-08-26 11:16:32 +08:00
743 changed files with 42744 additions and 4068 deletions
+4
View File
@@ -1,8 +1,12 @@
.PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical
FRONTEND_CRITICAL_VITEST := \
src/i18n/__tests__/localeKeyCompleteness.spec.ts \
src/api/__tests__/client.spec.ts \
src/api/__tests__/tokenRefresh.spec.ts \
src/api/__tests__/keys.bulkUpdate.spec.ts \
src/components/keys/__tests__/BulkEditKeysModal.spec.ts \
src/views/user/__tests__/KeysView.spec.ts \
src/api/__tests__/channelMonitorV2.spec.ts \
src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \
src/views/auth/__tests__/WechatCallbackView.spec.ts \
+11 -1
View File
@@ -166,6 +166,16 @@ Please read the following carefully before using this project:
<td>Thanks to AxisNow for sponsoring this project! <a href="https://www.axisnow.io/">AxisNow</a> protects and accelerates websites and APIs, delivering an optimal access experience across mainland China and globally, while extending acceleration and security capabilities to native/mobile apps through client SDKs — <strong>self-hosted private-deployment CDN</strong> | <strong>subscription-based DDoS-protected CDN</strong> | <strong>autonomous, flexibly composable CDN network</strong>.</td>
</tr>
<tr>
<td width="180"><a href="https://pp.dog/register?aff=SUB2API"><img src="assets/partners/logos/ppdog.png" alt="PP.dog" width="150"></a></td>
<td><a href="https://pp.dog/register?aff=SUB2API">PP.dog</a> is a source-level API gateway with its own massive account pool, purpose-built as an upstream relay for downstream transit stations and high-frequency developers — saving you the hassle of building and maintaining your own account pool. ✅ Direct from source: self-operated account pool with no middleman markup; 🧧 Cost crusher: combined rate multiplier as low as 0.03x, just 0.35% of official pricing; 🚀 Blazing fast: first-token latency < 1s, on par with the official native API. <a href="https://www.pp.dog/register?aff=SUB2API">Get started with PP.dog now</a></td>
</tr>
<tr>
<td width="180"><a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api"><img src="assets/partners/logos/cola-proxy.jpg" alt="ColaProxy" width="150"></a></td>
<td>ColaProxy provides high-quality residential proxies built for web scraping, automation, and multi-account management. Get a free trial with non-expiring traffic, pricing starting as low as $0.3/GB, unlimited concurrent connections, and intelligent IP rotation for a smoother, more stable proxy experience. Use discount code COLA10 to get 10% off and start scaling your projects with reliable residential proxies today. <a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">Get started with ColaProxy</a></td>
</tr>
</table>
## Overview
@@ -816,7 +826,7 @@ xAI quota is passive. Sub2API does not invent subscription quota values; it reco
`401` responses temporarily remove accounts with invalid credentials from scheduling. `403` responses are treated as access or entitlement failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling.
New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts require positive paid-entitlement evidence from the xAI billing probe; Free, forbidden, missing, malformed, and inconclusive billing observations are excluded from new media generation. Unobserved OAuth accounts are probed before the first media request is forwarded, and imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`.
New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts with explicit Free or forbidden billing evidence are excluded from new media generation. Missing or malformed observations are probed before dispatch; a successful but incomplete billing response is treated as `billing_inconclusive` and remains eligible for backwards compatibility, because an unknown billing schema is not proof that the account lacks media entitlement. Operators can quarantine a known-bad account with `extra.grok_media_eligible=false` or force-enable a verified account with `true`. Imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`.
Administrators can override automatic media eligibility through the account create/update API by setting `extra.grok_media_eligible` to `false` (exclude) or `true` (force eligible). On update, set it to `null` to remove the override and return to automatic probe-based behavior; omitting the field preserves the current override. A weekly allowance period alone is not treated as a paid tier signal. Successful image responses must contain at least one actual image output; empty HTTP `200` responses trigger account failover instead of being counted and returned as successful generations.
+42
View File
@@ -169,6 +169,16 @@
<td>感谢 AxisNow 赞助了本项目!<a href="https://www.axisnow.io/">AxisNow</a> 保护并加速网站与 API,兼顾中国大陆及全球的访问体验,并通过客户端 SDK,将加速与安全能力延伸至原生/移动 App — <strong>自建私有部署 CDN</strong>|<strong>订阅式高防 CDN</strong>|<strong>自主可控、灵活组合的 CDN 网络</strong>。</td>
</tr>
<tr>
<td width="180"><a href="https://pp.dog/register?aff=SUB2API"><img src="assets/partners/logos/ppdog.png" alt="PP.dog" width="150"></a></td>
<td><a href="https://pp.dog/register?aff=SUB2API">PP.dog</a> 是自建账号池的源头 API 网关,专注为下游中转站与高频开发者提供 API 网关中继服务,帮您省去自建号池的一切麻烦——✅ 源头直供:自持海量账号池,无中间商赚差价;🧧 成本屠夫:综合倍率低至 0.03x,成本仅为官方的千分之3.5;🚀 极速体验:首 Token 延迟 < 1s,流畅媲美官方原生 API。<a href="https://www.pp.dog/register?aff=SUB2API">立即接入PP.dog</a></td>
</tr>
<tr>
<td width="180"><a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api"><img src="assets/partners/logos/cola-proxy.jpg" alt="ColaProxy" width="150"></a></td>
<td>ColaProxy 提供专为网页抓取、自动化和多账号管理打造的高质量住宅代理。免费试用,流量永不过期,价格低至 $0.3/GB,支持无限并发连接和智能 IP 轮换,带来更流畅、更稳定的代理体验。使用优惠码 COLA10 立享 9 折优惠,立即开始使用可靠的住宅代理扩展您的项目。<a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">立即开始使用 ColaProxy</a></td>
</tr>
</table>
## 项目概述
@@ -217,6 +227,30 @@ underscores_in_headers on;
Nginx 默认会丢弃名称中含下划线的请求头(如 `session_id`),这会导致多账号环境下的粘性会话功能失效。
## Codex Fast/Flex 策略说明
管理员后台的 `系统设置 -> 网关服务 -> OpenAI Fast/Flex 策略` 只负责处理请求体中的 `service_tier`,不会修改 Codex 客户端的模型目录,也不会让 Codex UI 自动出现 Speed 或 `/fast` 选项。
策略支持以下处理方式:
- `pass`:保留客户端传入的 `service_tier`;`fast` 会规范为上游使用的 `priority`。
- `filter`:移除 `service_tier`,按普通优先级请求。
- `block`:拒绝匹配的 Fast/Flex 请求。
- `force_priority`:将匹配请求强制设置为 `priority`,会按 Priority/Fast 价格计费。为避免升级后改变既有规则语义,`all` 只匹配显式存在的 tier;如需让省略 `service_tier` 的 OpenAI 请求也强制升级,必须新增 `service_tier=missing + force_priority` 规则。非 OpenAI 平台不会执行缺省 tier 注入。这种方式可以让请求实际使用 Fast,但 Codex UI 仍可能不显示 Fast 状态。
Codex 的 Fast 入口由客户端模型目录驱动。只有当前模型目录声明了 `additional_speed_tiers: ["fast"]` 和对应的 `service_tiers`,Codex 才会显示 `/fast`。通过 API Key 或自定义模型提供商连接 Sub2API 时,如果模型目录没有这些字段,即使后台配置了 `force_priority`,重启 Codex 后也不会出现 Speed 选项。
客户端可在 `~/.codex/config.toml` 中直接指定默认请求级别:
```toml
service_tier = "fast"
[features]
fast_mode = true
```
其中 `service_tier = "fast"` 会让请求携带 Fast 设置;`features.fast_mode` 只启用客户端 Fast 功能。模型目录没有声明 Fast 能力时,`/fast` 仍可能不显示。可通过 Sub2API 使用记录确认最终 `service_tier` 是否为 `priority`。
---
## 部署方式
@@ -696,6 +730,14 @@ go generate ./cmd/server
---
## OpenAI 图片模型
支持 `gpt-image-2.5-flare`、`gpt-image-2.5-sunburst` 及其 `2026-09-08` 日期快照,可通过 `/v1/images/generations`、`/v1/images/edits` 调用。`quality` 支持 `xhigh`、`max`、`auto`,合法自定义尺寸和图片 usage 明细保持透传。
OAuth / Setup Token 图片请求使用 Responses 主控模型调用 `image_generation` 工具,默认主控为 `gpt-5.6-luna`。可设置 `SUB2API_IMAGES_MAIN_MODEL` 切换为账号支持的文本模型;Docker Compose 用户修改 `.env` 后执行 `docker compose up -d` 重建容器。该配置不会替换所选图片模型,也不会覆盖 `/v1/responses` 请求中已经提供的文本主控模型。
升级后,无模型限制的账号自动支持新模型。已有显式账号映射或分组白名单需要加入两个 2.5 模型(日期快照按需加入);升级不会自动扩大管理员设置的模型权限。新模型内置价格包含官方文本输入、图片输入和图片输出 token 费率,远端价格表尚未更新时使用内置 2.5 价格;实际按次或按 token 计费仍由既有分组/渠道配置决定。
## 简易模式
简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。
+10
View File
@@ -168,6 +168,16 @@
<td>AxisNow のご支援に感謝します!<a href="https://www.axisnow.io/">AxisNow</a> はウェブサイトと API を保護・高速化し、中国本土および世界中で快適なアクセス体験を実現します。クライアント SDK により、ネイティブ/モバイルアプリにも高速化・セキュリティ機能を拡張できます — <strong>自社構築・プライベートデプロイ CDN</strong>|<strong>サブスクリプション型高防御 CDN</strong>|<strong>自律制御で柔軟に組み合わせられる CDN ネットワーク</strong>。</td>
</tr>
<tr>
<td width="180"><a href="https://pp.dog/register?aff=SUB2API"><img src="assets/partners/logos/ppdog.png" alt="PP.dog" width="150"></a></td>
<td><a href="https://pp.dog/register?aff=SUB2API">PP.dog</a> は自社運用の大規模アカウントプールを持つソースレベルの API ゲートウェイで、下流の中継ステーションや高頻度開発者向けに API ゲートウェイリレーサービスを提供し、アカウントプールの自主構築にかかるすべての手間を省きます。✅ ソース直供:自社運用の大規模アカウントプール、中間マージンなし;🧧 コスト最強:総合レート倍率わずか 0.03x、コストは公式の 0.35% のみ;🚀 超高速体験:ファーストトークンレイテンシ 1 秒未満、公式ネイティブ API に匹敵するスムーズさ。<a href="https://www.pp.dog/register?aff=SUB2API">今すぐ PP.dog に接続</a></td>
</tr>
<tr>
<td width="180"><a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api"><img src="assets/partners/logos/cola-proxy.jpg" alt="ColaProxy" width="150"></a></td>
<td>ColaProxy は、Web スクレイピング、自動化、マルチアカウント管理向けに構築された高品質な住宅プロキシを提供します。有効期限なしのトラフィックで無料トライアルが利用でき、料金は $0.3/GB から、無制限の同時接続とインテリジェントな IP ローテーションにより、よりスムーズで安定したプロキシ体験を実現します。割引コード COLA10 で 10% オフ、信頼性の高い住宅プロキシでプロジェクトを今すぐスケールしましょう。<a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">ColaProxy を始める</a></td>
</tr>
</table>
## 概要
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+1 -1
View File
@@ -1 +1 @@
0.2.0
0.2.4
+10 -9
View File
@@ -117,10 +117,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
openAI403CounterCache := repository.NewOpenAI403CounterCache(redisClient)
geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
compositeTokenCacheInvalidator := service.NewCompositeTokenCacheInvalidator(geminiTokenCache)
rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, openAI403CounterCache, settingService, compositeTokenCacheInvalidator)
httpUpstream := repository.NewHTTPUpstream(configConfig)
leaderLockCache := repository.NewLeaderLockCache(redisClient)
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, openAI403CounterCache, settingService, compositeTokenCacheInvalidator, ollamaCloudUsageService)
identityCache := repository.NewIdentityCache(redisClient)
identityService := service.NewIdentityService(identityCache)
httpUpstream := repository.NewHTTPUpstream(configConfig)
timingWheelService, err := service.ProvideTimingWheelService()
if err != nil {
return nil, err
@@ -137,7 +139,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
channelRepository := repository.NewChannelRepository(db)
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
channelCachePubSub := repository.NewChannelCache(redisClient)
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService, channelCachePubSub)
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
compositeModelRouteRepository := repository.NewCompositeModelRouteRepository(client)
compositeRouteResolver := service.NewCompositeRouteResolver(compositeModelRouteRepository)
@@ -182,17 +185,16 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
leaderLockCache := repository.NewLeaderLockCache(redisClient)
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig)
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
adminGroupRepository := repository.NewAdminGroupRepository(client, db)
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
adminService := service.NewAdminService(configConfig, userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
groupHandler := admin.NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, configConfig)
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig)
grokQuotaFetcher := service.NewGrokQuotaFetcher()
@@ -205,12 +207,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo)
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
dataManagementService := service.NewDataManagementService()
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
backupObjectStoreFactory := repository.NewS3BackupStoreFactory()
dbDumper := repository.NewPgDumper(configConfig)
dbDumper := repository.NewPgDumper(configConfig, db)
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper, leaderLockCache, db)
imageStorageFactory := repository.ProvideImageStorageFactory()
imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig)
@@ -283,7 +285,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService)
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
+3 -1
View File
@@ -182,6 +182,8 @@ const (
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
ProviderMinimax Provider = "minimax"
ProviderOpencodeGo Provider = "opencode_go"
)
func (pr Provider) String() string {
@@ -191,7 +193,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax, ProviderOpencodeGo:
return nil
default:
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
@@ -108,6 +108,8 @@ const (
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
ProviderMinimax Provider = "minimax"
ProviderOpencodeGo Provider = "opencode_go"
)
func (pr Provider) String() string {
@@ -117,7 +119,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax, ProviderOpencodeGo:
return nil
default:
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
+17 -1
View File
@@ -4595,6 +4595,22 @@ func (c *ProxyClient) QueryAccounts(_m *Proxy) *AccountQuery {
return query
}
// QueryPrimaryProxies queries the primary_proxies edge of a Proxy.
func (c *ProxyClient) QueryPrimaryProxies(_m *Proxy) *ProxyQuery {
query := (&ProxyClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := _m.ID
step := sqlgraph.NewStep(
sqlgraph.From(proxy.Table, proxy.FieldID, id),
sqlgraph.To(proxy.Table, proxy.FieldID),
sqlgraph.Edge(sqlgraph.O2M, true, proxy.PrimaryProxiesTable, proxy.PrimaryProxiesColumn),
)
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
return fromV, nil
}
return query
}
// QueryBackupProxy queries the backup_proxy edge of a Proxy.
func (c *ProxyClient) QueryBackupProxy(_m *Proxy) *ProxyQuery {
query := (&ProxyClient{config: c.config}).Query()
@@ -4603,7 +4619,7 @@ func (c *ProxyClient) QueryBackupProxy(_m *Proxy) *ProxyQuery {
step := sqlgraph.NewStep(
sqlgraph.From(proxy.Table, proxy.FieldID, id),
sqlgraph.To(proxy.Table, proxy.FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
sqlgraph.Edge(sqlgraph.M2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
)
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
return fromV, nil
+9 -9
View File
@@ -134,8 +134,8 @@ type Group struct {
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
// OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
// 自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config,omitempty"`
// 分组模型白名单:同时约束模型列表接口与请求准入
ModelAllowlist domain.GroupModelAllowlist `json:"model_allowlist,omitempty"`
// 固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台)
CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config,omitempty"`
// 分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流
@@ -258,7 +258,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings:
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings:
values[i] = new([]byte)
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
values[i] = new(sql.NullBool)
@@ -662,12 +662,12 @@ func (_m *Group) assignValues(columns []string, values []any) error {
return fmt.Errorf("unmarshal field messages_dispatch_model_config: %w", err)
}
}
case group.FieldModelsListConfig:
case group.FieldModelAllowlist:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field models_list_config", values[i])
return fmt.Errorf("unexpected type %T for field model_allowlist", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.ModelsListConfig); err != nil {
return fmt.Errorf("unmarshal field models_list_config: %w", err)
if err := json.Unmarshal(*value, &_m.ModelAllowlist); err != nil {
return fmt.Errorf("unmarshal field model_allowlist: %w", err)
}
}
case group.FieldCodexModelsManifestConfig:
@@ -1007,8 +1007,8 @@ func (_m *Group) String() string {
builder.WriteString("messages_dispatch_model_config=")
builder.WriteString(fmt.Sprintf("%v", _m.MessagesDispatchModelConfig))
builder.WriteString(", ")
builder.WriteString("models_list_config=")
builder.WriteString(fmt.Sprintf("%v", _m.ModelsListConfig))
builder.WriteString("model_allowlist=")
builder.WriteString(fmt.Sprintf("%v", _m.ModelAllowlist))
builder.WriteString(", ")
builder.WriteString("codex_models_manifest_config=")
builder.WriteString(fmt.Sprintf("%v", _m.CodexModelsManifestConfig))
+5 -5
View File
@@ -130,8 +130,8 @@ const (
FieldDefaultMappedModel = "default_mapped_model"
// FieldMessagesDispatchModelConfig holds the string denoting the messages_dispatch_model_config field in the database.
FieldMessagesDispatchModelConfig = "messages_dispatch_model_config"
// FieldModelsListConfig holds the string denoting the models_list_config field in the database.
FieldModelsListConfig = "models_list_config"
// FieldModelAllowlist holds the string denoting the model_allowlist field in the database.
FieldModelAllowlist = "model_allowlist"
// FieldCodexModelsManifestConfig holds the string denoting the codex_models_manifest_config field in the database.
FieldCodexModelsManifestConfig = "codex_models_manifest_config"
// FieldRpmLimit holds the string denoting the rpm_limit field in the database.
@@ -280,7 +280,7 @@ var Columns = []string{
FieldRequirePrivacySet,
FieldDefaultMappedModel,
FieldMessagesDispatchModelConfig,
FieldModelsListConfig,
FieldModelAllowlist,
FieldCodexModelsManifestConfig,
FieldRpmLimit,
FieldMaxReasoningEffort,
@@ -412,8 +412,8 @@ var (
DefaultMappedModelValidator func(string) error
// DefaultMessagesDispatchModelConfig holds the default value on creation for the "messages_dispatch_model_config" field.
DefaultMessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig
// DefaultModelsListConfig holds the default value on creation for the "models_list_config" field.
DefaultModelsListConfig domain.GroupModelsListConfig
// DefaultModelAllowlist holds the default value on creation for the "model_allowlist" field.
DefaultModelAllowlist domain.GroupModelAllowlist
// DefaultCodexModelsManifestConfig holds the default value on creation for the "codex_models_manifest_config" field.
DefaultCodexModelsManifestConfig domain.GroupCodexModelsManifestConfig
// DefaultRpmLimit holds the default value on creation for the "rpm_limit" field.
+32 -32
View File
@@ -788,16 +788,16 @@ func (_c *GroupCreate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
return _c
}
// SetModelsListConfig sets the "models_list_config" field.
func (_c *GroupCreate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupCreate {
_c.mutation.SetModelsListConfig(v)
// SetModelAllowlist sets the "model_allowlist" field.
func (_c *GroupCreate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupCreate {
_c.mutation.SetModelAllowlist(v)
return _c
}
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
func (_c *GroupCreate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupCreate {
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
func (_c *GroupCreate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupCreate {
if v != nil {
_c.SetModelsListConfig(*v)
_c.SetModelAllowlist(*v)
}
return _c
}
@@ -1175,9 +1175,9 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultMessagesDispatchModelConfig
_c.mutation.SetMessagesDispatchModelConfig(v)
}
if _, ok := _c.mutation.ModelsListConfig(); !ok {
v := group.DefaultModelsListConfig
_c.mutation.SetModelsListConfig(v)
if _, ok := _c.mutation.ModelAllowlist(); !ok {
v := group.DefaultModelAllowlist
_c.mutation.SetModelAllowlist(v)
}
if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok {
v := group.DefaultCodexModelsManifestConfig
@@ -1381,8 +1381,8 @@ func (_c *GroupCreate) check() error {
if _, ok := _c.mutation.MessagesDispatchModelConfig(); !ok {
return &ValidationError{Name: "messages_dispatch_model_config", err: errors.New(`ent: missing required field "Group.messages_dispatch_model_config"`)}
}
if _, ok := _c.mutation.ModelsListConfig(); !ok {
return &ValidationError{Name: "models_list_config", err: errors.New(`ent: missing required field "Group.models_list_config"`)}
if _, ok := _c.mutation.ModelAllowlist(); !ok {
return &ValidationError{Name: "model_allowlist", err: errors.New(`ent: missing required field "Group.model_allowlist"`)}
}
if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok {
return &ValidationError{Name: "codex_models_manifest_config", err: errors.New(`ent: missing required field "Group.codex_models_manifest_config"`)}
@@ -1673,9 +1673,9 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
_node.MessagesDispatchModelConfig = value
}
if value, ok := _c.mutation.ModelsListConfig(); ok {
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
_node.ModelsListConfig = value
if value, ok := _c.mutation.ModelAllowlist(); ok {
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
_node.ModelAllowlist = value
}
if value, ok := _c.mutation.CodexModelsManifestConfig(); ok {
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
@@ -2795,15 +2795,15 @@ func (u *GroupUpsert) UpdateMessagesDispatchModelConfig() *GroupUpsert {
return u
}
// SetModelsListConfig sets the "models_list_config" field.
func (u *GroupUpsert) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsert {
u.Set(group.FieldModelsListConfig, v)
// SetModelAllowlist sets the "model_allowlist" field.
func (u *GroupUpsert) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsert {
u.Set(group.FieldModelAllowlist, v)
return u
}
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
func (u *GroupUpsert) UpdateModelsListConfig() *GroupUpsert {
u.SetExcluded(group.FieldModelsListConfig)
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
func (u *GroupUpsert) UpdateModelAllowlist() *GroupUpsert {
u.SetExcluded(group.FieldModelAllowlist)
return u
}
@@ -4054,17 +4054,17 @@ func (u *GroupUpsertOne) UpdateMessagesDispatchModelConfig() *GroupUpsertOne {
})
}
// SetModelsListConfig sets the "models_list_config" field.
func (u *GroupUpsertOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertOne {
// SetModelAllowlist sets the "model_allowlist" field.
func (u *GroupUpsertOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetModelsListConfig(v)
s.SetModelAllowlist(v)
})
}
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateModelsListConfig() *GroupUpsertOne {
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateModelAllowlist() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateModelsListConfig()
s.UpdateModelAllowlist()
})
}
@@ -5500,17 +5500,17 @@ func (u *GroupUpsertBulk) UpdateMessagesDispatchModelConfig() *GroupUpsertBulk {
})
}
// SetModelsListConfig sets the "models_list_config" field.
func (u *GroupUpsertBulk) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertBulk {
// SetModelAllowlist sets the "model_allowlist" field.
func (u *GroupUpsertBulk) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetModelsListConfig(v)
s.SetModelAllowlist(v)
})
}
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateModelsListConfig() *GroupUpsertBulk {
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateModelAllowlist() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateModelsListConfig()
s.UpdateModelAllowlist()
})
}
+16 -16
View File
@@ -1073,16 +1073,16 @@ func (_u *GroupUpdate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
return _u
}
// SetModelsListConfig sets the "models_list_config" field.
func (_u *GroupUpdate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdate {
_u.mutation.SetModelsListConfig(v)
// SetModelAllowlist sets the "model_allowlist" field.
func (_u *GroupUpdate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdate {
_u.mutation.SetModelAllowlist(v)
return _u
}
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdate {
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdate {
if v != nil {
_u.SetModelsListConfig(*v)
_u.SetModelAllowlist(*v)
}
return _u
}
@@ -1876,8 +1876,8 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
}
if value, ok := _u.mutation.ModelsListConfig(); ok {
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
if value, ok := _u.mutation.ModelAllowlist(); ok {
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
}
if value, ok := _u.mutation.CodexModelsManifestConfig(); ok {
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
@@ -3267,16 +3267,16 @@ func (_u *GroupUpdateOne) SetNillableMessagesDispatchModelConfig(v *domain.OpenA
return _u
}
// SetModelsListConfig sets the "models_list_config" field.
func (_u *GroupUpdateOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdateOne {
_u.mutation.SetModelsListConfig(v)
// SetModelAllowlist sets the "model_allowlist" field.
func (_u *GroupUpdateOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdateOne {
_u.mutation.SetModelAllowlist(v)
return _u
}
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdateOne {
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdateOne {
if v != nil {
_u.SetModelsListConfig(*v)
_u.SetModelAllowlist(*v)
}
return _u
}
@@ -4100,8 +4100,8 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
}
if value, ok := _u.mutation.ModelsListConfig(); ok {
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
if value, ok := _u.mutation.ModelAllowlist(); ok {
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
}
if value, ok := _u.mutation.CodexModelsManifestConfig(); ok {
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
+4 -4
View File
@@ -623,7 +623,7 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"}},
{Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"},
{Name: "account_id", Type: field.TypeInt64, Nullable: true},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
@@ -776,7 +776,7 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"}},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
{Name: "extra_headers", Type: field.TypeJSON},
@@ -960,7 +960,7 @@ var (
{Name: "require_privacy_set", Type: field.TypeBool, Default: false},
{Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""},
{Name: "messages_dispatch_model_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "models_list_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "model_allowlist", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "codex_models_manifest_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "rpm_limit", Type: field.TypeInt, Default: 0},
{Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""},
@@ -1413,7 +1413,7 @@ var (
{Name: "expires_at", Type: field.TypeTime, Nullable: true},
{Name: "fallback_mode", Type: field.TypeString, Size: 20, Default: "none"},
{Name: "expiry_warn_days", Type: field.TypeInt, Default: 7},
{Name: "backup_proxy_id", Type: field.TypeInt64, Unique: true, Nullable: true},
{Name: "backup_proxy_id", Type: field.TypeInt64, Nullable: true},
}
// ProxiesTable holds the schema information for the "proxies" table.
ProxiesTable = &schema.Table{
+140 -57
View File
@@ -22164,7 +22164,7 @@ type GroupMutation struct {
require_privacy_set *bool
default_mapped_model *string
messages_dispatch_model_config *domain.OpenAIMessagesDispatchModelConfig
models_list_config *domain.GroupModelsListConfig
model_allowlist *domain.GroupModelAllowlist
codex_models_manifest_config *domain.GroupCodexModelsManifestConfig
rpm_limit *int
addrpm_limit *int
@@ -25164,40 +25164,40 @@ func (m *GroupMutation) ResetMessagesDispatchModelConfig() {
m.messages_dispatch_model_config = nil
}
// SetModelsListConfig sets the "models_list_config" field.
func (m *GroupMutation) SetModelsListConfig(dmlc domain.GroupModelsListConfig) {
m.models_list_config = &dmlc
// SetModelAllowlist sets the "model_allowlist" field.
func (m *GroupMutation) SetModelAllowlist(dma domain.GroupModelAllowlist) {
m.model_allowlist = &dma
}
// ModelsListConfig returns the value of the "models_list_config" field in the mutation.
func (m *GroupMutation) ModelsListConfig() (r domain.GroupModelsListConfig, exists bool) {
v := m.models_list_config
// ModelAllowlist returns the value of the "model_allowlist" field in the mutation.
func (m *GroupMutation) ModelAllowlist() (r domain.GroupModelAllowlist, exists bool) {
v := m.model_allowlist
if v == nil {
return
}
return *v, true
}
// OldModelsListConfig returns the old "models_list_config" field's value of the Group entity.
// OldModelAllowlist returns the old "model_allowlist" field's value of the Group entity.
// If the Group object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *GroupMutation) OldModelsListConfig(ctx context.Context) (v domain.GroupModelsListConfig, err error) {
func (m *GroupMutation) OldModelAllowlist(ctx context.Context) (v domain.GroupModelAllowlist, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldModelsListConfig is only allowed on UpdateOne operations")
return v, errors.New("OldModelAllowlist is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldModelsListConfig requires an ID field in the mutation")
return v, errors.New("OldModelAllowlist requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldModelsListConfig: %w", err)
return v, fmt.Errorf("querying old value for OldModelAllowlist: %w", err)
}
return oldValue.ModelsListConfig, nil
return oldValue.ModelAllowlist, nil
}
// ResetModelsListConfig resets all changes to the "models_list_config" field.
func (m *GroupMutation) ResetModelsListConfig() {
m.models_list_config = nil
// ResetModelAllowlist resets all changes to the "model_allowlist" field.
func (m *GroupMutation) ResetModelAllowlist() {
m.model_allowlist = nil
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
@@ -26093,8 +26093,8 @@ func (m *GroupMutation) Fields() []string {
if m.messages_dispatch_model_config != nil {
fields = append(fields, group.FieldMessagesDispatchModelConfig)
}
if m.models_list_config != nil {
fields = append(fields, group.FieldModelsListConfig)
if m.model_allowlist != nil {
fields = append(fields, group.FieldModelAllowlist)
}
if m.codex_models_manifest_config != nil {
fields = append(fields, group.FieldCodexModelsManifestConfig)
@@ -26242,8 +26242,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.DefaultMappedModel()
case group.FieldMessagesDispatchModelConfig:
return m.MessagesDispatchModelConfig()
case group.FieldModelsListConfig:
return m.ModelsListConfig()
case group.FieldModelAllowlist:
return m.ModelAllowlist()
case group.FieldCodexModelsManifestConfig:
return m.CodexModelsManifestConfig()
case group.FieldRpmLimit:
@@ -26383,8 +26383,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldDefaultMappedModel(ctx)
case group.FieldMessagesDispatchModelConfig:
return m.OldMessagesDispatchModelConfig(ctx)
case group.FieldModelsListConfig:
return m.OldModelsListConfig(ctx)
case group.FieldModelAllowlist:
return m.OldModelAllowlist(ctx)
case group.FieldCodexModelsManifestConfig:
return m.OldCodexModelsManifestConfig(ctx)
case group.FieldRpmLimit:
@@ -26809,12 +26809,12 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetMessagesDispatchModelConfig(v)
return nil
case group.FieldModelsListConfig:
v, ok := value.(domain.GroupModelsListConfig)
case group.FieldModelAllowlist:
v, ok := value.(domain.GroupModelAllowlist)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetModelsListConfig(v)
m.SetModelAllowlist(v)
return nil
case group.FieldCodexModelsManifestConfig:
v, ok := value.(domain.GroupCodexModelsManifestConfig)
@@ -27554,8 +27554,8 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldMessagesDispatchModelConfig:
m.ResetMessagesDispatchModelConfig()
return nil
case group.FieldModelsListConfig:
m.ResetModelsListConfig()
case group.FieldModelAllowlist:
m.ResetModelAllowlist()
return nil
case group.FieldCodexModelsManifestConfig:
m.ResetCodexModelsManifestConfig()
@@ -37366,33 +37366,36 @@ func (m *PromoCodeUsageMutation) ResetEdge(name string) error {
// ProxyMutation represents an operation that mutates the Proxy nodes in the graph.
type ProxyMutation struct {
config
op Op
typ string
id *int64
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
name *string
protocol *string
host *string
port *int
addport *int
username *string
password *string
status *string
expires_at *time.Time
fallback_mode *string
expiry_warn_days *int
addexpiry_warn_days *int
clearedFields map[string]struct{}
accounts map[int64]struct{}
removedaccounts map[int64]struct{}
clearedaccounts bool
backup_proxy *int64
clearedbackup_proxy bool
done bool
oldValue func(context.Context) (*Proxy, error)
predicates []predicate.Proxy
op Op
typ string
id *int64
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
name *string
protocol *string
host *string
port *int
addport *int
username *string
password *string
status *string
expires_at *time.Time
fallback_mode *string
expiry_warn_days *int
addexpiry_warn_days *int
clearedFields map[string]struct{}
accounts map[int64]struct{}
removedaccounts map[int64]struct{}
clearedaccounts bool
primary_proxies map[int64]struct{}
removedprimary_proxies map[int64]struct{}
clearedprimary_proxies bool
backup_proxy *int64
clearedbackup_proxy bool
done bool
oldValue func(context.Context) (*Proxy, error)
predicates []predicate.Proxy
}
var _ ent.Mutation = (*ProxyMutation)(nil)
@@ -38156,6 +38159,60 @@ func (m *ProxyMutation) ResetAccounts() {
m.removedaccounts = nil
}
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by ids.
func (m *ProxyMutation) AddPrimaryProxyIDs(ids ...int64) {
if m.primary_proxies == nil {
m.primary_proxies = make(map[int64]struct{})
}
for i := range ids {
m.primary_proxies[ids[i]] = struct{}{}
}
}
// ClearPrimaryProxies clears the "primary_proxies" edge to the Proxy entity.
func (m *ProxyMutation) ClearPrimaryProxies() {
m.clearedprimary_proxies = true
}
// PrimaryProxiesCleared reports if the "primary_proxies" edge to the Proxy entity was cleared.
func (m *ProxyMutation) PrimaryProxiesCleared() bool {
return m.clearedprimary_proxies
}
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to the Proxy entity by IDs.
func (m *ProxyMutation) RemovePrimaryProxyIDs(ids ...int64) {
if m.removedprimary_proxies == nil {
m.removedprimary_proxies = make(map[int64]struct{})
}
for i := range ids {
delete(m.primary_proxies, ids[i])
m.removedprimary_proxies[ids[i]] = struct{}{}
}
}
// RemovedPrimaryProxies returns the removed IDs of the "primary_proxies" edge to the Proxy entity.
func (m *ProxyMutation) RemovedPrimaryProxiesIDs() (ids []int64) {
for id := range m.removedprimary_proxies {
ids = append(ids, id)
}
return
}
// PrimaryProxiesIDs returns the "primary_proxies" edge IDs in the mutation.
func (m *ProxyMutation) PrimaryProxiesIDs() (ids []int64) {
for id := range m.primary_proxies {
ids = append(ids, id)
}
return
}
// ResetPrimaryProxies resets all changes to the "primary_proxies" edge.
func (m *ProxyMutation) ResetPrimaryProxies() {
m.primary_proxies = nil
m.clearedprimary_proxies = false
m.removedprimary_proxies = nil
}
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
func (m *ProxyMutation) ClearBackupProxy() {
m.clearedbackup_proxy = true
@@ -38597,10 +38654,13 @@ func (m *ProxyMutation) ResetField(name string) error {
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *ProxyMutation) AddedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.accounts != nil {
edges = append(edges, proxy.EdgeAccounts)
}
if m.primary_proxies != nil {
edges = append(edges, proxy.EdgePrimaryProxies)
}
if m.backup_proxy != nil {
edges = append(edges, proxy.EdgeBackupProxy)
}
@@ -38617,6 +38677,12 @@ func (m *ProxyMutation) AddedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
case proxy.EdgePrimaryProxies:
ids := make([]ent.Value, 0, len(m.primary_proxies))
for id := range m.primary_proxies {
ids = append(ids, id)
}
return ids
case proxy.EdgeBackupProxy:
if id := m.backup_proxy; id != nil {
return []ent.Value{*id}
@@ -38627,10 +38693,13 @@ func (m *ProxyMutation) AddedIDs(name string) []ent.Value {
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *ProxyMutation) RemovedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.removedaccounts != nil {
edges = append(edges, proxy.EdgeAccounts)
}
if m.removedprimary_proxies != nil {
edges = append(edges, proxy.EdgePrimaryProxies)
}
return edges
}
@@ -38644,16 +38713,25 @@ func (m *ProxyMutation) RemovedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
case proxy.EdgePrimaryProxies:
ids := make([]ent.Value, 0, len(m.removedprimary_proxies))
for id := range m.removedprimary_proxies {
ids = append(ids, id)
}
return ids
}
return nil
}
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *ProxyMutation) ClearedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.clearedaccounts {
edges = append(edges, proxy.EdgeAccounts)
}
if m.clearedprimary_proxies {
edges = append(edges, proxy.EdgePrimaryProxies)
}
if m.clearedbackup_proxy {
edges = append(edges, proxy.EdgeBackupProxy)
}
@@ -38666,6 +38744,8 @@ func (m *ProxyMutation) EdgeCleared(name string) bool {
switch name {
case proxy.EdgeAccounts:
return m.clearedaccounts
case proxy.EdgePrimaryProxies:
return m.clearedprimary_proxies
case proxy.EdgeBackupProxy:
return m.clearedbackup_proxy
}
@@ -38690,6 +38770,9 @@ func (m *ProxyMutation) ResetEdge(name string) error {
case proxy.EdgeAccounts:
m.ResetAccounts()
return nil
case proxy.EdgePrimaryProxies:
m.ResetPrimaryProxies()
return nil
case proxy.EdgeBackupProxy:
m.ResetBackupProxy()
return nil
+18 -2
View File
@@ -55,11 +55,13 @@ type Proxy struct {
type ProxyEdges struct {
// Accounts holds the value of the accounts edge.
Accounts []*Account `json:"accounts,omitempty"`
// PrimaryProxies holds the value of the primary_proxies edge.
PrimaryProxies []*Proxy `json:"primary_proxies,omitempty"`
// BackupProxy holds the value of the backup_proxy edge.
BackupProxy *Proxy `json:"backup_proxy,omitempty"`
// loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not.
loadedTypes [2]bool
loadedTypes [3]bool
}
// AccountsOrErr returns the Accounts value or an error if the edge
@@ -71,12 +73,21 @@ func (e ProxyEdges) AccountsOrErr() ([]*Account, error) {
return nil, &NotLoadedError{edge: "accounts"}
}
// PrimaryProxiesOrErr returns the PrimaryProxies value or an error if the edge
// was not loaded in eager-loading.
func (e ProxyEdges) PrimaryProxiesOrErr() ([]*Proxy, error) {
if e.loadedTypes[1] {
return e.PrimaryProxies, nil
}
return nil, &NotLoadedError{edge: "primary_proxies"}
}
// BackupProxyOrErr returns the BackupProxy value or an error if the edge
// was not loaded in eager-loading, or loaded but was not found.
func (e ProxyEdges) BackupProxyOrErr() (*Proxy, error) {
if e.BackupProxy != nil {
return e.BackupProxy, nil
} else if e.loadedTypes[1] {
} else if e.loadedTypes[2] {
return nil, &NotFoundError{label: proxy.Label}
}
return nil, &NotLoadedError{edge: "backup_proxy"}
@@ -221,6 +232,11 @@ func (_m *Proxy) QueryAccounts() *AccountQuery {
return NewProxyClient(_m.config).QueryAccounts(_m)
}
// QueryPrimaryProxies queries the "primary_proxies" edge of the Proxy entity.
func (_m *Proxy) QueryPrimaryProxies() *ProxyQuery {
return NewProxyClient(_m.config).QueryPrimaryProxies(_m)
}
// QueryBackupProxy queries the "backup_proxy" edge of the Proxy entity.
func (_m *Proxy) QueryBackupProxy() *ProxyQuery {
return NewProxyClient(_m.config).QueryBackupProxy(_m)
+28 -1
View File
@@ -45,6 +45,8 @@ const (
FieldExpiryWarnDays = "expiry_warn_days"
// EdgeAccounts holds the string denoting the accounts edge name in mutations.
EdgeAccounts = "accounts"
// EdgePrimaryProxies holds the string denoting the primary_proxies edge name in mutations.
EdgePrimaryProxies = "primary_proxies"
// EdgeBackupProxy holds the string denoting the backup_proxy edge name in mutations.
EdgeBackupProxy = "backup_proxy"
// Table holds the table name of the proxy in the database.
@@ -56,6 +58,10 @@ const (
AccountsInverseTable = "accounts"
// AccountsColumn is the table column denoting the accounts relation/edge.
AccountsColumn = "proxy_id"
// PrimaryProxiesTable is the table that holds the primary_proxies relation/edge.
PrimaryProxiesTable = "proxies"
// PrimaryProxiesColumn is the table column denoting the primary_proxies relation/edge.
PrimaryProxiesColumn = "backup_proxy_id"
// BackupProxyTable is the table that holds the backup_proxy relation/edge.
BackupProxyTable = "proxies"
// BackupProxyColumn is the table column denoting the backup_proxy relation/edge.
@@ -219,6 +225,20 @@ func ByAccounts(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
}
}
// ByPrimaryProxiesCount orders the results by primary_proxies count.
func ByPrimaryProxiesCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborsCount(s, newPrimaryProxiesStep(), opts...)
}
}
// ByPrimaryProxies orders the results by primary_proxies terms.
func ByPrimaryProxies(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newPrimaryProxiesStep(), append([]sql.OrderTerm{term}, terms...)...)
}
}
// ByBackupProxyField orders the results by backup_proxy field.
func ByBackupProxyField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
@@ -232,10 +252,17 @@ func newAccountsStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.O2M, true, AccountsTable, AccountsColumn),
)
}
func newPrimaryProxiesStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2M, true, PrimaryProxiesTable, PrimaryProxiesColumn),
)
}
func newBackupProxyStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, BackupProxyTable, BackupProxyColumn),
sqlgraph.Edge(sqlgraph.M2O, false, BackupProxyTable, BackupProxyColumn),
)
}
+24 -1
View File
@@ -913,12 +913,35 @@ func HasAccountsWith(preds ...predicate.Account) predicate.Proxy {
})
}
// HasPrimaryProxies applies the HasEdge predicate on the "primary_proxies" edge.
func HasPrimaryProxies() predicate.Proxy {
return predicate.Proxy(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2M, true, PrimaryProxiesTable, PrimaryProxiesColumn),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasPrimaryProxiesWith applies the HasEdge predicate on the "primary_proxies" edge with a given conditions (other predicates).
func HasPrimaryProxiesWith(preds ...predicate.Proxy) predicate.Proxy {
return predicate.Proxy(func(s *sql.Selector) {
step := newPrimaryProxiesStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// HasBackupProxy applies the HasEdge predicate on the "backup_proxy" edge.
func HasBackupProxy() predicate.Proxy {
return predicate.Proxy(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, BackupProxyTable, BackupProxyColumn),
sqlgraph.Edge(sqlgraph.M2O, false, BackupProxyTable, BackupProxyColumn),
)
sqlgraph.HasNeighbors(s, step)
})
+33 -2
View File
@@ -202,6 +202,21 @@ func (_c *ProxyCreate) AddAccounts(v ...*Account) *ProxyCreate {
return _c.AddAccountIDs(ids...)
}
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
func (_c *ProxyCreate) AddPrimaryProxyIDs(ids ...int64) *ProxyCreate {
_c.mutation.AddPrimaryProxyIDs(ids...)
return _c
}
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
func (_c *ProxyCreate) AddPrimaryProxies(v ...*Proxy) *ProxyCreate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _c.AddPrimaryProxyIDs(ids...)
}
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
func (_c *ProxyCreate) SetBackupProxy(v *Proxy) *ProxyCreate {
return _c.SetBackupProxyID(v.ID)
@@ -432,13 +447,29 @@ func (_c *ProxyCreate) createSpec() (*Proxy, *sqlgraph.CreateSpec) {
}
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := _c.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := _c.mutation.BackupProxyIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
+92 -16
View File
@@ -21,13 +21,14 @@ import (
// ProxyQuery is the builder for querying Proxy entities.
type ProxyQuery struct {
config
ctx *QueryContext
order []proxy.OrderOption
inters []Interceptor
predicates []predicate.Proxy
withAccounts *AccountQuery
withBackupProxy *ProxyQuery
modifiers []func(*sql.Selector)
ctx *QueryContext
order []proxy.OrderOption
inters []Interceptor
predicates []predicate.Proxy
withAccounts *AccountQuery
withPrimaryProxies *ProxyQuery
withBackupProxy *ProxyQuery
modifiers []func(*sql.Selector)
// intermediate query (i.e. traversal path).
sql *sql.Selector
path func(context.Context) (*sql.Selector, error)
@@ -86,6 +87,28 @@ func (_q *ProxyQuery) QueryAccounts() *AccountQuery {
return query
}
// QueryPrimaryProxies chains the current query on the "primary_proxies" edge.
func (_q *ProxyQuery) QueryPrimaryProxies() *ProxyQuery {
query := (&ProxyClient{config: _q.config}).Query()
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
if err := _q.prepareQuery(ctx); err != nil {
return nil, err
}
selector := _q.sqlQuery(ctx)
if err := selector.Err(); err != nil {
return nil, err
}
step := sqlgraph.NewStep(
sqlgraph.From(proxy.Table, proxy.FieldID, selector),
sqlgraph.To(proxy.Table, proxy.FieldID),
sqlgraph.Edge(sqlgraph.O2M, true, proxy.PrimaryProxiesTable, proxy.PrimaryProxiesColumn),
)
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
return fromU, nil
}
return query
}
// QueryBackupProxy chains the current query on the "backup_proxy" edge.
func (_q *ProxyQuery) QueryBackupProxy() *ProxyQuery {
query := (&ProxyClient{config: _q.config}).Query()
@@ -100,7 +123,7 @@ func (_q *ProxyQuery) QueryBackupProxy() *ProxyQuery {
step := sqlgraph.NewStep(
sqlgraph.From(proxy.Table, proxy.FieldID, selector),
sqlgraph.To(proxy.Table, proxy.FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
sqlgraph.Edge(sqlgraph.M2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
)
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
return fromU, nil
@@ -295,13 +318,14 @@ func (_q *ProxyQuery) Clone() *ProxyQuery {
return nil
}
return &ProxyQuery{
config: _q.config,
ctx: _q.ctx.Clone(),
order: append([]proxy.OrderOption{}, _q.order...),
inters: append([]Interceptor{}, _q.inters...),
predicates: append([]predicate.Proxy{}, _q.predicates...),
withAccounts: _q.withAccounts.Clone(),
withBackupProxy: _q.withBackupProxy.Clone(),
config: _q.config,
ctx: _q.ctx.Clone(),
order: append([]proxy.OrderOption{}, _q.order...),
inters: append([]Interceptor{}, _q.inters...),
predicates: append([]predicate.Proxy{}, _q.predicates...),
withAccounts: _q.withAccounts.Clone(),
withPrimaryProxies: _q.withPrimaryProxies.Clone(),
withBackupProxy: _q.withBackupProxy.Clone(),
// clone intermediate query.
sql: _q.sql.Clone(),
path: _q.path,
@@ -319,6 +343,17 @@ func (_q *ProxyQuery) WithAccounts(opts ...func(*AccountQuery)) *ProxyQuery {
return _q
}
// WithPrimaryProxies tells the query-builder to eager-load the nodes that are connected to
// the "primary_proxies" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *ProxyQuery) WithPrimaryProxies(opts ...func(*ProxyQuery)) *ProxyQuery {
query := (&ProxyClient{config: _q.config}).Query()
for _, opt := range opts {
opt(query)
}
_q.withPrimaryProxies = query
return _q
}
// WithBackupProxy tells the query-builder to eager-load the nodes that are connected to
// the "backup_proxy" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *ProxyQuery) WithBackupProxy(opts ...func(*ProxyQuery)) *ProxyQuery {
@@ -408,8 +443,9 @@ func (_q *ProxyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Proxy,
var (
nodes = []*Proxy{}
_spec = _q.querySpec()
loadedTypes = [2]bool{
loadedTypes = [3]bool{
_q.withAccounts != nil,
_q.withPrimaryProxies != nil,
_q.withBackupProxy != nil,
}
)
@@ -441,6 +477,13 @@ func (_q *ProxyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Proxy,
return nil, err
}
}
if query := _q.withPrimaryProxies; query != nil {
if err := _q.loadPrimaryProxies(ctx, query, nodes,
func(n *Proxy) { n.Edges.PrimaryProxies = []*Proxy{} },
func(n *Proxy, e *Proxy) { n.Edges.PrimaryProxies = append(n.Edges.PrimaryProxies, e) }); err != nil {
return nil, err
}
}
if query := _q.withBackupProxy; query != nil {
if err := _q.loadBackupProxy(ctx, query, nodes, nil,
func(n *Proxy, e *Proxy) { n.Edges.BackupProxy = e }); err != nil {
@@ -483,6 +526,39 @@ func (_q *ProxyQuery) loadAccounts(ctx context.Context, query *AccountQuery, nod
}
return nil
}
func (_q *ProxyQuery) loadPrimaryProxies(ctx context.Context, query *ProxyQuery, nodes []*Proxy, init func(*Proxy), assign func(*Proxy, *Proxy)) error {
fks := make([]driver.Value, 0, len(nodes))
nodeids := make(map[int64]*Proxy)
for i := range nodes {
fks = append(fks, nodes[i].ID)
nodeids[nodes[i].ID] = nodes[i]
if init != nil {
init(nodes[i])
}
}
if len(query.ctx.Fields) > 0 {
query.ctx.AppendFieldOnce(proxy.FieldBackupProxyID)
}
query.Where(predicate.Proxy(func(s *sql.Selector) {
s.Where(sql.InValues(s.C(proxy.PrimaryProxiesColumn), fks...))
}))
neighbors, err := query.All(ctx)
if err != nil {
return err
}
for _, n := range neighbors {
fk := n.BackupProxyID
if fk == nil {
return fmt.Errorf(`foreign-key "backup_proxy_id" is nil for node %v`, n.ID)
}
node, ok := nodeids[*fk]
if !ok {
return fmt.Errorf(`unexpected referenced foreign-key "backup_proxy_id" returned %v for node %v`, *fk, n.ID)
}
assign(node, n)
}
return nil
}
func (_q *ProxyQuery) loadBackupProxy(ctx context.Context, query *ProxyQuery, nodes []*Proxy, init func(*Proxy), assign func(*Proxy, *Proxy)) error {
ids := make([]int64, 0, len(nodes))
nodeids := make(map[int64][]*Proxy)
+170 -8
View File
@@ -262,6 +262,21 @@ func (_u *ProxyUpdate) AddAccounts(v ...*Account) *ProxyUpdate {
return _u.AddAccountIDs(ids...)
}
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
func (_u *ProxyUpdate) AddPrimaryProxyIDs(ids ...int64) *ProxyUpdate {
_u.mutation.AddPrimaryProxyIDs(ids...)
return _u
}
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
func (_u *ProxyUpdate) AddPrimaryProxies(v ...*Proxy) *ProxyUpdate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.AddPrimaryProxyIDs(ids...)
}
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
func (_u *ProxyUpdate) SetBackupProxy(v *Proxy) *ProxyUpdate {
return _u.SetBackupProxyID(v.ID)
@@ -293,6 +308,27 @@ func (_u *ProxyUpdate) RemoveAccounts(v ...*Account) *ProxyUpdate {
return _u.RemoveAccountIDs(ids...)
}
// ClearPrimaryProxies clears all "primary_proxies" edges to the Proxy entity.
func (_u *ProxyUpdate) ClearPrimaryProxies() *ProxyUpdate {
_u.mutation.ClearPrimaryProxies()
return _u
}
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to Proxy entities by IDs.
func (_u *ProxyUpdate) RemovePrimaryProxyIDs(ids ...int64) *ProxyUpdate {
_u.mutation.RemovePrimaryProxyIDs(ids...)
return _u
}
// RemovePrimaryProxies removes "primary_proxies" edges to Proxy entities.
func (_u *ProxyUpdate) RemovePrimaryProxies(v ...*Proxy) *ProxyUpdate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.RemovePrimaryProxyIDs(ids...)
}
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
func (_u *ProxyUpdate) ClearBackupProxy() *ProxyUpdate {
_u.mutation.ClearBackupProxy()
@@ -492,13 +528,58 @@ func (_u *ProxyUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.PrimaryProxiesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.RemovedPrimaryProxiesIDs(); len(nodes) > 0 && !_u.mutation.PrimaryProxiesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.BackupProxyCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
@@ -507,11 +588,11 @@ func (_u *ProxyUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
if nodes := _u.mutation.BackupProxyIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
@@ -774,6 +855,21 @@ func (_u *ProxyUpdateOne) AddAccounts(v ...*Account) *ProxyUpdateOne {
return _u.AddAccountIDs(ids...)
}
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
func (_u *ProxyUpdateOne) AddPrimaryProxyIDs(ids ...int64) *ProxyUpdateOne {
_u.mutation.AddPrimaryProxyIDs(ids...)
return _u
}
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
func (_u *ProxyUpdateOne) AddPrimaryProxies(v ...*Proxy) *ProxyUpdateOne {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.AddPrimaryProxyIDs(ids...)
}
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
func (_u *ProxyUpdateOne) SetBackupProxy(v *Proxy) *ProxyUpdateOne {
return _u.SetBackupProxyID(v.ID)
@@ -805,6 +901,27 @@ func (_u *ProxyUpdateOne) RemoveAccounts(v ...*Account) *ProxyUpdateOne {
return _u.RemoveAccountIDs(ids...)
}
// ClearPrimaryProxies clears all "primary_proxies" edges to the Proxy entity.
func (_u *ProxyUpdateOne) ClearPrimaryProxies() *ProxyUpdateOne {
_u.mutation.ClearPrimaryProxies()
return _u
}
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to Proxy entities by IDs.
func (_u *ProxyUpdateOne) RemovePrimaryProxyIDs(ids ...int64) *ProxyUpdateOne {
_u.mutation.RemovePrimaryProxyIDs(ids...)
return _u
}
// RemovePrimaryProxies removes "primary_proxies" edges to Proxy entities.
func (_u *ProxyUpdateOne) RemovePrimaryProxies(v ...*Proxy) *ProxyUpdateOne {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.RemovePrimaryProxyIDs(ids...)
}
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
func (_u *ProxyUpdateOne) ClearBackupProxy() *ProxyUpdateOne {
_u.mutation.ClearBackupProxy()
@@ -1034,13 +1151,58 @@ func (_u *ProxyUpdateOne) sqlSave(ctx context.Context) (_node *Proxy, err error)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.PrimaryProxiesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.RemovedPrimaryProxiesIDs(); len(nodes) > 0 && !_u.mutation.PrimaryProxiesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.BackupProxyCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
@@ -1049,11 +1211,11 @@ func (_u *ProxyUpdateOne) sqlSave(ctx context.Context) (_node *Proxy, err error)
}
if nodes := _u.mutation.BackupProxyIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
+4 -4
View File
@@ -1183,10 +1183,10 @@ func init() {
groupDescMessagesDispatchModelConfig := groupFields[53].Descriptor()
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
groupDescModelsListConfig := groupFields[54].Descriptor()
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
// groupDescModelAllowlist is the schema descriptor for model_allowlist field.
groupDescModelAllowlist := groupFields[54].Descriptor()
// group.DefaultModelAllowlist holds the default value on creation for the model_allowlist field.
group.DefaultModelAllowlist = groupDescModelAllowlist.Default.(domain.GroupModelAllowlist)
// groupDescCodexModelsManifestConfig is the schema descriptor for codex_models_manifest_config field.
groupDescCodexModelsManifestConfig := groupFields[55].Descriptor()
// group.DefaultCodexModelsManifestConfig holds the default value on creation for the codex_models_manifest_config field.
+1 -1
View File
@@ -36,7 +36,7 @@ func (ChannelMonitor) Fields() []ent.Field {
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
"antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"),
// check_mode: 'probe' | 'quota' | 'quota_probe'
// probe - LLM 探活(默认,原有行为)
// quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空)
@@ -40,7 +40,7 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
"antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"),
field.String("api_mode").
Default("chat_completions").
MaxLen(32).
+3 -3
View File
@@ -262,10 +262,10 @@ func (Group) Fields() []ent.Field {
Default(domain.OpenAIMessagesDispatchModelConfig{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型"),
field.JSON("models_list_config", domain.GroupModelsListConfig{}).
Default(domain.GroupModelsListConfig{}).
field.JSON("model_allowlist", domain.GroupModelAllowlist{}).
Default(domain.GroupModelAllowlist{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度"),
Comment("分组模型白名单:同时约束模型列表接口与请求准入"),
field.JSON("codex_models_manifest_config", domain.GroupCodexModelsManifestConfig{}).
Default(domain.GroupCodexModelsManifestConfig{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
+4
View File
@@ -73,6 +73,10 @@ func (Proxy) Edges() []ent.Edge {
// accounts: 使用此代理的账户(反向边)
edge.From("accounts", Account.Type).
Ref("proxy"),
// Directed many-to-one: a backup can serve multiple primary proxies.
// The inverse edge prevents Ent from treating this self-reference as symmetric.
edge.From("primary_proxies", Proxy.Type).
Ref("backup_proxy"),
edge.To("backup_proxy", Proxy.Type).
Field("backup_proxy_id").
Unique(),
+1 -1
View File
@@ -42,7 +42,7 @@ func (UserPlatformQuota) Fields() []ent.Field {
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
switch s {
case "anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek":
"kimi", "zhipu", "deepseek", "minimax", "opencode_go":
return nil
default:
return fmt.Errorf("platform %q is not allowed", s)
+3 -4
View File
@@ -31,7 +31,7 @@ require (
github.com/lib/pq v1.10.9
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/redis/go-redis/v9 v9.17.2
github.com/redis/go-redis/v9 v9.22.0
github.com/refraction-networking/utls v1.8.2
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.25.6
@@ -101,7 +101,6 @@ require (
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/dlclark/regexp2/v2 v2.1.0 // indirect
github.com/docker/docker v28.5.1+incompatible // indirect
@@ -135,7 +134,7 @@ require (
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
@@ -197,7 +196,7 @@ require (
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
+16 -4
View File
@@ -159,8 +159,6 @@ github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dr
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2/v2 v2.1.0 h1:jHXRmHRZGbuQzDZjMlCAXOvQb75iv3HyLDzXGj5H1AY=
@@ -255,6 +253,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -302,6 +302,8 @@ github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxh
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -327,6 +329,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
@@ -365,6 +369,8 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -393,12 +399,14 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -434,6 +442,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
@@ -535,6 +545,8 @@ go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeX
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+20 -10
View File
@@ -1274,11 +1274,15 @@ type GatewayOpenAIWSConfig struct {
MaxConnsPerAccount int `mapstructure:"max_conns_per_account"`
MinIdlePerAccount int `mapstructure:"min_idle_per_account"`
MaxIdlePerAccount int `mapstructure:"max_idle_per_account"`
// DynamicMaxConnsByAccountConcurrencyEnabled: 是否按账号并发动态计算连接池上限
// DynamicMaxConnsByAccountConcurrencyEnabled: 是否按账号并发动态计算连接池上限。
// 旧版及 mode_router_v2 的 ctx_pool 共用此开关和类型系数;关闭后使用 max_conns_per_account。
// mode_router_v2 下并发数 <= 0 的账号仍不可调度。
DynamicMaxConnsByAccountConcurrencyEnabled bool `mapstructure:"dynamic_max_conns_by_account_concurrency_enabled"`
// OAuthMaxConnsFactor: OAuth 账号连接池系数(effective=ceil(concurrency*factor))
// OAuthMaxConnsFactor: OAuth 账号连接池系数(effective=ceil(concurrency*factor),再受 max_conns_per_account 封顶)。
// ctx_pool 接入下每个客户端会话在整个生命周期(含轮次之间)持有一条上游连接,此上限限制的是同时持有连接的会话数,
// 在飞请求数另由账号并发槽限制;系数 1.0 会让存活会话数一到并发数就返回 1013 busy,默认 5.0。
OAuthMaxConnsFactor float64 `mapstructure:"oauth_max_conns_factor"`
// APIKeyMaxConnsFactor: API Key 账号连接池系数(effective=ceil(concurrency*factor))
// APIKeyMaxConnsFactor: API Key 账号连接池系数,含义与 OAuthMaxConnsFactor 相同,默认 5.0。
APIKeyMaxConnsFactor float64 `mapstructure:"apikey_max_conns_factor"`
DialTimeoutSeconds int `mapstructure:"dial_timeout_seconds"`
ReadTimeoutSeconds int `mapstructure:"read_timeout_seconds"`
@@ -1613,10 +1617,10 @@ type OpsCleanupConfig struct {
Enabled bool `mapstructure:"enabled"`
Schedule string `mapstructure:"schedule"`
// Retention days (0 disables that cleanup target).
//
// vNext requirement: default 30 days across ops datasets.
// Retention days. Error and metrics targets accept 0 as an explicit truncate;
// system logs require a positive value because their runtime setting is bounded.
ErrorLogRetentionDays int `mapstructure:"error_log_retention_days"`
SystemLogRetentionDays int `mapstructure:"system_log_retention_days"`
MinuteMetricsRetentionDays int `mapstructure:"minute_metrics_retention_days"`
HourlyMetricsRetentionDays int `mapstructure:"hourly_metrics_retention_days"`
}
@@ -2046,7 +2050,9 @@ func setDefaults() {
"api.moonshot.ai",
"api.moonshot.cn",
"open.bigmodel.cn",
"api.minimaxi.com",
"api.minimaxi.com", // MiniMax CN quota + inference
"api.minimax.io", // MiniMax intl; frozen allowlists must add this host to use the intl site
"opencode.ai",
"generativelanguage.googleapis.com",
"cloudcode-pa.googleapis.com",
"*.openai.azure.com",
@@ -2250,6 +2256,7 @@ func setDefaults() {
viper.SetDefault("ops.cleanup.schedule", "0 2 * * *")
// Retention days: vNext defaults to 30 days across ops datasets.
viper.SetDefault("ops.cleanup.error_log_retention_days", 30)
viper.SetDefault("ops.cleanup.system_log_retention_days", 30)
viper.SetDefault("ops.cleanup.minute_metrics_retention_days", 30)
viper.SetDefault("ops.cleanup.hourly_metrics_retention_days", 30)
viper.SetDefault("ops.aggregation.enabled", true)
@@ -2373,7 +2380,7 @@ func setDefaults() {
viper.SetDefault("gateway.disable_codex_originator_normalization", false)
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
viper.SetDefault("gateway.openai_compact_model", "gpt-5.4")
viper.SetDefault("gateway.openai_compact_model", "gpt-5.5")
viper.SetDefault("gateway.live.max_session_duration_seconds", 3600)
// OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚)
viper.SetDefault("gateway.openai_ws.enabled", true)
@@ -2399,8 +2406,8 @@ func setDefaults() {
viper.SetDefault("gateway.openai_ws.min_idle_per_account", 4)
viper.SetDefault("gateway.openai_ws.max_idle_per_account", 12)
viper.SetDefault("gateway.openai_ws.dynamic_max_conns_by_account_concurrency_enabled", true)
viper.SetDefault("gateway.openai_ws.oauth_max_conns_factor", 1.0)
viper.SetDefault("gateway.openai_ws.apikey_max_conns_factor", 1.0)
viper.SetDefault("gateway.openai_ws.oauth_max_conns_factor", 5.0)
viper.SetDefault("gateway.openai_ws.apikey_max_conns_factor", 5.0)
viper.SetDefault("gateway.openai_ws.dial_timeout_seconds", 10)
viper.SetDefault("gateway.openai_ws.read_timeout_seconds", 900)
viper.SetDefault("gateway.openai_ws.write_timeout_seconds", 120)
@@ -3673,6 +3680,9 @@ func (c *Config) Validate() error {
if c.Ops.Cleanup.ErrorLogRetentionDays < 0 {
return fmt.Errorf("ops.cleanup.error_log_retention_days must be non-negative")
}
if c.Ops.Cleanup.Enabled && c.Ops.Cleanup.SystemLogRetentionDays <= 0 {
return fmt.Errorf("ops.cleanup.system_log_retention_days must be positive when ops cleanup is enabled")
}
if c.Ops.Cleanup.MinuteMetricsRetentionDays < 0 {
return fmt.Errorf("ops.cleanup.minute_metrics_retention_days must be non-negative")
}
+25 -5
View File
@@ -452,11 +452,11 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
if !cfg.Gateway.OpenAIWS.DynamicMaxConnsByAccountConcurrencyEnabled {
t.Fatalf("Gateway.OpenAIWS.DynamicMaxConnsByAccountConcurrencyEnabled = false, want true")
}
if cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor != 1.0 {
t.Fatalf("Gateway.OpenAIWS.OAuthMaxConnsFactor = %v, want 1.0", cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor)
if cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor != 5.0 {
t.Fatalf("Gateway.OpenAIWS.OAuthMaxConnsFactor = %v, want 5.0", cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor)
}
if cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor != 1.0 {
t.Fatalf("Gateway.OpenAIWS.APIKeyMaxConnsFactor = %v, want 1.0", cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor)
if cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor != 5.0 {
t.Fatalf("Gateway.OpenAIWS.APIKeyMaxConnsFactor = %v, want 5.0", cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor)
}
if cfg.Gateway.OpenAIWS.StickySessionTTLSeconds != 3600 {
t.Fatalf("Gateway.OpenAIWS.StickySessionTTLSeconds = %d, want 3600", cfg.Gateway.OpenAIWS.StickySessionTTLSeconds)
@@ -574,7 +574,7 @@ func TestLoadDefaultOpenAICompactModel(t *testing.T) {
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, "gpt-5.4", cfg.Gateway.OpenAICompactModel)
require.Equal(t, "gpt-5.5", cfg.Gateway.OpenAICompactModel)
}
func TestLoadOpenAICompactModelFromEnv(t *testing.T) {
@@ -1191,6 +1191,21 @@ func TestLoadDefaultUsageCleanupConfig(t *testing.T) {
}
}
func TestLoadDefaultOpsCleanupConfig(t *testing.T) {
resetViperWithJWTSecret(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if !cfg.Ops.Cleanup.Enabled {
t.Fatal("Ops.Cleanup.Enabled = false, want true")
}
if cfg.Ops.Cleanup.SystemLogRetentionDays != 30 {
t.Fatalf("Ops.Cleanup.SystemLogRetentionDays = %d, want 30", cfg.Ops.Cleanup.SystemLogRetentionDays)
}
}
func TestValidateUsageCleanupConfigEnabled(t *testing.T) {
resetViperWithJWTSecret(t)
@@ -2137,6 +2152,11 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Ops.Cleanup.ErrorLogRetentionDays = -1 },
wantErr: "ops.cleanup.error_log_retention_days",
},
{
name: "ops cleanup system log retention",
mutate: func(c *Config) { c.Ops.Cleanup.SystemLogRetentionDays = 0 },
wantErr: "ops.cleanup.system_log_retention_days",
},
{
name: "ops cleanup minute retention",
mutate: func(c *Config) { c.Ops.Cleanup.MinuteMetricsRetentionDays = -1 },
@@ -19,7 +19,8 @@ import "time"
// - "7d" 7 天窗口(Claude/Codex)
// - "7d-sonnet" Claude 7 天 Sonnet 独立额度
// - "7d-fable" Claude 7 天 Fable 独立额度
// - "weekly" 周窗口(Kimi/Zhipu coding plan)
// - "weekly" 周窗口(Kimi/Zhipu coding plan / OpenCode Go)
// - "monthly" 月窗口(OpenCode Go)
// - "daily" 日窗口(Gemini 日配额 / Grok 日请求)
// - "30d" 30 天窗口(Grok 月度)
// - "total" 无窗口语义的总量额度(Antigravity per-model 等)
+23 -5
View File
@@ -24,10 +24,14 @@ const (
PlatformAntigravity = "antigravity"
PlatformGrok = "grok"
// 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformComposite = "composite"
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformMiniMax = "minimax" // MiniMax (M 系列)
// PlatformOpenCodeGo 是 OpenCode 平台(账号类型 Zen 按量 / Go 订阅)。
// 值保持 opencode_go 以兼容已落库的分组、配额与 Composite 路由 CHECK。
PlatformOpenCodeGo = "opencode_go"
PlatformComposite = "composite"
)
// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。
@@ -35,6 +39,8 @@ const (
const (
AccountModePayG = "payg" // 按量付费:消耗余额,做余额检测冷却
AccountModeCoding = "coding" // Coding Plan:滚动用量窗口冷却(5h / weekly)
AccountModeZen = "zen" // OpenCode Zen:按量付费,https://opencode.ai/zen/v1
AccountModeGo = "go" // OpenCode Go:订阅额度窗口,https://opencode.ai/zen/go/v1
)
// API protocol constants 国产供应商的上游 API 协议维度。存储于
@@ -43,7 +49,7 @@ const (
const (
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex)
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi / minimax 原生端点,适配 Codex)
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
)
@@ -145,6 +151,18 @@ var DefaultAntigravityModelMapping = map[string]string{
"gemini-3.6-flash-low": "gemini-3.6-flash-low",
"gemini-3.6-flash-medium": "gemini-3.6-flash-medium",
"gemini-3.6-flash-tiered": "gemini-3.6-flash-tiered",
// Gemini 3.7 Flash tiered models
"gemini-3.7-flash": "gemini-3.7-flash",
"gemini-3.7-flash-high": "gemini-3.7-flash-high",
"gemini-3.7-flash-low": "gemini-3.7-flash-low",
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
"gemini-3.7-flash-tiered": "gemini-3.7-flash-tiered",
// Gemini 3.8 Flash tiered models
"gemini-3.8-flash": "gemini-3.8-flash",
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
"gemini-3.8-flash-low": "gemini-3.8-flash-low",
"gemini-3.8-flash-medium": "gemini-3.8-flash-medium",
"gemini-3.8-flash-tiered": "gemini-3.8-flash-tiered",
// Gemini 3 image 兼容映射(向 3.1 image 迁移)
"gemini-3-pro-image": "gemini-3.1-flash-image",
"gemini-3-pro-image-preview": "gemini-3.1-flash-image",
+16
View File
@@ -89,6 +89,22 @@ func TestDefaultAntigravityModelMapping_Gemini36FlashModels(t *testing.T) {
}
}
func TestDefaultAntigravityModelMapping_Gemini37FlashModels(t *testing.T) {
for _, model := range []string{"gemini-3.7-flash", "gemini-3.7-flash-high", "gemini-3.7-flash-low", "gemini-3.7-flash-medium", "gemini-3.7-flash-tiered"} {
if got := DefaultAntigravityModelMapping[model]; got != model {
t.Fatalf("expected %s to map to itself, got %q", model, got)
}
}
}
func TestDefaultAntigravityModelMapping_Gemini38FlashModels(t *testing.T) {
for _, model := range []string{"gemini-3.8-flash", "gemini-3.8-flash-high", "gemini-3.8-flash-low", "gemini-3.8-flash-medium", "gemini-3.8-flash-tiered"} {
if got := DefaultAntigravityModelMapping[model]; got != model {
t.Fatalf("expected %s to map to itself, got %q", model, got)
}
}
}
func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
t.Parallel()
@@ -0,0 +1,8 @@
package domain
// GroupModelAllowlist 是分组级模型白名单:开启后既过滤模型列表类接口的返回内容,
// 也约束分组实际可调用的模型(网关准入在合成路由改写与调度之前完成)。
type GroupModelAllowlist struct {
Enabled bool `json:"enabled"`
Models []string `json:"models,omitempty"`
}
@@ -1,7 +0,0 @@
package domain
// GroupModelsListConfig controls the optional custom /v1/models response list.
type GroupModelsListConfig struct {
Enabled bool `json:"enabled"`
Models []string `json:"models,omitempty"`
}
+2 -1
View File
@@ -7,7 +7,8 @@ const (
)
// ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort
// value to another before the group ceiling is applied.
// value to another before the group ceiling is applied. To may also be "deny"
// to reject the request when the matching source value is present.
//
// Model and MatchType optionally scope the rewrite to a request model:
// exact matches the full model id, prefix/suffix match a model-id affix.
+10 -6
View File
@@ -309,15 +309,17 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
_, _ = h.adminService.UpdateProxy(ctx, existingID, &service.UpdateProxyInput{
Status: normalizedStatus,
ExpiresAt: existingExpiresAt,
ClearExpiresAt: existingExpiresAt == nil,
FallbackMode: existingFallbackMode,
BackupProxyID: existingBackupProxyID,
ExpiryWarnDays: item.ExpiryWarnDays,
ClearBackupID: existingBackupProxyID == nil,
ExpiryWarnDays: &item.ExpiryWarnDays,
Name: proxy.Name,
Protocol: proxy.Protocol,
Host: proxy.Host,
Port: proxy.Port,
Username: proxy.Username,
Password: proxy.Password,
Username: &proxy.Username,
Password: &proxy.Password,
})
}
}
@@ -383,15 +385,17 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
_, _ = h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{
Status: normalizedStatus,
ExpiresAt: expiresAt,
ClearExpiresAt: expiresAt == nil,
FallbackMode: fallbackMode,
BackupProxyID: backupProxyID,
ExpiryWarnDays: item.ExpiryWarnDays,
ClearBackupID: backupProxyID == nil,
ExpiryWarnDays: &item.ExpiryWarnDays,
Name: created.Name,
Protocol: created.Protocol,
Host: created.Host,
Port: created.Port,
Username: created.Username,
Password: created.Password,
Username: &created.Username,
Password: &created.Password,
})
}
}
@@ -17,6 +17,7 @@ import (
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
@@ -64,6 +65,7 @@ type AccountHandler struct {
grokImportProber grokImportProber
upstreamBillingProbe *service.UpstreamBillingProbeService
ollamaCloudUsage *service.OllamaCloudUsageService
cfg *config.Config
}
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
@@ -190,6 +192,7 @@ type CheckMixedChannelRequest struct {
// AccountWithConcurrency extends Account with real-time concurrency info
type AccountWithConcurrency struct {
*dto.Account
simpleMode bool `json:"-"`
CurrentConcurrency int `json:"current_concurrency"`
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
@@ -212,6 +215,110 @@ type AccountListItemWithConcurrency struct {
CurrentRPM *int `json:"current_rpm,omitempty"`
}
type simpleModeGroupReference struct {
ID int64 `json:"id"`
Name string `json:"name"`
Platform string `json:"platform"`
Status string `json:"status"`
}
type simpleModeAccountGroupReference struct {
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
Group *simpleModeGroupReference `json:"group,omitempty"`
}
func simpleModeGroupReferenceFromDTO(group *dto.Group) *simpleModeGroupReference {
if group == nil {
return nil
}
return &simpleModeGroupReference{ID: group.ID, Name: group.Name, Platform: group.Platform, Status: group.Status}
}
func simpleModeCompositeGroupIDs(account *dto.Account) map[int64]struct{} {
hidden := make(map[int64]struct{})
if account == nil {
return hidden
}
for _, group := range account.Groups {
if group != nil && group.Platform == service.PlatformComposite {
hidden[group.ID] = struct{}{}
}
}
for _, accountGroup := range account.AccountGroups {
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
hidden[accountGroup.GroupID] = struct{}{}
}
}
return hidden
}
func filterSimpleModeGroupIDs(groupIDs []int64, hidden map[int64]struct{}) []int64 {
visible := make([]int64, 0, len(groupIDs))
for _, groupID := range groupIDs {
if _, ok := hidden[groupID]; !ok {
visible = append(visible, groupID)
}
}
return visible
}
func simpleModeCompositeServiceGroupIDs(account *service.Account) map[int64]struct{} {
hidden := make(map[int64]struct{})
if account == nil {
return hidden
}
for _, group := range account.Groups {
if group != nil && group.Platform == service.PlatformComposite {
hidden[group.ID] = struct{}{}
}
}
for _, accountGroup := range account.AccountGroups {
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
hidden[accountGroup.GroupID] = struct{}{}
}
}
return hidden
}
func (a AccountWithConcurrency) MarshalJSON() ([]byte, error) {
type alias AccountWithConcurrency
if !a.simpleMode || a.Account == nil {
return json.Marshal(alias(a))
}
groups := make([]simpleModeGroupReference, 0, len(a.Groups))
compositeIDs := simpleModeCompositeGroupIDs(a.Account)
for _, group := range a.Groups {
if group != nil && group.Platform == service.PlatformComposite {
continue
}
if ref := simpleModeGroupReferenceFromDTO(group); ref != nil {
groups = append(groups, *ref)
}
}
accountGroups := make([]simpleModeAccountGroupReference, 0, len(a.AccountGroups))
for _, accountGroup := range a.AccountGroups {
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
continue
}
if _, hidden := compositeIDs[accountGroup.GroupID]; hidden {
continue
}
accountGroups = append(accountGroups, simpleModeAccountGroupReference{
AccountID: accountGroup.AccountID, GroupID: accountGroup.GroupID, Priority: accountGroup.Priority,
CreatedAt: accountGroup.CreatedAt, Group: simpleModeGroupReferenceFromDTO(accountGroup.Group),
})
}
return json.Marshal(struct {
alias
GroupIDs []int64 `json:"group_ids,omitempty"`
Groups []simpleModeGroupReference `json:"groups"`
AccountGroups []simpleModeAccountGroupReference `json:"account_groups"`
}{alias: alias(a), GroupIDs: filterSimpleModeGroupIDs(a.GroupIDs, compositeIDs), Groups: groups, AccountGroups: accountGroups})
}
type AccountSchedulerScore struct {
BaseScore float64 `json:"base_score"`
StickyScore float64 `json:"sticky_score"`
@@ -247,9 +354,14 @@ func (h *AccountHandler) accountListResponseFromService(account *service.Account
return out
}
func (h *AccountHandler) isSimpleMode() bool {
return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
}
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
item := AccountWithConcurrency{
Account: h.accountResponseFromService(account),
simpleMode: h.isSimpleMode(),
CurrentConcurrency: 0,
}
if account == nil {
@@ -677,9 +789,13 @@ func (h *AccountHandler) List(c *gin.Context) {
accountResponse := h.accountResponseFromService(acc)
if lite {
accountResponse = h.accountListResponseFromService(acc)
if h.isSimpleMode() {
accountResponse.GroupIDs = filterSimpleModeGroupIDs(accountResponse.GroupIDs, simpleModeCompositeServiceGroupIDs(acc))
}
}
item := AccountWithConcurrency{
Account: accountResponse,
simpleMode: h.isSimpleMode(),
CurrentConcurrency: concurrencyCounts[acc.ID],
SchedulerScore: schedulerScores[acc.ID],
SchedulerScores: schedulerGroupScores[acc.ID],
@@ -1429,6 +1545,7 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
if warning == "missing_project_id_temporary" {
response.Success(c, gin.H{
"account": h.buildAccountResponseWithRuntime(c.Request.Context(), updatedAccount),
"message": "Token refreshed successfully, but project_id could not be retrieved (will retry automatically)",
"warning": "missing_project_id_temporary",
})
@@ -1942,6 +2059,14 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
return
}
}
groupIDs := make([]int64, 0)
for _, item := range req.Accounts {
groupIDs = append(groupIDs, item.GroupIDs...)
}
if err := h.adminService.ValidateAccountGroupBindings(c.Request.Context(), groupIDs); err != nil {
response.ErrorFrom(c, err)
return
}
executeAdminIdempotentJSON(c, "admin.accounts.batch_create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
success := 0
@@ -2656,6 +2781,14 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
// Handle OpenAI accounts
if account.IsOpenAI() {
// Prefer the shared, account-keyed upstream catalog. If discovery fails,
// retain the legacy local catalog below so the test dialog remains usable.
if h.accountTestService != nil {
if models, fetchErr := h.accountTestService.FetchOpenAIAccountModels(c.Request.Context(), account); fetchErr == nil {
response.Success(c, models)
return
}
}
// OpenAI 自动透传会绕过常规模型改写,测试/模型列表也应回落到默认模型集。
if account.IsOpenAIPassthroughEnabled() {
response.Success(c, openai.DefaultModels)
@@ -0,0 +1,240 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type simpleModeAccountService struct {
*stubAdminService
account service.Account
createCalls int
updateCalls int
bulkCalls int
}
func (s *simpleModeAccountService) GetAccount(context.Context, int64) (*service.Account, error) {
return &s.account, nil
}
func (s *simpleModeAccountService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) {
if err := s.ValidateAccountGroupBindings(ctx, input.GroupIDs); err != nil {
return nil, err
}
s.createCalls++
return &s.account, nil
}
func (s *simpleModeAccountService) UpdateAccount(ctx context.Context, _ int64, input *service.UpdateAccountInput) (*service.Account, error) {
if input.GroupIDs != nil {
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
return nil, err
}
}
s.updateCalls++
return &s.account, nil
}
func (s *simpleModeAccountService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) {
if input.GroupIDs != nil {
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
return nil, err
}
}
s.bulkCalls++
return &service.BulkUpdateAccountsResult{}, nil
}
func (s *simpleModeAccountService) ValidateAccountGroupBindings(_ context.Context, groupIDs []int64) error {
for _, id := range groupIDs {
for i := range s.groups {
if s.groups[i].ID == id && s.groups[i].Platform == service.PlatformComposite {
return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode")
}
}
}
return nil
}
func TestAccountHandlerSimpleModeUsesMinimalGroupReferences(t *testing.T) {
gin.SetMode(gin.TestMode)
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9, IsExclusive: true, SubscriptionType: service.SubscriptionTypeSubscription, RPMLimit: 99}
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
account := service.Account{
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
GroupIDs: []int64{7, 9},
Groups: []*service.Group{richGroup, historicalComposite},
AccountGroups: []service.AccountGroup{
{AccountID: 3, GroupID: 7, Priority: 2, Group: richGroup, Account: &service.Account{ID: 3, Extra: map[string]any{"secret": true}}},
{AccountID: 3, GroupID: 9, Priority: 3, Group: historicalComposite},
},
}
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
svc.accounts = []service.Account{account}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.GET("/accounts", h.List)
r.GET("/accounts/:id", h.GetByID)
r.POST("/accounts", h.Create)
r.PUT("/accounts/:id", h.Update)
tests := []struct {
method string
path string
body string
}{
{http.MethodGet, "/accounts", ""},
{http.MethodGet, "/accounts/3", ""},
{http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"}}`},
{http.MethodPut, "/accounts/3", `{"name":"account"}`},
}
for _, tt := range tests {
t.Run(tt.method+tt.path, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
var payload map[string]any
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
data, ok := payload["data"].(map[string]any)
require.True(t, ok)
if items, ok := data["items"].([]any); ok {
require.Len(t, items, 1)
data, ok = items[0].(map[string]any)
require.True(t, ok)
}
groups, ok := data["groups"].([]any)
require.True(t, ok)
require.Len(t, groups, 1)
require.Equal(t, []any{float64(7)}, data["group_ids"])
group, ok := groups[0].(map[string]any)
require.True(t, ok)
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(group))
accountGroups, ok := data["account_groups"].([]any)
require.True(t, ok)
require.Len(t, accountGroups, 1)
accountGroup, ok := accountGroups[0].(map[string]any)
require.True(t, ok)
require.ElementsMatch(t, []string{"account_id", "group_id", "priority", "created_at", "group"}, mapKeys(accountGroup))
nestedGroup, ok := accountGroup["group"].(map[string]any)
require.True(t, ok)
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(nestedGroup))
})
}
}
func TestAccountHandlerSimpleModeLitePreservesCompactShapeAndETag(t *testing.T) {
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9}
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
account := service.Account{
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
GroupIDs: []int64{7, 9}, Groups: []*service.Group{richGroup, historicalComposite},
AccountGroups: []service.AccountGroup{{AccountID: 3, GroupID: 7, Group: richGroup}, {AccountID: 3, GroupID: 9, Group: historicalComposite}},
}
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
svc.accounts = []service.Account{account}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.GET("/accounts", h.List)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil))
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
require.NotEmpty(t, res.Header().Get("ETag"))
var payload struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
require.Len(t, payload.Data.Items, 1)
require.Equal(t, []any{float64(7)}, payload.Data.Items[0]["group_ids"])
require.NotContains(t, payload.Data.Items[0], "groups")
require.NotContains(t, payload.Data.Items[0], "account_groups")
notModified := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil)
req.Header.Set("If-None-Match", res.Header().Get("ETag"))
r.ServeHTTP(notModified, req)
require.Equal(t, http.StatusNotModified, notModified.Code)
}
func TestAccountHandlerSimpleModeRejectsCompositeGroupBindingsBeforeWrites(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tt := range []struct{ name, method, path, body string }{
{"create", http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[9]}`},
{"update", http.MethodPut, "/accounts/3", `{"group_ids":[9]}`},
{"bulk", http.MethodPost, "/accounts/bulk-update", `{"account_ids":[3],"group_ids":[9]}`},
} {
t.Run(tt.name, func(t *testing.T) {
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.POST("/accounts", h.Create)
r.PUT("/accounts/:id", h.Update)
r.POST("/accounts/bulk-update", h.BulkUpdate)
res := httptest.NewRecorder()
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
require.Zero(t, svc.createCalls)
require.Zero(t, svc.updateCalls)
require.Zero(t, svc.bulkCalls)
})
}
}
func TestAccountHandlerSimpleModePreservesBasicGroupBinding(t *testing.T) {
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: service.Account{ID: 3}}
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.POST("/accounts", h.Create)
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString(`{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
require.Equal(t, 1, svc.createCalls)
}
func TestAccountHandlerSimpleModeBatchPrevalidatesAllGroupsAtomically(t *testing.T) {
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.POST("/accounts/batch", h.BatchCreate)
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/accounts/batch", bytes.NewBufferString(`{"accounts":[{"name":"first","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]},{"name":"second","platform":"anthropic","type":"apikey","credentials":{"key":"y"},"group_ids":[9]}]}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
require.Zero(t, svc.createCalls)
}
func TestAccountHandlerAdvancedModeKeepsFullGroupReferences(t *testing.T) {
group := &service.Group{ID: 7, Name: "advanced", RateMultiplier: 9, SubscriptionType: service.SubscriptionTypeSubscription}
h := NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
raw, err := json.Marshal(h.buildAccountResponseWithRuntime(context.Background(), &service.Account{Groups: []*service.Group{group}}))
require.NoError(t, err)
require.Contains(t, string(raw), `"rate_multiplier":9`)
require.Contains(t, string(raw), `"subscription_type":"subscription"`)
}
@@ -33,7 +33,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
router.GET("/api/v1/admin/groups", groupHandler.List)
router.GET("/api/v1/admin/groups/all", groupHandler.GetAll)
router.GET("/api/v1/admin/groups/:id/models-list-candidates", groupHandler.GetModelsListCandidates)
router.GET("/api/v1/admin/groups/:id/model-allowlist-candidates", groupHandler.GetGroupModelAllowlistCandidates)
router.GET("/api/v1/admin/groups/:id/composite-routes", groupHandler.ListCompositeRoutes)
router.POST("/api/v1/admin/groups/:id/composite-routes", groupHandler.CreateCompositeRoute)
router.POST("/api/v1/admin/groups/:id/composite-routes/preview", groupHandler.PreviewCompositeRoute)
@@ -184,7 +184,7 @@ func TestGroupHandlerEndpoints(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/models-list-candidates?platform=openai", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/model-allowlist-candidates?platform=openai", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), "gpt-5.5")
@@ -24,6 +24,13 @@ type stubAdminService struct {
boundAuthIdentity *service.AdminBindAuthIdentityInput
boundAuthIdentityFor int64
createdAccounts []*service.CreateAccountInput
createdGroups []*service.CreateGroupInput
updatedGroups []*service.UpdateGroupInput
deletedGroupIDs []int64
guardedDeletedGroupIDs []int64
deleteGroupIfEmptyErr error
advancedGroupOperationCalls int
lastListGroupsIsExclusive *bool
createdProxies []*service.CreateProxyInput
updatedProxyIDs []int64
updatedProxies []*service.UpdateProxyInput
@@ -267,6 +274,7 @@ func (s *stubAdminService) BindUserAuthIdentity(ctx context.Context, userID int6
}
func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]service.Group, int64, error) {
s.lastListGroupsIsExclusive = isExclusive
return s.groups, int64(len(s.groups)), nil
}
@@ -295,6 +303,7 @@ func (s *stubAdminService) GetGroupModelsListCandidates(ctx context.Context, id
}
func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int64) ([]service.CompositeModelRoute, error) {
s.advancedGroupOperationCalls++
return []service.CompositeModelRoute{
{
ID: 1,
@@ -311,6 +320,7 @@ func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int6
}
func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
s.advancedGroupOperationCalls++
return &service.CompositeModelRoute{
ID: 1,
GroupID: groupID,
@@ -326,6 +336,7 @@ func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int
}
func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
s.advancedGroupOperationCalls++
return &service.CompositeModelRoute{
ID: routeID,
GroupID: groupID,
@@ -341,10 +352,12 @@ func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, ro
}
func (s *stubAdminService) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error {
s.advancedGroupOperationCalls++
return nil
}
func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRoutePreviewRequest) (*service.CompositeRouteDecision, error) {
s.advancedGroupOperationCalls++
decision, err := service.NewCompositeRouteResolver(nil).Resolve(ctx, groupID, input.Model, input.Endpoint)
if err != nil {
return nil, err
@@ -353,11 +366,13 @@ func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID in
}
func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.CreateGroupInput) (*service.Group, error) {
s.createdGroups = append(s.createdGroups, input)
group := service.Group{ID: 200, Name: input.Name, Status: service.StatusActive}
return &group, nil
}
func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
s.advancedGroupOperationCalls++
group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"}
return &group, nil
}
@@ -367,14 +382,21 @@ func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64,
}
func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) {
s.updatedGroups = append(s.updatedGroups, input)
group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive}
return &group, nil
}
func (s *stubAdminService) DeleteGroup(ctx context.Context, id int64) error {
s.deletedGroupIDs = append(s.deletedGroupIDs, id)
return nil
}
func (s *stubAdminService) DeleteGroupIfEmpty(ctx context.Context, id int64) error {
s.guardedDeletedGroupIDs = append(s.guardedDeletedGroupIDs, id)
return s.deleteGroupIfEmptyErr
}
func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]service.APIKey, int64, error) {
return s.apiKeys, int64(len(s.apiKeys)), nil
}
@@ -384,10 +406,12 @@ func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) (
}
func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) error {
s.advancedGroupOperationCalls++
return nil
}
func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error {
s.advancedGroupOperationCalls++
return nil
}
@@ -396,6 +420,7 @@ func (s *stubAdminService) ClearGroupRPMOverrides(_ context.Context, _ int64) er
}
func (s *stubAdminService) BatchSetGroupRPMOverrides(_ context.Context, _ int64, _ []service.GroupRPMOverrideInput) error {
s.advancedGroupOperationCalls++
return nil
}
@@ -491,6 +516,8 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre
return &account, nil
}
func (s *stubAdminService) ValidateAccountGroupBindings(context.Context, []int64) error { return nil }
func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) {
account := service.Account{ID: 301, Name: "account (Copy)", Status: service.StatusActive, Schedulable: false}
return &account, nil
@@ -643,6 +643,8 @@ var platformToLiteLLMProvider = map[string]string{
service.PlatformKimi: "moonshot",
service.PlatformZhipu: "zhipu",
service.PlatformDeepseek: "deepseek",
service.PlatformMiniMax: "minimax",
service.PlatformOpenCodeGo: "opencode-go",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -549,7 +549,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
svc := service.NewPricingService(nil, nil)
router := setupSyncPricingModelsRouter(svc)
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek"} {
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek", "minimax"} {
req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -40,7 +40,7 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
type channelMonitorCreateRequest struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax opencode_go"`
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
@@ -64,7 +64,7 @@ type channelMonitorCreateRequest struct {
type channelMonitorUpdateRequest struct {
Name *string `json:"name" binding:"omitempty,max=100"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax opencode_go"`
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
@@ -6,6 +6,7 @@ import (
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
)
@@ -194,6 +195,7 @@ func (h *GrokOAuthHandler) scheduleGrokImportProbe(account *service.Account) {
// ProvideAccountHandler injects the Grok active prober for production while
// keeping NewAccountHandler convenient for focused unit tests.
func ProvideAccountHandler(
cfg *config.Config,
adminService service.AdminService,
oauthService *service.OAuthService,
openaiOAuthService *service.OpenAIOAuthService,
@@ -227,5 +229,6 @@ func ProvideAccountHandler(
tokenCacheInvalidator,
)
handler.grokImportProber = grokQuotaService
handler.cfg = cfg
return handler
}
@@ -0,0 +1,144 @@
package admin
import (
"strconv"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
const (
grokMediaEligibilityModeAuto = "auto"
grokMediaEligibilityModeEnabled = "enabled"
grokMediaEligibilityModeDisabled = "disabled"
)
type grokMediaEligibilityResponse struct {
AccountID int64 `json:"account_id"`
Mode string `json:"mode"`
Eligible bool `json:"eligible"`
Reason string `json:"reason"`
}
type updateGrokMediaEligibilityRequest struct {
Mode string `json:"mode" binding:"required"`
}
// GetGrokMediaEligibility returns the current manual override and evaluated
// media-routing decision for a Grok OAuth account.
// GET /api/v1/admin/accounts/:id/grok-media-eligibility
func (h *AccountHandler) GetGrokMediaEligibility(c *gin.Context) {
account, err := h.getGrokOAuthAccount(c)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, buildGrokMediaEligibilityResponse(account))
}
// UpdateGrokMediaEligibility updates only the manual media eligibility
// override. It deliberately uses UpdateAccountExtra so unrelated account
// extra fields are preserved.
// PUT /api/v1/admin/accounts/:id/grok-media-eligibility
func (h *AccountHandler) UpdateGrokMediaEligibility(c *gin.Context) {
account, err := h.getGrokOAuthAccount(c)
if err != nil {
response.ErrorFrom(c, err)
return
}
var req updateGrokMediaEligibilityRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
var value any
switch req.Mode {
case grokMediaEligibilityModeAuto:
value = nil
case grokMediaEligibilityModeEnabled:
value = true
case grokMediaEligibilityModeDisabled:
value = false
default:
response.ErrorFrom(c, infraerrors.BadRequest(
"GROK_MEDIA_ELIGIBILITY_INVALID_MODE",
"mode must be one of auto, enabled, disabled",
))
return
}
updates := map[string]any{service.GrokMediaEligibleExtraKey: value}
if err := service.ValidateGrokMediaEligibilityExtra(account.Platform, updates); err != nil {
response.ErrorFrom(c, err)
return
}
if err := h.adminService.UpdateAccountExtra(c.Request.Context(), account.ID, updates); err != nil {
response.ErrorFrom(c, err)
return
}
updated, err := h.adminService.GetAccount(c.Request.Context(), account.ID)
if err != nil {
response.ErrorFrom(c, err)
return
}
if err := ensureGrokOAuthAccount(updated); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, buildGrokMediaEligibilityResponse(updated))
}
func (h *AccountHandler) getGrokOAuthAccount(c *gin.Context) (*service.Account, error) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return nil, infraerrors.BadRequest("INVALID_ACCOUNT_ID", "Invalid account ID")
}
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
if err != nil {
return nil, err
}
if err := ensureGrokOAuthAccount(account); err != nil {
return nil, err
}
return account, nil
}
func ensureGrokOAuthAccount(account *service.Account) error {
if account == nil {
return infraerrors.NotFound("ACCOUNT_NOT_FOUND", "account not found")
}
if account.Platform != service.PlatformGrok || account.Type != service.AccountTypeOAuth {
return infraerrors.BadRequest(
"GROK_MEDIA_ELIGIBILITY_UNSUPPORTED_ACCOUNT",
"media eligibility is only supported for Grok OAuth accounts",
)
}
return nil
}
func buildGrokMediaEligibilityResponse(account *service.Account) grokMediaEligibilityResponse {
eligible, reason := account.GrokMediaGenerationEligibility()
return grokMediaEligibilityResponse{
AccountID: account.ID,
Mode: grokMediaEligibilityModeFromExtra(account.Extra),
Eligible: eligible,
Reason: reason,
}
}
func grokMediaEligibilityModeFromExtra(extra map[string]any) string {
if extra != nil {
if enabled, ok := extra[service.GrokMediaEligibleExtraKey].(bool); ok {
if enabled {
return grokMediaEligibilityModeEnabled
}
return grokMediaEligibilityModeDisabled
}
}
return grokMediaEligibilityModeAuto
}
@@ -0,0 +1,146 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type grokMediaEligibilityAdminServiceStub struct {
*stubAdminService
account *service.Account
}
func (s *grokMediaEligibilityAdminServiceStub) GetAccount(_ context.Context, _ int64) (*service.Account, error) {
return s.account, nil
}
func (s *grokMediaEligibilityAdminServiceStub) UpdateAccountExtra(_ context.Context, _ int64, updates map[string]any) error {
if s.account.Extra == nil {
s.account.Extra = map[string]any{}
}
for key, value := range updates {
if value == nil {
delete(s.account.Extra, key)
} else {
s.account.Extra[key] = value
}
}
return nil
}
func newGrokMediaEligibilityRouter(account *service.Account) *gin.Engine {
stub := &grokMediaEligibilityAdminServiceStub{
stubAdminService: newStubAdminService(),
account: account,
}
h := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.GET("/accounts/:id/grok-media-eligibility", h.GetGrokMediaEligibility)
router.PUT("/accounts/:id/grok-media-eligibility", h.UpdateGrokMediaEligibility)
return router
}
func TestGrokMediaEligibilityHandlerGetReturnsModeAndReason(t *testing.T) {
gin.SetMode(gin.TestMode)
router := newGrokMediaEligibilityRouter(&service.Account{
ID: 12,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Extra: map[string]any{
"grok_billing_snapshot": map[string]any{"status_code": 200, "partial": true},
"unrelated_setting": "preserve",
},
})
req := httptest.NewRequest(http.MethodGet, "/accounts/12/grok-media-eligibility", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var envelope struct {
Data grokMediaEligibilityResponse `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
require.Equal(t, int64(12), envelope.Data.AccountID)
require.Equal(t, grokMediaEligibilityModeAuto, envelope.Data.Mode)
require.Equal(t, "billing_inconclusive", envelope.Data.Reason)
}
func TestGrokMediaEligibilityHandlerUpdateModesPreservesExtra(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &service.Account{
ID: 12,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Extra: map[string]any{"unrelated_setting": "preserve"},
}
router := newGrokMediaEligibilityRouter(account)
tests := []struct {
mode string
wantMode string
wantExtra any
wantEligible bool
wantReason string
}{
{mode: "enabled", wantMode: grokMediaEligibilityModeEnabled, wantExtra: true, wantEligible: true, wantReason: "override_enabled"},
{mode: "disabled", wantMode: grokMediaEligibilityModeDisabled, wantExtra: false, wantEligible: false, wantReason: "override_disabled"},
{mode: "auto", wantMode: grokMediaEligibilityModeAuto, wantExtra: nil, wantEligible: false, wantReason: "billing_unobserved"},
}
for _, tt := range tests {
t.Run(tt.mode, func(t *testing.T) {
body, err := json.Marshal(map[string]string{"mode": tt.mode})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPut, "/accounts/12/grok-media-eligibility", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var envelope struct {
Data grokMediaEligibilityResponse `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
require.Equal(t, tt.wantMode, envelope.Data.Mode)
require.Equal(t, tt.wantEligible, envelope.Data.Eligible)
require.Equal(t, tt.wantReason, envelope.Data.Reason)
require.Equal(t, "preserve", account.Extra["unrelated_setting"])
if tt.wantExtra == nil {
_, exists := account.Extra[service.GrokMediaEligibleExtraKey]
require.False(t, exists)
} else {
require.Equal(t, tt.wantExtra, account.Extra[service.GrokMediaEligibleExtraKey])
}
})
}
}
func TestGrokMediaEligibilityHandlerRejectsUnsupportedAndInvalidModes(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tt := range []struct {
name string
account *service.Account
body string
}{
{name: "unsupported account", account: &service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, body: `{"mode":"enabled"}`},
{name: "invalid mode", account: &service.Account{ID: 1, Platform: service.PlatformGrok, Type: service.AccountTypeOAuth}, body: `{"mode":"force"}`},
} {
t.Run(tt.name, func(t *testing.T) {
router := newGrokMediaEligibilityRouter(tt.account)
req := httptest.NewRequest(http.MethodPut, "/accounts/1/grok-media-eligibility", bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
})
}
}
+213 -12
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
@@ -24,10 +25,14 @@ type GroupHandler struct {
adminService service.AdminService
dashboardService *service.DashboardService
groupCapacityService *service.GroupCapacityService
cfg *config.Config
}
// GetLiveCapability 返回当前服务端是否具备生成 Live attestation 的运行环境。
func (h *GroupHandler) GetLiveCapability(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "live_capability") {
return
}
err := liveattestation.NewProvider().Check(c.Request.Context())
result := gin.H{"supported": err == nil}
if err != nil {
@@ -87,18 +92,99 @@ func (f optionalLimitField) ToServiceInput() *float64 {
// NewGroupHandler creates a new admin group handler
func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler {
return NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, nil)
}
func NewGroupHandlerWithConfig(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService, cfg *config.Config) *GroupHandler {
return &GroupHandler{
adminService: adminService,
dashboardService: dashboardService,
groupCapacityService: groupCapacityService,
cfg: cfg,
}
}
func (h *GroupHandler) isSimpleMode() bool {
return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
}
type simpleModeGroupOperation string
const (
simpleModeGroupList simpleModeGroupOperation = "list"
simpleModeGroupGetAll simpleModeGroupOperation = "get_all"
simpleModeGroupGet simpleModeGroupOperation = "get"
simpleModeGroupCreate simpleModeGroupOperation = "create"
simpleModeGroupUpdate simpleModeGroupOperation = "update"
simpleModeGroupDelete simpleModeGroupOperation = "delete"
)
var simpleModeGroupOperations = map[simpleModeGroupOperation]struct{}{
simpleModeGroupList: {}, simpleModeGroupGetAll: {}, simpleModeGroupGet: {},
simpleModeGroupCreate: {}, simpleModeGroupUpdate: {}, simpleModeGroupDelete: {},
}
func (h *GroupHandler) rejectUnsupportedSimpleModeOperation(c *gin.Context, operation simpleModeGroupOperation) bool {
if _, allowed := simpleModeGroupOperations[operation]; allowed {
return false
}
if err := service.ValidateSimpleModeGroupOperation(h.cfg, service.AdminGroupOperation(operation)); err != nil {
response.ErrorFrom(c, err)
return true
}
return false
}
type simpleModeGroupResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Platform string `json:"platform"`
Status string `json:"status"`
AccountCount int64 `json:"account_count,omitempty"`
ActiveAccountCount int64 `json:"active_account_count,omitempty"`
RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse {
if group == nil {
return nil
}
return &simpleModeGroupResponse{
ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform,
Status: group.Status,
AccountCount: group.AccountCount,
ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount,
SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt,
}
}
func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) {
if req == nil {
return
}
allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform}
allowed.RateMultiplier = 1
allowed.SubscriptionType = service.SubscriptionTypeStandard
*req = allowed
}
func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
if req == nil {
return
}
*req = UpdateGroupRequest{Name: req.Name, Description: req.Description}
}
// CreateGroupRequest represents create group request
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -153,7 +239,7 @@ type CreateGroupRequest struct {
RequirePrivacySet bool `json:"require_privacy_set"`
DefaultMappedModel string `json:"default_mapped_model"`
MessagesDispatchModelConfig service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
ModelsListConfig service.GroupModelsListConfig `json:"models_list_config"`
ModelAllowlist service.GroupModelAllowlist `json:"model_allowlist"`
// 固定账号 manifest 配置;创建路径禁止开启,仅编辑可配置。
CodexModelsManifestConfig service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
// 分组 RPM 上限(0 = 不限制)
@@ -172,7 +258,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -228,7 +314,7 @@ type UpdateGroupRequest struct {
RequirePrivacySet *bool `json:"require_privacy_set"`
DefaultMappedModel *string `json:"default_mapped_model"`
MessagesDispatchModelConfig *service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
ModelsListConfig *service.GroupModelsListConfig `json:"models_list_config"`
ModelAllowlist *service.GroupModelAllowlist `json:"model_allowlist"`
// 固定账号 manifest 配置;nil 表示不修改。
CodexModelsManifestConfig *service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
// 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动
@@ -246,7 +332,7 @@ type UpdateGroupRequest struct {
type CompositeRouteRequest struct {
PublicModel string `json:"public_model" binding:"required"`
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
Priority int `json:"priority"`
@@ -262,6 +348,9 @@ type CompositeRoutePreviewRequest struct {
// List handles listing all groups with pagination
// GET /api/v1/admin/groups
func (h *GroupHandler) List(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupList) {
return
}
page, pageSize := response.ParsePagination(c)
platform := c.Query("platform")
status := c.Query("status")
@@ -276,7 +365,7 @@ func (h *GroupHandler) List(c *gin.Context) {
sortOrder := c.DefaultQuery("sort_order", "asc")
var isExclusive *bool
if isExclusiveStr != "" {
if !h.isSimpleMode() && isExclusiveStr != "" {
val := isExclusiveStr == "true"
isExclusive = &val
}
@@ -287,6 +376,16 @@ func (h *GroupHandler) List(c *gin.Context) {
return
}
if h.isSimpleMode() {
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
for i := range groups {
if service.IsGroupBindableInSimpleMode(&groups[i]) {
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
}
}
response.Paginated(c, simpleGroups, total, page, pageSize)
return
}
outGroups := make([]dto.AdminGroup, 0, len(groups))
for i := range groups {
outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
@@ -297,6 +396,9 @@ func (h *GroupHandler) List(c *gin.Context) {
// ListCompositeRoutes handles listing composite model routes for one group.
// GET /api/v1/admin/groups/:id/composite-routes
func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -312,6 +414,9 @@ func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
// CreateCompositeRoute handles creating one composite model route.
// POST /api/v1/admin/groups/:id/composite-routes
func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -332,6 +437,9 @@ func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
// UpdateCompositeRoute handles replacing one composite model route.
// PUT /api/v1/admin/groups/:id/composite-routes/:route_id
func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -356,6 +464,9 @@ func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
// DeleteCompositeRoute handles deleting one composite model route.
// DELETE /api/v1/admin/groups/:id/composite-routes/:route_id
func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -374,6 +485,9 @@ func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
// PreviewCompositeRoute resolves a model without mutating routes.
// POST /api/v1/admin/groups/:id/composite-routes/preview
func (h *GroupHandler) PreviewCompositeRoute(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -427,6 +541,9 @@ func parsePositiveIDParam(c *gin.Context, name string) (int64, bool) {
// bound to them even after the group is disabled).
// GET /api/v1/admin/groups/all
func (h *GroupHandler) GetAll(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGetAll) {
return
}
platform := c.Query("platform")
includeInactive := c.Query("include_inactive") == "true"
@@ -446,6 +563,16 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
return
}
if h.isSimpleMode() {
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
for i := range groups {
if service.IsGroupBindableInSimpleMode(&groups[i]) {
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
}
}
response.Success(c, simpleGroups)
return
}
outGroups := make([]dto.AdminGroup, 0, len(groups))
for i := range groups {
outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
@@ -456,6 +583,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
// GetByID handles getting a group by ID
// GET /api/v1/admin/groups/:id
func (h *GroupHandler) GetByID(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGet) {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -468,12 +598,19 @@ func (h *GroupHandler) GetByID(c *gin.Context) {
return
}
if h.isSimpleMode() {
response.Success(c, groupForSimpleMode(group))
return
}
response.Success(c, dto.GroupFromServiceAdmin(group))
}
// GetModelsListCandidates handles getting candidate model IDs for custom /v1/models list.
// GET /api/v1/admin/groups/:id/models-list-candidates
func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) {
// GetGroupModelAllowlistCandidates handles getting candidate model IDs for the group model allowlist.
// GET /api/v1/admin/groups/:id/model-allowlist-candidates
func (h *GroupHandler) GetGroupModelAllowlistCandidates(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || groupID < 0 {
response.BadRequest(c, "Invalid group ID")
@@ -496,11 +633,21 @@ func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) {
// Create handles creating a new group
// POST /api/v1/admin/groups
func (h *GroupHandler) Create(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupCreate) {
return
}
var req CreateGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if h.isSimpleMode() && req.Platform == service.PlatformComposite {
response.BadRequest(c, "Platform is not supported in simple mode")
return
}
if h.isSimpleMode() {
sanitizeCreateGroupRequestForSimpleMode(&req)
}
if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, float64ValueOrDefault(req.PeakRateMultiplier, 1.0)); err != nil {
response.BadRequest(c, err.Error())
@@ -568,7 +715,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
RequirePrivacySet: req.RequirePrivacySet,
DefaultMappedModel: req.DefaultMappedModel,
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
ModelsListConfig: req.ModelsListConfig,
ModelAllowlist: req.ModelAllowlist,
CodexModelsManifestConfig: req.CodexModelsManifestConfig,
RPMLimit: req.RPMLimit,
MaxReasoningEffort: req.MaxReasoningEffort,
@@ -581,12 +728,19 @@ func (h *GroupHandler) Create(c *gin.Context) {
return
}
if h.isSimpleMode() {
response.Success(c, groupForSimpleMode(group))
return
}
response.Success(c, dto.GroupFromServiceAdmin(group))
}
// Duplicate handles creating an inactive group copy with the source account bindings.
// POST /api/v1/admin/groups/:id/duplicate
func (h *GroupHandler) Duplicate(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || groupID <= 0 {
response.BadRequest(c, "Invalid group ID")
@@ -634,6 +788,9 @@ func (h *GroupHandler) Duplicate(c *gin.Context) {
// Update handles updating a group
// PUT /api/v1/admin/groups/:id
func (h *GroupHandler) Update(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupUpdate) {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -645,6 +802,9 @@ func (h *GroupHandler) Update(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if h.isSimpleMode() {
sanitizeUpdateGroupRequestForSimpleMode(&req)
}
group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{
Name: req.Name,
@@ -701,7 +861,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
RequirePrivacySet: req.RequirePrivacySet,
DefaultMappedModel: req.DefaultMappedModel,
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
ModelsListConfig: req.ModelsListConfig,
ModelAllowlist: req.ModelAllowlist,
CodexModelsManifestConfig: req.CodexModelsManifestConfig,
RPMLimit: req.RPMLimit,
MaxReasoningEffort: req.MaxReasoningEffort,
@@ -714,19 +874,30 @@ func (h *GroupHandler) Update(c *gin.Context) {
return
}
if h.isSimpleMode() {
response.Success(c, groupForSimpleMode(group))
return
}
response.Success(c, dto.GroupFromServiceAdmin(group))
}
// Delete handles deleting a group
// DELETE /api/v1/admin/groups/:id
func (h *GroupHandler) Delete(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupDelete) {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
return
}
err = h.adminService.DeleteGroup(c.Request.Context(), groupID)
if h.isSimpleMode() {
err = h.adminService.DeleteGroupIfEmpty(c.Request.Context(), groupID)
} else {
err = h.adminService.DeleteGroup(c.Request.Context(), groupID)
}
if err != nil {
response.ErrorFrom(c, err)
return
@@ -738,6 +909,9 @@ func (h *GroupHandler) Delete(c *gin.Context) {
// GetStats handles getting group statistics
// GET /api/v1/admin/groups/:id/stats
func (h *GroupHandler) GetStats(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "stats") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -757,6 +931,9 @@ func (h *GroupHandler) GetStats(c *gin.Context) {
// GetUsageSummary returns today's, yesterday's, and cumulative cost for all groups.
// GET /api/v1/admin/groups/usage-summary
func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
todayStart := service.GroupUsageTodayStart(time.Now())
results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart)
@@ -771,6 +948,9 @@ func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
// GetCapacitySummary returns aggregated capacity (concurrency/sessions/RPM) for all active groups.
// GET /api/v1/admin/groups/capacity-summary
func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
results, err := h.groupCapacityService.GetAllGroupCapacity(c.Request.Context())
if err != nil {
response.Error(c, 500, "Failed to get group capacity summary")
@@ -782,6 +962,9 @@ func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
// GetGroupAPIKeys handles getting API keys in a group
// GET /api/v1/admin/groups/:id/api-keys
func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "api_keys") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -806,6 +989,9 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
// GetGroupRateMultipliers handles getting rate multipliers for users in a group
// GET /api/v1/admin/groups/:id/rate-multipliers
func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -827,6 +1013,9 @@ func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
// ClearGroupRateMultipliers handles clearing all rate multipliers for a group
// DELETE /api/v1/admin/groups/:id/rate-multipliers
func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -849,6 +1038,9 @@ type BatchSetGroupRateMultipliersRequest struct {
// BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group
// PUT /api/v1/admin/groups/:id/rate-multipliers
func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -877,6 +1069,9 @@ type BatchSetGroupRPMOverridesRequest struct {
// BatchSetGroupRPMOverrides handles batch setting rpm_override for users in a group
// PUT /api/v1/admin/groups/:id/rpm-overrides
func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -900,6 +1095,9 @@ func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) {
// ClearGroupRPMOverrides handles clearing all rpm_override for a group
// DELETE /api/v1/admin/groups/:id/rpm-overrides
func (h *GroupHandler) ClearGroupRPMOverrides(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -925,6 +1123,9 @@ type UpdateSortOrderRequest struct {
// UpdateSortOrder handles updating group sort orders
// PUT /api/v1/admin/groups/sort-order
func (h *GroupHandler) UpdateSortOrder(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
var req UpdateSortOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
@@ -12,9 +12,9 @@ import (
"github.com/stretchr/testify/require"
)
// 回归分组平台枚举:kimi/zhipu/deepseek 必须能通过 Create/Update 的 binding 校验
// (历史 bug:调度/路由链路已支持 CN 平台分组,但 oneof 白名单漏加三平台,导致
// 平台分组无法创建、CN 账号"无可用分组");非法值仍须被拒。
// 回归分组平台枚举:kimi/zhipu/deepseek/opencode_go 必须能通过 Create/Update 的
// binding 校验(历史 bug:调度/路由链路已支持这些平台,但 oneof 白名单漏加,
// 导致平台分组无法创建、账号"无可用分组");非法值仍须被拒。
func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
t.Helper()
gin.SetMode(gin.TestMode)
@@ -27,7 +27,7 @@ func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
allowed := []string{
"anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek", "composite",
"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "composite",
}
for _, platform := range allowed {
t.Run("create_"+platform, func(t *testing.T) {
@@ -72,10 +72,15 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
}
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax", "opencode_go"} {
var req CompositeRouteRequest
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
require.Equal(t, platform, req.TargetPlatform)
}
}
func TestCompositeRouteTargetPlatform_RejectsComposite(t *testing.T) {
var req CompositeRouteRequest
require.Error(t, bindGroupPlatformJSON(t, &req, `{"public_model":"m","target_platform":"composite"}`))
}
@@ -0,0 +1,309 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func newSimpleModeGroupRouter(svc *stubAdminService) *gin.Engine {
h := NewGroupHandlerWithConfig(svc, nil, nil, &config.Config{RunMode: config.RunModeSimple})
r := gin.New()
r.GET("/groups", h.List)
r.GET("/groups/all", h.GetAll)
r.GET("/groups/live-capability", h.GetLiveCapability)
r.GET("/groups/usage-summary", h.GetUsageSummary)
r.GET("/groups/capacity-summary", h.GetCapacitySummary)
r.PUT("/groups/sort-order", h.UpdateSortOrder)
r.GET("/groups/:id", h.GetByID)
r.GET("/groups/:id/stats", h.GetStats)
r.GET("/groups/:id/api-keys", h.GetGroupAPIKeys)
r.GET("/groups/:id/model-allowlist-candidates", h.GetGroupModelAllowlistCandidates)
r.POST("/groups", h.Create)
r.PUT("/groups/:id", h.Update)
r.POST("/groups/:id/duplicate", h.Duplicate)
r.GET("/groups/:id/composite-routes", h.ListCompositeRoutes)
r.POST("/groups/:id/composite-routes", h.CreateCompositeRoute)
r.POST("/groups/:id/composite-routes/preview", h.PreviewCompositeRoute)
r.PUT("/groups/:id/composite-routes/:route_id", h.UpdateCompositeRoute)
r.DELETE("/groups/:id/composite-routes/:route_id", h.DeleteCompositeRoute)
r.GET("/groups/:id/rate-multipliers", h.GetGroupRateMultipliers)
r.PUT("/groups/:id/rate-multipliers", h.BatchSetGroupRateMultipliers)
r.DELETE("/groups/:id/rate-multipliers", h.ClearGroupRateMultipliers)
r.PUT("/groups/:id/rpm-overrides", h.BatchSetGroupRPMOverrides)
r.DELETE("/groups/:id/rpm-overrides", h.ClearGroupRPMOverrides)
r.DELETE("/groups/:id", h.Delete)
return r
}
func TestGroupHandlerSimpleModeSanitizesCommercialFields(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
create := `{"name":"simple","description":"basic grouping","platform":"anthropic","rate_multiplier":7,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":10,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"rpm_limit":99}`
req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(create))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
require.Len(t, svc.createdGroups, 1)
created := svc.createdGroups[0]
require.Equal(t, "basic grouping", created.Description)
require.Equal(t, 1.0, created.RateMultiplier)
require.False(t, created.IsExclusive)
require.Equal(t, service.SubscriptionTypeStandard, created.SubscriptionType)
require.Nil(t, created.DailyLimitUSD)
require.False(t, created.AllowImageGeneration)
require.False(t, created.LongContextPricingEnabled)
require.Empty(t, created.ModelPricing)
require.False(t, created.AllowBatchImageGeneration)
require.Nil(t, created.VideoPrice720P)
require.Nil(t, created.WebSearchPricePerCall)
require.Nil(t, created.AudioRealtimePricePerMin)
require.Zero(t, created.RPMLimit)
update := `{"name":"renamed","description":"still basic","rate_multiplier":9,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":12,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"status":"inactive","rpm_limit":123}`
req = httptest.NewRequest(http.MethodPut, "/groups/2", bytes.NewBufferString(update))
req.Header.Set("Content-Type", "application/json")
res = httptest.NewRecorder()
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
require.Len(t, svc.updatedGroups, 1)
updated := svc.updatedGroups[0]
require.NotNil(t, updated.Description)
require.Equal(t, "still basic", *updated.Description)
require.Nil(t, updated.RateMultiplier)
require.Nil(t, updated.IsExclusive)
require.Empty(t, updated.SubscriptionType)
require.Nil(t, updated.DailyLimitUSD)
require.Nil(t, updated.AllowImageGeneration)
require.Nil(t, updated.LongContextPricingEnabled)
require.Nil(t, updated.ModelPricing)
require.Nil(t, updated.AllowBatchImageGeneration)
require.Nil(t, updated.VideoPrice720P)
require.Nil(t, updated.WebSearchPricePerCall)
require.Nil(t, updated.AudioRealtimePricePerMin)
require.Empty(t, updated.Status)
require.Nil(t, updated.RPMLimit)
}
func TestGroupHandlerSimpleModeRejectsCompositeBeforeService(t *testing.T) {
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(`{"name":"composite","platform":"composite"}`))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
r.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code)
require.Empty(t, svc.createdGroups)
}
func TestGroupHandlerSimpleModeDeleteRejectsNonEmptyGroupBeforeCascade(t *testing.T) {
svc := newStubAdminService()
svc.deleteGroupIfEmptyErr = service.ErrGroupNotEmpty
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/7", nil))
require.Equal(t, http.StatusConflict, res.Code)
require.Equal(t, []int64{7}, svc.guardedDeletedGroupIDs)
require.Empty(t, svc.deletedGroupIDs)
}
func TestGroupHandlerSimpleModeDeletePermitsEmptyGroup(t *testing.T) {
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/8", nil))
require.Equal(t, http.StatusOK, res.Code)
require.Equal(t, []int64{8}, svc.guardedDeletedGroupIDs)
require.Empty(t, svc.deletedGroupIDs)
}
func TestGroupHandlerSimpleModeIgnoresExclusiveFilter(t *testing.T) {
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups?is_exclusive=true", nil))
require.Equal(t, http.StatusOK, res.Code)
require.Nil(t, svc.lastListGroupsIsExclusive)
}
func float64PtrForSimpleModeTest(value float64) *float64 { return &value }
func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newStubAdminService()
svc.groups = []service.Group{{
ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic,
AccountCount: 3, ActiveAccountCount: 2, RateLimitedAccountCount: 1,
Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
LongContextPricingEnabled: true,
ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
AllowBatchImageGeneration: true, VideoPrice720P: float64PtrForSimpleModeTest(2),
WebSearchPricePerCall: float64PtrForSimpleModeTest(3), AudioRealtimePricePerMin: float64PtrForSimpleModeTest(4),
ModelRouting: map[string][]int64{"claude": {2}},
}}
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups", nil))
require.Equal(t, http.StatusOK, res.Code)
var payload struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
require.Len(t, payload.Data.Items, 1)
item := payload.Data.Items[0]
require.Equal(t, "basic", item["name"])
require.Equal(t, "allowed", item["description"])
require.Equal(t, "anthropic", item["platform"])
require.Equal(t, float64(3), item["account_count"])
require.ElementsMatch(t, []string{
"id", "name", "description", "platform", "status", "account_count",
"active_account_count", "rate_limited_account_count", "sort_order", "created_at", "updated_at",
}, mapKeys(item))
for _, forbidden := range []string{
"rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
"allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
"audio_realtime_price_per_min", "model_routing",
} {
_, exposed := item[forbidden]
require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
}
}
func TestGroupHandlerSimpleModeListsOnlyBindableGroups(t *testing.T) {
svc := newStubAdminService()
svc.groups = []service.Group{{ID: 1, Name: "basic", Platform: service.PlatformAnthropic}, {ID: 2, Name: "composite", Platform: service.PlatformComposite}}
r := newSimpleModeGroupRouter(svc)
for _, path := range []string{"/groups", "/groups/all"} {
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, path, nil))
require.Equal(t, http.StatusOK, res.Code)
require.Contains(t, res.Body.String(), `"name":"basic"`)
require.NotContains(t, res.Body.String(), `"name":"composite"`)
}
}
func mapKeys(values map[string]any) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
return keys
}
func TestGroupHandlerSimpleModeAllReadAndWriteResponsesUseFieldAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newStubAdminService()
svc.groups = []service.Group{{
ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic, AccountCount: 3,
Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
LongContextPricingEnabled: true,
ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
}}
r := newSimpleModeGroupRouter(svc)
assertNoAdvancedFields := func(t *testing.T, item map[string]any) {
t.Helper()
for _, forbidden := range []string{
"rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
"allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
"audio_realtime_price_per_min", "model_routing",
} {
_, exposed := item[forbidden]
require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
}
}
for _, tt := range []struct {
name string
method string
path string
body string
list bool
}{
{name: "get all", method: http.MethodGet, path: "/groups/all", list: true},
{name: "get by id", method: http.MethodGet, path: "/groups/1"},
{name: "create", method: http.MethodPost, path: "/groups", body: `{"name":"created","platform":"anthropic","model_pricing":[{"models":["claude"]}]}`},
{name: "update", method: http.MethodPut, path: "/groups/1", body: `{"name":"updated","model_pricing":[{"models":["claude"]}]}`},
} {
t.Run(tt.name, func(t *testing.T) {
res := httptest.NewRecorder()
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
var payload struct {
Data json.RawMessage `json:"data"`
}
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
if tt.list {
var items []map[string]any
require.NoError(t, json.Unmarshal(payload.Data, &items))
require.Len(t, items, 1)
assertNoAdvancedFields(t, items[0])
return
}
var item map[string]any
require.NoError(t, json.Unmarshal(payload.Data, &item))
assertNoAdvancedFields(t, item)
})
}
}
func TestGroupHandlerSimpleModeBlocksAdvancedOperations(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
method string
path string
body string
}{
{http.MethodPost, "/groups/1/duplicate", ""},
{http.MethodGet, "/groups/1/model-allowlist-candidates", ""},
{http.MethodGet, "/groups/1/composite-routes", ""},
{http.MethodPost, "/groups/1/composite-routes", `{"public_model":"x","target_platform":"openai"}`},
{http.MethodPost, "/groups/1/composite-routes/preview", `{"model":"x"}`},
{http.MethodPut, "/groups/1/composite-routes/2", `{"public_model":"x","target_platform":"openai"}`},
{http.MethodDelete, "/groups/1/composite-routes/2", ""},
{http.MethodGet, "/groups/1/rate-multipliers", ""},
{http.MethodPut, "/groups/1/rate-multipliers", `{"entries":[]}`},
{http.MethodDelete, "/groups/1/rate-multipliers", ""},
{http.MethodPut, "/groups/1/rpm-overrides", `{"entries":[]}`},
{http.MethodDelete, "/groups/1/rpm-overrides", ""},
{http.MethodGet, "/groups/1/stats", ""},
{http.MethodGet, "/groups/1/api-keys", ""},
{http.MethodGet, "/groups/live-capability", ""},
{http.MethodGet, "/groups/usage-summary", ""},
{http.MethodGet, "/groups/capacity-summary", ""},
{http.MethodPut, "/groups/sort-order", `{"updates":[{"id":1,"sort_order":1}]}`},
}
for _, tt := range tests {
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusForbidden, res.Code)
require.Zero(t, svc.advancedGroupOperationCalls)
})
}
}
@@ -27,10 +27,27 @@ func executeAdminIdempotent(
payload any,
ttl time.Duration,
execute func(context.Context) (any, error),
) (*service.IdempotencyExecuteResult, error) {
return executeAdminIdempotentWithTimeout(c, scope, payload, ttl, 0, execute)
}
func executeAdminIdempotentWithTimeout(
c *gin.Context,
scope string,
payload any,
ttl time.Duration,
executionTimeout time.Duration,
execute func(context.Context) (any, error),
) (*service.IdempotencyExecuteResult, error) {
coordinator := service.DefaultIdempotencyCoordinator()
if coordinator == nil {
data, err := execute(c.Request.Context())
ctx := c.Request.Context()
if executionTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.WithoutCancel(ctx), executionTimeout)
defer cancel()
}
data, err := execute(ctx)
if err != nil {
return nil, err
}
@@ -38,14 +55,15 @@ func executeAdminIdempotent(
}
return coordinator.Execute(c.Request.Context(), service.IdempotencyExecuteOptions{
Scope: scope,
ActorScope: adminActorScope(c),
Method: c.Request.Method,
Route: c.FullPath(),
IdempotencyKey: c.GetHeader("Idempotency-Key"),
Payload: payload,
RequireKey: true,
TTL: ttl,
Scope: scope,
ActorScope: adminActorScope(c),
Method: c.Request.Method,
Route: c.FullPath(),
IdempotencyKey: c.GetHeader("Idempotency-Key"),
Payload: payload,
RequireKey: true,
TTL: ttl,
ExecutionTimeout: executionTimeout,
}, execute)
}
@@ -64,7 +82,18 @@ func executeAdminIdempotentJSON(
ttl time.Duration,
execute func(context.Context) (any, error),
) {
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, idempotencyStoreUnavailableFailClose, execute)
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, 0, idempotencyStoreUnavailableFailClose, execute)
}
func executeAdminIdempotentJSONWithTimeout(
c *gin.Context,
scope string,
payload any,
ttl time.Duration,
executionTimeout time.Duration,
execute func(context.Context) (any, error),
) {
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, executionTimeout, idempotencyStoreUnavailableFailClose, execute)
}
func executeAdminIdempotentJSONFailOpenOnStoreUnavailable(
@@ -74,7 +103,7 @@ func executeAdminIdempotentJSONFailOpenOnStoreUnavailable(
ttl time.Duration,
execute func(context.Context) (any, error),
) {
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, idempotencyStoreUnavailableFailOpen, execute)
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, 0, idempotencyStoreUnavailableFailOpen, execute)
}
func executeAdminIdempotentJSONWithMode(
@@ -82,10 +111,11 @@ func executeAdminIdempotentJSONWithMode(
scope string,
payload any,
ttl time.Duration,
executionTimeout time.Duration,
mode idempotencyStoreUnavailableMode,
execute func(context.Context) (any, error),
) {
result, err := executeAdminIdempotent(c, scope, payload, ttl, execute)
result, err := executeAdminIdempotentWithTimeout(c, scope, payload, ttl, executionTimeout, execute)
if err != nil {
if infraerrors.Code(err) == infraerrors.Code(service.ErrIdempotencyStoreUnavail) {
strategy := "fail_close"
@@ -219,7 +219,8 @@ func (h *OpsHandler) GetDashboardErrorDistribution(c *gin.Context) {
response.Success(c, data)
}
// GetDashboardOpenAITokenStats returns OpenAI token efficiency stats grouped by model.
// GetDashboardOpenAITokenStats returns token efficiency stats for all platforms, grouped by model.
// The endpoint name is retained for compatibility; platform and group_id optionally narrow the scope.
// GET /api/v1/admin/ops/dashboard/openai-token-stats
func (h *OpsHandler) GetDashboardOpenAITokenStats(c *gin.Context) {
if h.opsService == nil {
@@ -0,0 +1,41 @@
//go:build unit
package admin
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestProxyHandlerUpdateCredentialPresence(t *testing.T) {
gin.SetMode(gin.TestMode)
check := func(name, body string, username, password *string) {
t.Helper()
t.Run(name, func(t *testing.T) {
svc := &proxyPartialUpdateService{}
router := gin.New()
router.PUT("/proxies/:id", NewProxyHandler(svc).Update)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/proxies/9", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.NotNil(t, svc.input)
require.Equal(t, username, svc.input.Username)
require.Equal(t, password, svc.input.Password)
})
}
empty, username, password := "", "user", "pass"
check("omitted", `{"status":"inactive"}`, nil, nil)
check("null preserves", `{"username":null,"password":null}`, nil, nil)
check("clear both", `{"username":"","password":""}`, &empty, &empty)
check("clear username only", `{"username":""}`, &empty, nil)
check("clear password only", `{"password":""}`, nil, &empty)
check("trim values", `{"username":" user ","password":" pass "}`, &username, &password)
check("whitespace clears", `{"username":" ","password":" "}`, &empty, &empty)
}
+10 -6
View File
@@ -172,16 +172,18 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
updateInput := &service.UpdateProxyInput{
Status: normalizedStatus,
ExpiresAt: existingExpiresAt,
ClearExpiresAt: existingExpiresAt == nil,
FallbackMode: existingFallbackMode,
BackupProxyID: existingBackupProxyID,
ExpiryWarnDays: item.ExpiryWarnDays,
ClearBackupID: existingBackupProxyID == nil,
ExpiryWarnDays: &item.ExpiryWarnDays,
// 保留已存在代理的网络配置字段
Name: existing.Name,
Protocol: existing.Protocol,
Host: existing.Host,
Port: existing.Port,
Username: existing.Username,
Password: existing.Password,
Username: &existing.Username,
Password: &existing.Password,
}
if _, err := h.adminService.UpdateProxy(ctx, existing.ID, updateInput); err != nil {
result.Errors = append(result.Errors, DataImportError{
@@ -255,15 +257,17 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
if _, err := h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{
Status: normalizedStatus,
ExpiresAt: expiresAt,
ClearExpiresAt: expiresAt == nil,
FallbackMode: fallbackMode,
BackupProxyID: backupProxyID,
ExpiryWarnDays: item.ExpiryWarnDays,
ClearBackupID: backupProxyID == nil,
ExpiryWarnDays: &item.ExpiryWarnDays,
Name: created.Name,
Protocol: created.Protocol,
Host: created.Host,
Port: created.Port,
Username: created.Username,
Password: created.Password,
Username: &created.Username,
Password: &created.Password,
}); err != nil {
result.Errors = append(result.Errors, DataImportError{
Kind: "proxy",
+24 -16
View File
@@ -41,17 +41,17 @@ type CreateProxyRequest struct {
// UpdateProxyRequest represents update proxy request
type UpdateProxyRequest struct {
Name string `json:"name"`
Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"`
Host string `json:"host"`
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
Username string `json:"username"`
Password string `json:"password"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
ExpiresAt *int64 `json:"expires_at"`
FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"`
BackupProxyID *int64 `json:"backup_proxy_id"`
ExpiryWarnDays int `json:"expiry_warn_days" binding:"omitempty,min=0"`
Name string `json:"name"`
Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"`
Host string `json:"host"`
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
Username *string `json:"username"`
Password *string `json:"password"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
ExpiresAt dto.NullableInt64Field `json:"expires_at"`
FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"`
BackupProxyID dto.NullableInt64Field `json:"backup_proxy_id"`
ExpiryWarnDays *int `json:"expiry_warn_days" binding:"omitempty,min=0"`
}
// List handles listing all proxies with pagination
@@ -183,21 +183,29 @@ func (h *ProxyHandler) Update(c *gin.Context) {
}
var expiresAt *time.Time
if req.ExpiresAt != nil && *req.ExpiresAt > 0 {
t := time.Unix(*req.ExpiresAt, 0).UTC()
if req.ExpiresAt.Value != nil && *req.ExpiresAt.Value > 0 {
t := time.Unix(*req.ExpiresAt.Value, 0).UTC()
expiresAt = &t
}
if req.Username != nil {
*req.Username = strings.TrimSpace(*req.Username)
}
if req.Password != nil {
*req.Password = strings.TrimSpace(*req.Password)
}
proxy, err := h.adminService.UpdateProxy(c.Request.Context(), proxyID, &service.UpdateProxyInput{
Name: strings.TrimSpace(req.Name),
Protocol: strings.TrimSpace(req.Protocol),
Host: strings.TrimSpace(req.Host),
Port: req.Port,
Username: strings.TrimSpace(req.Username),
Password: strings.TrimSpace(req.Password),
Username: req.Username,
Password: req.Password,
Status: strings.TrimSpace(req.Status),
ExpiresAt: expiresAt,
ClearExpiresAt: req.ExpiresAt.Set && expiresAt == nil,
FallbackMode: strings.TrimSpace(req.FallbackMode),
BackupProxyID: req.BackupProxyID,
BackupProxyID: req.BackupProxyID.Value,
ClearBackupID: req.BackupProxyID.Set && req.BackupProxyID.Value == nil,
ExpiryWarnDays: req.ExpiryWarnDays,
})
if err != nil {
@@ -0,0 +1,60 @@
//go:build unit
package admin
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type proxyPartialUpdateService struct {
service.AdminService
input *service.UpdateProxyInput
}
func (s *proxyPartialUpdateService) UpdateProxy(_ context.Context, _ int64, input *service.UpdateProxyInput) (*service.Proxy, error) {
s.input = input
return &service.Proxy{ID: 9}, nil
}
func TestProxyHandlerUpdatePreservesFieldPresence(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name, body string
set, clear, warn bool
}{
{name: "omitted", body: `{"status":"inactive"}`},
{name: "null clears", body: `{"expires_at":null,"backup_proxy_id":null,"fallback_mode":"none","expiry_warn_days":0}`, set: true, clear: true, warn: true},
{name: "zero clears expiry", body: `{"expires_at":0,"backup_proxy_id":null}`, set: true, clear: true},
{name: "values", body: `{"expires_at":1800000000,"backup_proxy_id":10,"fallback_mode":"proxy","expiry_warn_days":7}`, set: true, warn: true},
} {
t.Run(tc.name, func(t *testing.T) {
svc := &proxyPartialUpdateService{}
router := gin.New()
router.PUT("/proxies/:id", NewProxyHandler(svc).Update)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/proxies/9", strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.NotNil(t, svc.input)
require.Equal(t, tc.set, svc.input.ExpiresAt != nil || svc.input.ClearExpiresAt)
require.Equal(t, tc.set, svc.input.BackupProxyID != nil || svc.input.ClearBackupID)
require.Equal(t, tc.warn, svc.input.ExpiryWarnDays != nil)
if !tc.set || tc.clear {
require.Nil(t, svc.input.ExpiresAt)
require.Nil(t, svc.input.BackupProxyID)
} else {
require.Equal(t, int64(1800000000), svc.input.ExpiresAt.Unix())
require.Equal(t, int64(10), *svc.input.BackupProxyID)
}
})
}
}
@@ -228,7 +228,7 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
return nil, createErr
}
redeemed, redeemErr := h.redeemService.Redeem(ctx, req.UserID, req.Code)
redeemed, redeemErr := h.redeemService.RedeemForAdminFulfillment(ctx, req.UserID, req.Code)
if redeemErr != nil {
return nil, redeemErr
}
@@ -246,7 +246,7 @@ func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, exis
return nil, service.ErrRedeemCodeExpired
}
if existing.CanUse() {
redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code)
redeemed, err := h.redeemService.RedeemForAdminFulfillment(ctx, userID, existing.Code)
if err == nil {
return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil
}
@@ -376,12 +376,14 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
GrokDefaultTextModel: settings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled,
GrokDefaultBaseURLMode: settings.GrokDefaultBaseURLMode,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
SubscriptionEnabled: settings.SubscriptionEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
@@ -579,6 +579,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
changed = append(changed, "available_channels_enabled")
}
if before.SubscriptionEnabled != after.SubscriptionEnabled {
changed = append(changed, "subscription_enabled")
}
if before.ModelPlazaEnabled != after.ModelPlazaEnabled {
changed = append(changed, "model_plaza_enabled")
}
@@ -186,3 +186,20 @@ func TestUpdateSettingsValidatesTencentCaptchaAppIDWhenEnabledFlagIsOmitted(t *t
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Contains(t, rec.Body.String(), "positive integer")
}
// subscription_enabled is an opt-out switch: an explicit false is written as-is,
// and a later payload that omits the field keeps the stored value.
func TestUpdateSettingsSubscriptionEnabledIsWritableAndKeptWhenOmitted(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeySubscriptionEnabled: "true",
})
rec := doUpdateSettings(t, h, map[string]any{"subscription_enabled": false}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "false", repo.values[service.SettingKeySubscriptionEnabled])
rec = doUpdateSettings(t, h, map[string]any{"site_name": "Example Gateway"}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "false", repo.values[service.SettingKeySubscriptionEnabled],
"a payload without subscription_enabled must not flip the stored value back to true")
}
@@ -334,6 +334,7 @@ type UpdateSettingsRequest struct {
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"`
ChannelMonitorShowQuota *bool `json:"channel_monitor_show_quota"`
ChannelMonitorHideUserRanking *bool `json:"channel_monitor_hide_user_ranking"`
// Grok model mapping policy
GrokDefaultTextModel *string `json:"grok_default_text_model"`
@@ -343,6 +344,9 @@ type UpdateSettingsRequest struct {
// Available Channels feature switch (user-facing)
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
// Subscription feature switch (user-facing subscription surface; see SettingKeySubscriptionEnabled)
SubscriptionEnabled *bool `json:"subscription_enabled"`
// Model Plaza feature switches + description
ModelPlazaEnabled *bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
@@ -1906,6 +1910,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.ChannelMonitorShowQuota
}(),
ChannelMonitorHideUserRanking: func() bool {
if req.ChannelMonitorHideUserRanking != nil {
return *req.ChannelMonitorHideUserRanking
}
return previousSettings.ChannelMonitorHideUserRanking
}(),
GrokDefaultTextModel: func() string {
if req.GrokDefaultTextModel != nil {
return *req.GrokDefaultTextModel
@@ -1930,6 +1940,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.AvailableChannelsEnabled
}(),
SubscriptionEnabled: func() bool {
if req.SubscriptionEnabled != nil {
return *req.SubscriptionEnabled
}
return previousSettings.SubscriptionEnabled
}(),
ModelPlazaEnabled: func() bool {
if req.ModelPlazaEnabled != nil {
return *req.ModelPlazaEnabled
@@ -2366,12 +2382,14 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput,
ChannelMonitorShowQuota: updatedSettings.ChannelMonitorShowQuota,
ChannelMonitorHideUserRanking: updatedSettings.ChannelMonitorHideUserRanking,
GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled,
GrokDefaultBaseURLMode: updatedSettings.GrokDefaultBaseURLMode,
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
SubscriptionEnabled: updatedSettings.SubscriptionEnabled,
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
@@ -0,0 +1,212 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type bulkActionHandlerSubscriptionRepo struct {
service.UserSubscriptionRepository
sub *service.UserSubscription
extendCalls int
}
type cancellationAwareBulkActionRepo struct {
*bulkActionHandlerSubscriptionRepo
cancelRequest context.CancelFunc
}
func (r *cancellationAwareBulkActionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return r.bulkActionHandlerSubscriptionRepo.GetByID(ctx, id)
}
func (r *cancellationAwareBulkActionRepo) ExtendExpiry(ctx context.Context, id int64, expiry time.Time) error {
if err := ctx.Err(); err != nil {
return err
}
err := r.bulkActionHandlerSubscriptionRepo.ExtendExpiry(ctx, id, expiry)
r.cancelRequest()
return err
}
type cancellationAwareAdminIdempotencyRepo struct {
*memoryIdempotencyRepoStub
saves int
}
func (r *cancellationAwareAdminIdempotencyRepo) MarkSucceeded(ctx context.Context, id int64, status int, body string, expiresAt time.Time) error {
if err := ctx.Err(); err != nil {
return err
}
r.saves++
return r.memoryIdempotencyRepoStub.MarkSucceeded(ctx, id, status, body, expiresAt)
}
func (r *bulkActionHandlerSubscriptionRepo) GetByID(_ context.Context, id int64) (*service.UserSubscription, error) {
if r.sub == nil || r.sub.ID != id {
return nil, service.ErrSubscriptionNotFound
}
copy := *r.sub
return &copy, nil
}
func (r *bulkActionHandlerSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) {
return r.GetByID(ctx, id)
}
func (r *bulkActionHandlerSubscriptionRepo) ExtendExpiry(_ context.Context, _ int64, expiry time.Time) error {
r.extendCalls++
r.sub.ExpiresAt = expiry
return nil
}
func bulkActionHandlerRequest(router *gin.Engine, path, body, key string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func TestSubscriptionBulkAction_ReplaysPartialResultWithoutRepeatingExtension(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := service.DefaultIdempotencyConfig()
cfg.ObserveOnly = false
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), cfg))
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
expiresAt := time.Now().AddDate(0, 0, 30)
repo := &bulkActionHandlerSubscriptionRepo{sub: &service.UserSubscription{ID: 1, UserID: 1, GroupID: 10, ExpiresAt: expiresAt}}
svc := service.NewSubscriptionService(nil, repo, nil, nil, nil)
t.Cleanup(svc.Stop)
h := NewSubscriptionHandler(svc)
router := gin.New()
path := "/api/v1/admin/subscriptions/bulk-action"
router.POST(path, h.BulkAction)
body := `{"subscription_ids":[1,404,1],"action":"extend","days":7}`
missingKey := bulkActionHandlerRequest(router, path, body, "")
require.Equal(t, http.StatusBadRequest, missingKey.Code)
require.Zero(t, repo.extendCalls)
first := bulkActionHandlerRequest(router, path, body, "bulk-extend-once")
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
var result struct {
Data service.BulkSubscriptionActionResult `json:"data"`
}
require.NoError(t, json.Unmarshal(first.Body.Bytes(), &result))
require.Equal(t, 1, result.Data.SuccessCount)
require.Equal(t, 1, result.Data.FailedCount)
require.Len(t, result.Data.Results, 2)
replayed := bulkActionHandlerRequest(router, path, body, "bulk-extend-once")
require.Equal(t, http.StatusOK, replayed.Code, replayed.Body.String())
require.Equal(t, "true", replayed.Header().Get("X-Idempotency-Replayed"))
require.JSONEq(t, first.Body.String(), replayed.Body.String())
require.Equal(t, 1, repo.extendCalls)
require.Equal(t, expiresAt.AddDate(0, 0, 7), repo.sub.ExpiresAt)
conflict := bulkActionHandlerRequest(router, path, `{"subscription_ids":[1,404,1],"action":"extend","days":14}`, "bulk-extend-once")
require.Equal(t, http.StatusConflict, conflict.Code)
require.Equal(t, 1, repo.extendCalls)
}
func TestSubscriptionBulkAction_ClientCancellationStillPersistsReplay(t *testing.T) {
gin.SetMode(gin.TestMode)
idempotencyRepo := &cancellationAwareAdminIdempotencyRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub()}
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(idempotencyRepo, service.DefaultIdempotencyConfig()))
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
requestCtx, cancel := context.WithCancel(context.Background())
defer cancel()
expiresAt := time.Now().AddDate(0, 0, 30)
repo := &cancellationAwareBulkActionRepo{
bulkActionHandlerSubscriptionRepo: &bulkActionHandlerSubscriptionRepo{sub: &service.UserSubscription{ID: 1, UserID: 1, GroupID: 10, ExpiresAt: expiresAt}},
cancelRequest: cancel,
}
svc := service.NewSubscriptionService(nil, repo, nil, nil, nil)
t.Cleanup(svc.Stop)
router := gin.New()
path := "/api/v1/admin/subscriptions/bulk-action"
router.POST(path, NewSubscriptionHandler(svc).BulkAction)
body := `{"subscription_ids":[1],"action":"extend","days":7}`
request := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body)).WithContext(requestCtx)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Idempotency-Key", "canceled-request")
first := httptest.NewRecorder()
router.ServeHTTP(first, request)
require.ErrorIs(t, requestCtx.Err(), context.Canceled)
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
require.Equal(t, 1, idempotencyRepo.saves)
replayed := bulkActionHandlerRequest(router, path, body, "canceled-request")
require.Equal(t, http.StatusOK, replayed.Code, replayed.Body.String())
require.Equal(t, "true", replayed.Header().Get("X-Idempotency-Replayed"))
require.JSONEq(t, first.Body.String(), replayed.Body.String())
require.Equal(t, 1, repo.extendCalls)
require.Equal(t, expiresAt.AddDate(0, 0, 7), repo.sub.ExpiresAt)
}
func TestSubscriptionBulkAction_ValidatesBeforeIdempotencyAndExecution(t *testing.T) {
gin.SetMode(gin.TestMode)
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(storeUnavailableRepoStub{}, service.DefaultIdempotencyConfig()))
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
router := gin.New()
path := "/api/v1/admin/subscriptions/bulk-action"
router.POST(path, NewSubscriptionHandler(nil).BulkAction)
tooManyIDs := make([]int64, 101)
for i := range tooManyIDs {
tooManyIDs[i] = 1
}
tooManyBody, err := json.Marshal(service.BulkSubscriptionActionInput{SubscriptionIDs: tooManyIDs, Action: "revoke"})
require.NoError(t, err)
for _, body := range []string{
`{"subscription_ids":[1],"action":"delete"}`,
`{"subscription_ids":[1,0],"action":"revoke"}`,
`{"subscription_ids":[],"action":"restore"}`,
`{"subscription_ids":[1],"action":"extend","days":0}`,
`{"subscription_ids":[1],"action":"extend","days":36501}`,
`{"subscription_ids":[1],"action":"reset_quota"}`,
`{"subscription_ids":[1],"action":"reset_quota","daily":"yes"}`,
`{"subscription_ids":[1],"action":"revoke"`,
string(tooManyBody),
} {
t.Run(body, func(t *testing.T) {
response := bulkActionHandlerRequest(router, path, body, "bulk-invalid")
require.Equal(t, http.StatusBadRequest, response.Code, response.Body.String())
})
}
// A valid request must still fail closed when replay protection is unavailable.
response := bulkActionHandlerRequest(router, path, `{"subscription_ids":[1],"action":"revoke"}`, "bulk-valid")
require.Equal(t, http.StatusServiceUnavailable, response.Code)
}
func TestSubscriptionBulkAssign_RejectsInvalidUserIDsBeforeExecution(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
path := "/api/v1/admin/subscriptions/bulk-assign"
router.POST(path, NewSubscriptionHandler(nil).BulkAssign)
for _, ids := range [][]int64{{}, {1, 0}, {1, -1}, make([]int64, 101)} {
t.Run(fmt.Sprint(len(ids), ids), func(t *testing.T) {
body, err := json.Marshal(BulkAssignSubscriptionRequest{UserIDs: ids, GroupID: 1, ValidityDays: 30})
require.NoError(t, err)
response := bulkActionHandlerRequest(router, path, string(body), "")
require.Equal(t, http.StatusBadRequest, response.Code, response.Body.String())
})
}
}
@@ -3,6 +3,7 @@ package admin
import (
"context"
"strconv"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
@@ -48,7 +49,7 @@ type AssignSubscriptionRequest struct {
// BulkAssignSubscriptionRequest represents bulk assign subscription request
type BulkAssignSubscriptionRequest struct {
UserIDs []int64 `json:"user_ids" binding:"required,min=1"`
UserIDs []int64 `json:"user_ids" binding:"required,min=1,max=100,dive,gt=0"`
GroupID int64 `json:"group_id" binding:"required"`
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years
Notes string `json:"notes"`
@@ -186,6 +187,23 @@ func (h *SubscriptionHandler) BulkAssign(c *gin.Context) {
response.Success(c, dto.BulkAssignResultFromService(result))
}
// BulkAction applies one operation to selected subscriptions, returning each outcome.
// POST /api/v1/admin/subscriptions/bulk-action
func (h *SubscriptionHandler) BulkAction(c *gin.Context) {
var req service.BulkSubscriptionActionInput
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := req.Validate(); err != nil {
response.ErrorFrom(c, err)
return
}
executeAdminIdempotentJSONWithTimeout(c, "admin.subscriptions.bulk-action", req, service.DefaultWriteIdempotencyTTL(), 2*time.Minute, func(ctx context.Context) (any, error) {
return h.subscriptionService.BulkSubscriptionAction(ctx, &req)
})
}
// Extend handles adjusting a subscription (extend or shorten)
// POST /api/v1/admin/subscriptions/:id/extend
func (h *SubscriptionHandler) Extend(c *gin.Context) {
@@ -790,15 +790,21 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
}
}
// 三档全空的输入不落库:user_platform_quotas 只保存至少配置了一档限额的记录,
// 不存在的行等价于不限额。未进入 records 的平台由 UpsertForUser 软删,审计里记为 removed。
records := make([]service.UserPlatformQuotaRecord, 0, len(req.Quotas))
for _, q := range req.Quotas {
records = append(records, service.UserPlatformQuotaRecord{
rec := service.UserPlatformQuotaRecord{
UserID: userID,
Platform: q.Platform,
DailyLimitUSD: q.DailyLimitUSD,
WeeklyLimitUSD: q.WeeklyLimitUSD,
MonthlyLimitUSD: q.MonthlyLimitUSD,
})
}
if !rec.HasAnyLimit() {
continue
}
records = append(records, rec)
}
ctx := c.Request.Context()
@@ -860,6 +866,7 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
slog.Info("admin.quota_updated",
"actor_admin_id", getAdminIDFromContext(c),
"target_user_id", userID,
"submitted_count", len(req.Quotas),
"platform_count", len(records),
"before_snapshot_available", beforeErr == nil,
"changes", changes)
@@ -112,16 +112,61 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
if len(repo.upsertCalls) != 1 {
t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
}
// upsert 记录数 = 请求体中给出的平台数(未给出的平台不落库)。
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 5 {
// upsert 记录数 = 请求体中至少配置了一档限额的平台数;三档全空的平台不落库,
// 由 UpsertForUser 的"不在列表里就软删"分支处理。
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 2 {
t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0])
}
for _, r := range repo.upsertCalls[0].records {
if r.Platform != "anthropic" && r.Platform != "openai" {
t.Errorf("platform %q has no configured limit and must not be upserted", r.Platform)
}
}
// 缓存失效:按全部允许平台统一失效(含 kimi/zhipu/deepseek)。
if len(cache.deleteCalls) != len(service.AllowedQuotaPlatforms) {
t.Errorf("expected %d cache delete calls, got %d: %+v", len(service.AllowedQuotaPlatforms), len(cache.deleteCalls), cache.deleteCalls)
}
}
// TestUpdateUserPlatformQuotas_AllUnlimitedClearsRows 锁定:全部平台三档全空等价于清空,
// UpsertForUser 收到空列表(软删该用户所有活跃行),且 0 = 显式禁用仍算已配置。
func TestUpdateUserPlatformQuotas_AllUnlimitedClearsRows(t *testing.T) {
repo := &upsertCapturingQuotaRepo{}
cache := &billingCacheStub{}
h := buildTestHandler(repo, cache)
body := `{"quotas":[
{"platform":"anthropic","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null},
{"platform":"openai"}
]}`
c, w := putReq(t, body)
h.UpdateUserPlatformQuotas(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if len(repo.upsertCalls) != 1 {
t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
}
if len(repo.upsertCalls[0].records) != 0 {
t.Errorf("all-unlimited input must upsert zero records, got %+v", repo.upsertCalls[0].records)
}
repo = &upsertCapturingQuotaRepo{}
h = buildTestHandler(repo, &billingCacheStub{})
c, w = putReq(t, `{"quotas":[{"platform":"gemini","daily_limit_usd":0}]}`)
h.UpdateUserPlatformQuotas(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if len(repo.upsertCalls) != 1 || len(repo.upsertCalls[0].records) != 1 {
t.Fatalf("zero limit is a configured limit and must be upserted: %+v", repo.upsertCalls)
}
if r := repo.upsertCalls[0].records[0]; r.Platform != "gemini" || r.DailyLimitUSD == nil || *r.DailyLimitUSD != 0 {
t.Errorf("unexpected record: %+v", r)
}
}
func TestUpdateUserPlatformQuotas_RejectsDuplicatePlatform(t *testing.T) {
h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{})
body := `{"quotas":[
@@ -29,19 +29,19 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T
AvatarURL: "https://cdn.example.com/linuxdo.png",
AvatarSource: "remote_url",
},
identities: []service.UserAuthIdentityRecord{
{
ProviderType: "linuxdo",
ProviderKey: "linuxdo",
ProviderSubject: "linuxdo-subject-31",
VerifiedAt: &verifiedAt,
Metadata: map[string]any{
"username": "linuxdo-handle",
"avatar_url": "https://cdn.example.com/linuxdo.png",
},
identities: []service.UserAuthIdentityRecord{
{
ProviderType: "linuxdo",
ProviderKey: "linuxdo",
ProviderSubject: "linuxdo-subject-31",
VerifiedAt: &verifiedAt,
Metadata: map[string]any{
"username": "linuxdo-handle",
"avatar_url": "https://cdn.example.com/linuxdo.png",
},
},
}
},
}
handler := &AuthHandler{
userService: service.NewUserService(repo, nil, nil, nil),
@@ -81,15 +81,19 @@ type userSupportedModelPricing struct {
// userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。
type userPricingIntervalDTO struct {
MinTokens int `json:"min_tokens"`
MaxTokens *int `json:"max_tokens"`
TierLabel string `json:"tier_label,omitempty"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
PerRequestPrice *float64 `json:"per_request_price"`
MinTokens int `json:"min_tokens"`
MaxTokens *int `json:"max_tokens"`
TierLabel string `json:"tier_label,omitempty"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
InputMultiplier *float64 `json:"input_multiplier"`
OutputMultiplier *float64 `json:"output_multiplier"`
CacheWriteMultiplier *float64 `json:"cache_write_multiplier"`
CacheReadMultiplier *float64 `json:"cache_read_multiplier"`
PerRequestPrice *float64 `json:"per_request_price"`
}
// userSupportedModel 用户可见的支持模型条目。
@@ -295,15 +299,19 @@ func toUserPricingIntervals(src []service.PricingInterval) []userPricingInterval
intervals := make([]userPricingIntervalDTO, 0, len(src))
for _, iv := range src {
intervals = append(intervals, userPricingIntervalDTO{
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheWrite1hPrice: iv.CacheWrite1hPrice,
CacheReadPrice: iv.CacheReadPrice,
PerRequestPrice: iv.PerRequestPrice,
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheWrite1hPrice: iv.CacheWrite1hPrice,
CacheReadPrice: iv.CacheReadPrice,
InputMultiplier: iv.InputMultiplier,
OutputMultiplier: iv.OutputMultiplier,
CacheWriteMultiplier: iv.CacheWriteMultiplier,
CacheReadMultiplier: iv.CacheReadMultiplier,
PerRequestPrice: iv.PerRequestPrice,
})
}
return intervals
@@ -113,10 +113,18 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
}
// pricing interval 白名单:不应暴露 id / sort_order。
inputMultiplier := 2.0
outputMultiplier := 1.5
cacheWriteMultiplier := 2.0
cacheReadMultiplier := 2.0
pricing := toUserPricing(&service.ChannelModelPricing{
BillingMode: service.BillingModeToken,
Intervals: []service.PricingInterval{
{ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3},
{
ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3,
InputMultiplier: &inputMultiplier, OutputMultiplier: &outputMultiplier,
CacheWriteMultiplier: &cacheWriteMultiplier, CacheReadMultiplier: &cacheReadMultiplier,
},
},
})
require.NotNil(t, pricing)
@@ -129,6 +137,14 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
_, exists := ivDecoded[key]
require.Falsef(t, exists, "user pricing interval must not expose %q", key)
}
for key, want := range map[string]float64{
"input_multiplier": inputMultiplier, "output_multiplier": outputMultiplier,
"cache_write_multiplier": cacheWriteMultiplier, "cache_read_multiplier": cacheReadMultiplier,
} {
got, exists := ivDecoded[key]
require.Truef(t, exists, "user pricing interval must expose %q", key)
require.InDelta(t, want, got.(float64), 1e-12)
}
}
func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) {
@@ -139,9 +139,28 @@ func (h *BatchImageHandler) Models(c *gin.Context) {
batchImageError(c, err)
return
}
// 分组级模型白名单:ListModels 已按分组账号枚举,这里再按白名单过滤一次。
if apiKey, ok := middleware.GetAPIKeyFromContext(c); ok && apiKey != nil && apiKey.Group != nil {
got.Data = filterBatchImageModelsByAllowlist(got.Data, apiKey.Group.ModelAllowlist)
}
c.JSON(http.StatusOK, got)
}
// filterBatchImageModelsByAllowlist 按分组模型白名单过滤批量生图模型列表。
// 白名单未开启时原样返回。
func filterBatchImageModelsByAllowlist(models []service.BatchImagePublicModel, allowlist service.GroupModelAllowlist) []service.BatchImagePublicModel {
if !allowlist.Enabled {
return models
}
filtered := make([]service.BatchImagePublicModel, 0, len(models))
for _, model := range models {
if allowlist.Allows(model.ID) {
filtered = append(filtered, model)
}
}
return filtered
}
func (h *BatchImageHandler) Items(c *gin.Context) {
owner, ok := batchImageOwnerFromContext(c)
if !ok {
@@ -35,6 +35,7 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) {
{model: "k3", platform: service.PlatformKimi},
{model: "glm-5.2", platform: service.PlatformZhipu},
{model: "deepseek-v3.2", platform: service.PlatformDeepseek},
{model: "MiniMax-M3", platform: service.PlatformMiniMax},
}
for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} {
for _, provider := range providers {
@@ -56,7 +57,7 @@ func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testi
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI))
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok))
for _, platform := range []string{
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax,
service.PlatformAnthropic, service.PlatformGemini,
} {
require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform)
@@ -152,6 +153,23 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
var overLimit *service.ReasoningEffortOverLimitError
require.ErrorAs(t, err, &overLimit)
mappingDenyGroup := *group
mappingDenyGroup.MaxReasoningEffort = ""
mappingDenyGroup.ReasoningEffortMappings = []service.ReasoningEffortMapping{
{From: "max", To: service.ReasoningEffortMappingDeny},
}
mappingDenyAPIKey := &service.APIKey{Group: &mappingDenyGroup}
mappingDenyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
mappingDenyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
mappingDenyCtx.Request = mappingDenyCtx.Request.WithContext(service.WithResolvedTargetPlatform(mappingDenyCtx.Request.Context(), service.PlatformOpenAI))
_, changed, err = applyOpenAIReasoningEffortPolicyForRequest(mappingDenyCtx, mappingDenyAPIKey, body)
require.Error(t, err)
require.False(t, changed)
var mappingDenied *service.ReasoningEffortMappingDeniedError
require.ErrorAs(t, err, &mappingDenied)
require.Equal(t, "max", mappingDenied.Requested)
require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy")
grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
+1 -1
View File
@@ -159,7 +159,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
MCPXMLInject: g.MCPXMLInject,
DefaultMappedModel: g.DefaultMappedModel,
MessagesDispatchModelConfig: g.MessagesDispatchModelConfig,
ModelsListConfig: g.ModelsListConfig,
ModelAllowlist: g.ModelAllowlist,
CodexModelsManifestConfig: g.CodexModelsManifestConfig,
SupportedModelScopes: g.SupportedModelScopes,
AccountCount: g.AccountCount,
+21 -8
View File
@@ -9,13 +9,14 @@ import (
// CustomMenuItem represents a user-configured custom menu entry.
type CustomMenuItem struct {
ID string `json:"id"`
Label string `json:"label"`
IconSVG string `json:"icon_svg"`
URL string `json:"url"`
PageSlug string `json:"page_slug,omitempty"`
Visibility string `json:"visibility"` // "user" or "admin"
SortOrder int `json:"sort_order"`
ID string `json:"id"`
Label string `json:"label"`
IconSVG string `json:"icon_svg"`
URL string `json:"url"`
PageSlug string `json:"page_slug,omitempty"`
Visibility string `json:"visibility"` // "user" or "admin"
SortOrder int `json:"sort_order"`
HideOpenButton bool `json:"hide_open_button,omitempty"`
}
// CustomEndpoint represents an admin-configured API endpoint for quick copy.
@@ -307,6 +308,7 @@ type SystemSettings struct {
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
// Grok model mapping policy (admin settings; empty account mapping falls back to these).
GrokDefaultTextModel string `json:"grok_default_text_model"`
@@ -316,6 +318,10 @@ type SystemSettings struct {
// Available Channels feature switch (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Subscription feature switch: gates the whole user-facing subscription surface
// (sidebar entries, purchase-page subscription tab, header badge, /subscriptions route).
SubscriptionEnabled bool `json:"subscription_enabled"`
// Model Plaza feature (public group/model pricing showcase)
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
@@ -402,7 +408,11 @@ type PublicSettings struct {
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version"`
// PaymentBalanceDisabled mirrors the payment-config BALANCE_PAYMENT_DISABLED switch so the
// user shell can derive the site billing mode (recharge & subscription / recharge only /
// subscription only) before any authenticated checkout call.
PaymentBalanceDisabled bool `json:"payment_balance_disabled"`
Version string `json:"version"`
// 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。
// 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。
ServerTimezone string `json:"server_timezone"`
@@ -417,9 +427,12 @@ type PublicSettings struct {
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
SubscriptionEnabled bool `json:"subscription_enabled"`
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
PluginManagementEnabled bool `json:"plugin_management_enabled"`
+1 -1
View File
@@ -189,7 +189,7 @@ type AdminGroup struct {
// OpenAI Messages 调度配置(仅 openai 平台使用)
DefaultMappedModel string `json:"default_mapped_model"`
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config"`
ModelAllowlist service.GroupModelAllowlist `json:"model_allowlist"`
// 固定账号获取 Codex Model Manifest 配置(仅 openai 平台使用)。
CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
+1 -1
View File
@@ -315,7 +315,7 @@ func GetUpstreamEndpoint(c *gin.Context, platform string) string {
// OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。
// 这对 force_chat_completions 的错误路径尤为重要:此时可能没有
// ForwardResult,不能把入站 /v1/responses 误报成上游端点。
if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsCNProvider(platform) {
if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform) {
if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" {
return endpoint
}
+12
View File
@@ -233,6 +233,18 @@ func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) {
result: &service.OpenAIForwardResult{},
want: EndpointResponses,
},
{
name: "opencode go conversion result reports responses",
account: &service.Account{Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey},
result: &service.OpenAIForwardResult{UpstreamEndpoint: EndpointResponses},
want: EndpointResponses,
},
{
name: "opencode go empty result without runtime stays inbound",
account: &service.Account{Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey},
result: &service.OpenAIForwardResult{},
want: EndpointChatCompletions,
},
}
for _, tt := range tests {
+64 -108
View File
@@ -1116,8 +1116,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
}
// Models handles listing available models
// GET /v1/models
// Models lists visible models, or retrieves the exact list entry for a model path parameter.
// GET /v1/models and /v1/models/:model (also exposed through root aliases)
// Returns models based on account configurations (model_mapping whitelist)
// Falls back to default models if no whitelist is configured
func (h *GatewayHandler) Models(c *gin.Context) {
@@ -1134,11 +1134,20 @@ func (h *GatewayHandler) Models(c *gin.Context) {
platform = forcedPlatform
}
if platform == service.PlatformOpenAI && apiKey != nil && apiKey.Group != nil &&
apiKey.Group.Platform == service.PlatformOpenAI && apiKey.Group.CodexModelsManifestConfig.Enabled {
h.pinnedOpenAIModels(c, apiKey.Group)
return
}
if platform == service.PlatformComposite {
availableModels := h.compositeAvailableModels(c.Request.Context(), groupID)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() {
availableModels = filterModelsByCustomList(availableModels, defaultModelIDsForPlatform(service.PlatformComposite), apiKey.Group.ModelsListConfig.Models)
writeCustomModelsList(c, service.PlatformComposite, availableModels)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
source := availableModels
if len(source) == 0 {
source = defaultModelIDsForPlatform(service.PlatformComposite)
}
writeAllowlistedModelsList(c, service.PlatformComposite, apiKey.Group.ModelAllowlist.FilterForListing(source))
return
}
if len(availableModels) > 0 {
@@ -1151,10 +1160,9 @@ 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() {
fallbackModels := defaultModelIDsForPlatform(platform)
availableModels = filterModelsByCustomList(customModelsListSource(platform, availableModels, fallbackModels), fallbackModels, apiKey.Group.ModelsListConfig.Models)
writeCustomModelsList(c, platform, availableModels)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
source := modelListingSource(platform, availableModels, defaultModelIDsForPlatform(platform))
writeAllowlistedModelsList(c, platform, apiKey.Group.ModelAllowlist.FilterForListing(source))
return
}
@@ -1165,18 +1173,12 @@ func (h *GatewayHandler) Models(c *gin.Context) {
// Fallback to default models
if platform == service.PlatformOpenAI {
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": openai.DefaultModels,
})
writeModelsListResponse(c, openai.DefaultModels)
return
}
if platform == service.PlatformGemini {
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": geminicli.DefaultModels,
})
writeModelsListResponse(c, geminicli.DefaultModels)
return
}
if platform == service.PlatformGrok {
@@ -1184,10 +1186,7 @@ func (h *GatewayHandler) Models(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": claude.DefaultModels,
})
writeModelsListResponse(c, claude.DefaultModels)
}
// CodexModels returns the effective group model list using the manifest shape
@@ -1239,8 +1238,12 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
if platform == service.PlatformComposite {
availableModels := h.compositeAvailableModels(ctx, groupID)
fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite)
if group.CustomModelsListEnabled() {
return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models)
if group.ModelAllowlistEnabled() {
source := availableModels
if len(source) == 0 {
source = fallbackModels
}
return group.ModelAllowlist.FilterForListing(source)
}
if len(availableModels) > 0 {
return availableModels
@@ -1250,12 +1253,8 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
fallbackModels := defaultCodexModelIDsForPlatform(platform)
if group.CustomModelsListEnabled() {
return filterModelsByCustomList(
customModelsListSource(platform, availableModels, fallbackModels),
fallbackModels,
group.ModelsListConfig.Models,
)
if group.ModelAllowlistEnabled() {
return group.ModelAllowlist.FilterForListing(modelListingSource(platform, availableModels, fallbackModels))
}
if len(availableModels) > 0 {
return availableModels
@@ -1270,12 +1269,12 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
seen := make(map[string]struct{})
models := make([]string, 0)
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
if len(platformModels) == 0 {
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
// default 分支是 Claude 列表),composite 下只暴露账号映射键。
if _, ok := schedulablePlatforms[platform]; ok && !service.IsCNProvider(platform) {
if _, ok := schedulablePlatforms[platform]; ok && !service.IsMultiProtocolAPIKeyProvider(platform) {
platformModels = defaultModelIDsForPlatform(platform)
}
}
@@ -1295,6 +1294,10 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
}
func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
if platform == service.PlatformOpenAI {
writeOpenAIModelsList(c, modelIDs)
return
}
if platform == service.PlatformGrok {
writeGrokModelsList(c, modelIDs)
return
@@ -1308,13 +1311,10 @@ func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
CreatedAt: "2024-01-01T00:00:00Z",
})
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": models,
})
writeModelsListResponse(c, models)
}
func writeCustomModelsList(c *gin.Context, platform string, modelIDs []string) {
func writeAllowlistedModelsList(c *gin.Context, platform string, modelIDs []string) {
if platform == service.PlatformOpenAI {
writeOpenAIModelsList(c, modelIDs)
return
@@ -1370,10 +1370,7 @@ func writeGrokModelsList(c *gin.Context, modelIDs []string) {
models = append(models, item)
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": models,
})
writeModelsListResponse(c, models)
}
func grokModelSupportsConfigurableReasoning(modelID string) bool {
@@ -1406,82 +1403,28 @@ func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
DisplayName: modelID,
})
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": models,
})
writeModelsListResponse(c, models)
}
func customModelsListSource(platform string, availableModels, fallbackModels []string) []string {
if platform == service.PlatformAnthropic && len(availableModels) > 0 {
// modelListingSource 汇总模型列表过滤的候选来源:账号映射键(availableModels)
// 与平台默认列表(fallbackModels)。账号映射为空时回落默认列表;Anthropic
// 平台两者取并集,其余平台以账号映射键为准。
func modelListingSource(platform string, availableModels, fallbackModels []string) []string {
if len(availableModels) == 0 {
return fallbackModels
}
if platform == service.PlatformAnthropic {
return mergeModelIDs(availableModels, fallbackModels)
}
return availableModels
}
func filterModelsByCustomList(availableModels, fallbackModels, selectedModels []string) []string {
if len(selectedModels) == 0 {
return availableModels
}
source := availableModels
if len(source) == 0 {
source = fallbackModels
}
if len(source) == 0 {
return nil
}
allowed := make([]string, 0, len(source))
for _, model := range source {
model = strings.TrimSpace(model)
if model != "" {
allowed = append(allowed, model)
}
}
seen := make(map[string]struct{}, len(selectedModels))
filtered := make([]string, 0, len(selectedModels))
for _, model := range selectedModels {
model = strings.TrimSpace(model)
if model == "" {
continue
}
if !customModelsListAllowsModel(allowed, model) {
continue
}
if _, ok := seen[model]; ok {
continue
}
seen[model] = struct{}{}
filtered = append(filtered, model)
}
return filtered
}
func customModelsListAllowsModel(availablePatterns []string, model string) bool {
for _, pattern := range availablePatterns {
if pattern == model {
return true
}
if strings.HasSuffix(pattern, "*") && strings.HasPrefix(model, strings.TrimSuffix(pattern, "*")) {
return true
}
}
normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking"))
if normalizedClaudeModel != model {
for _, pattern := range availablePatterns {
if pattern == normalizedClaudeModel {
return true
}
}
}
return false
}
func defaultCodexModelIDsForPlatform(platform string) []string {
switch platform {
case service.PlatformDeepseek:
return []string{"deepseek-v4-pro", "deepseek-v4-flash"}
return []string{"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-flash"}
case service.PlatformMiniMax:
return []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}
default:
return defaultModelIDsForPlatform(platform)
}
@@ -1508,10 +1451,12 @@ func defaultModelIDsForPlatform(platform string) []string {
return claude.DefaultModelIDs()
case service.PlatformGrok:
return xai.DefaultModelIDs()
case service.PlatformOpenCodeGo:
return service.DefaultOpenCodeGoModelIDs()
case service.PlatformComposite:
ids := make([]string, 0)
seen := make(map[string]struct{})
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
if _, ok := seen[id]; ok {
continue
@@ -1551,10 +1496,21 @@ func mergeModelIDs(primary, secondary []string) []string {
// AntigravityModels 返回 Antigravity 支持的全部模型
// GET /antigravity/models
// 分组级模型白名单开启时按白名单过滤。
func (h *GatewayHandler) AntigravityModels(c *gin.Context) {
models := antigravity.DefaultModels()
if apiKey, ok := middleware2.GetAPIKeyFromContext(c); ok && apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
filtered := make([]antigravity.ClaudeModel, 0, len(models))
for _, model := range models {
if apiKey.Group.ModelAllowlist.Allows(model.ID) {
filtered = append(filtered, model)
}
}
models = filtered
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": antigravity.DefaultModels(),
"data": models,
})
}
@@ -89,6 +89,10 @@ func claudeCodeBodyMapFromParsedRequest(parsedReq *service.ParsedRequest) map[st
bodyMap := map[string]any{
"model": parsedReq.Model,
}
// 探测识别(max_tokens=1)需要看到该字段,复用已解析请求时一并带上。
if parsedReq.MaxTokens > 0 {
bodyMap["max_tokens"] = parsedReq.MaxTokens
}
if parsedReq.HasSystem {
if system, ok := parsedReq.SystemValue(); ok {
bodyMap["system"] = system
@@ -500,3 +500,19 @@ type helperConcurrencyCacheStubWithError struct {
func (s *helperConcurrencyCacheStubWithError) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
return false, s.err
}
func TestSetClaudeCodeClientContext_ParsedRequestProbeWithoutSystemPrompt(t *testing.T) {
c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
c.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
// The hot path reuses ParsedRequest instead of re-parsing the body; the probe
// marker must survive that projection or the exemption only works cold.
parsed := &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 1}
SetClaudeCodeClientContext(c, nil, parsed)
require.True(t, service.IsClaudeCodeClient(c.Request.Context()))
c2, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
c2.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
SetClaudeCodeClientContext(c2, nil, &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 64})
require.False(t, service.IsClaudeCodeClient(c2.Request.Context()))
}
@@ -0,0 +1,245 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// 通配条目在 /v1/models 中展开为候选来源中所有匹配项,保持来源顺序。
func TestGatewayModels_ModelAllowlistWildcardExpandsAgainstSource(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(31)
h := newGatewayModelsHandlerForTest(
&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {
{
ID: 1,
Platform: service.PlatformOpenAI,
Credentials: map[string]any{
"model_mapping": map[string]any{
"gpt-5.4": "gpt-5.4",
"gpt-5.5-codex": "gpt-5.5-codex",
"gpt-5.5-mini": "gpt-5.5-mini",
"other-foo": "other-foo",
},
},
},
},
},
},
)
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.PlatformOpenAI,
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gpt-5.5-*", "gpt-5.4"},
},
},
})
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{"gpt-5.5-codex", "gpt-5.5-mini", "gpt-5.4"}, modelIDsForTest(got.Data))
}
// geminiAllowlistAccountRepoStub 在 gatewayModelsAccountRepoStub 之上补充
// Gemini 兼容层用到的按平台过滤查询(分组内无任何账号,触发 fallback 列表)。
type geminiAllowlistAccountRepoStub struct {
gatewayModelsAccountRepoStub
}
func (s *geminiAllowlistAccountRepoStub) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]service.Account, error) {
allowed := make(map[string]struct{}, len(platforms))
for _, platform := range platforms {
allowed[platform] = struct{}{}
}
accounts := s.byGroup[groupID]
filtered := make([]service.Account, 0, len(accounts))
for _, account := range accounts {
if _, ok := allowed[account.Platform]; ok {
filtered = append(filtered, account)
}
}
return filtered, nil
}
// Gemini 原生 /v1beta/models:白名单开启时过滤 fallback 列表的 models[].name。
func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
// 只有 antigravity 账号:Gemini 选号失败后回落静态模型列表。
41: {{ID: 2, Platform: service.PlatformAntigravity, Status: service.StatusActive, Schedulable: true}},
},
}}
h := &GatewayHandler{
geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
geminiGroupID := int64(41)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
GroupID: &geminiGroupID,
Group: &service.Group{
ID: geminiGroupID,
Platform: service.PlatformGemini,
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gemini-2.5-pro", "gemini-3-*"},
},
},
})
h.GeminiV1BetaListModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
names := make([]string, 0, len(got.Models))
for _, model := range got.Models {
names = append(names, model.Name)
}
// models/ 前缀的候选形式也应命中条目。
require.Contains(t, names, "models/gemini-2.5-pro")
require.Contains(t, names, "models/gemini-3-pro-preview")
require.NotContains(t, names, "models/gemini-2.5-flash")
require.NotContains(t, names, "models/gemini-2.0-flash")
}
// Antigravity /antigravity/models:白名单开启时按条目过滤静态列表。
func TestAntigravityModels_FiltersByAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &GatewayHandler{}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/antigravity/models", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{
ID: 51,
Platform: service.PlatformAntigravity,
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gemini-2.5-flash"},
},
},
})
h.AntigravityModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got struct {
Object string `json:"object"`
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, "list", got.Object)
// -thinking 后缀的宽容规则:gemini-2.5-flash-thinking 也命中 gemini-2.5-flash 条目。
require.Len(t, got.Data, 2)
require.Equal(t, "gemini-2.5-flash", got.Data[0].ID)
require.Equal(t, "gemini-2.5-flash-thinking", got.Data[1].ID)
}
func TestFilterUpstreamGeminiModelsBody(t *testing.T) {
allowlist := service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-2.5-pro"}}
t.Run("full hit returns original body with dropped=false", func(t *testing.T) {
body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-pro"}]}`)
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
require.True(t, ok)
require.False(t, dropped, "full hit must keep the original response so headers pass through")
require.Equal(t, string(body), string(filtered))
})
t.Run("partial hit filters models and preserves envelope fields", func(t *testing.T) {
body := []byte(`{"nextPageToken":"tok","models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-flash"}],"other":"kept"}`)
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
require.True(t, ok)
require.True(t, dropped)
require.JSONEq(t, `{"nextPageToken":"tok","other":"kept","models":[{"name":"models/gemini-2.5-pro"}]}`, string(filtered))
})
t.Run("models prefix stripped candidate form matches entry", func(t *testing.T) {
body := []byte(`{"models":[{"name":"gemini-2.5-pro"}]}`)
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
require.True(t, ok)
require.False(t, dropped)
require.Equal(t, string(body), string(filtered))
})
t.Run("missing models field passes through", func(t *testing.T) {
body := []byte(`{"error":"odd upstream"}`)
_, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
require.True(t, ok)
require.False(t, dropped)
})
t.Run("invalid JSON signals passthrough", func(t *testing.T) {
_, dropped, ok := filterUpstreamGeminiModelsBody([]byte(`not-json`), allowlist)
require.False(t, ok)
require.False(t, dropped)
})
}
func TestFilterBatchImageModelsByAllowlist(t *testing.T) {
models := []service.BatchImagePublicModel{
{ID: "gemini-2.5-flash-image", Object: "image.batch.model", Provider: "gemini_api"},
{ID: "gemini-3-pro-image", Object: "image.batch.model", Provider: "vertex"},
{ID: "gpt-image-1", Object: "image.batch.model", Provider: "openai"},
}
t.Run("disabled allowlist keeps everything", func(t *testing.T) {
got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{Enabled: false})
require.Equal(t, models, got)
})
t.Run("exact and wildcard entries filter by model id", func(t *testing.T) {
got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gemini-3-*", "gpt-image-1"},
})
require.Equal(t, []string{"gemini-3-pro-image", "gpt-image-1"}, []string{got[0].ID, got[1].ID})
require.Len(t, got, 2)
})
t.Run("no match yields empty list", func(t *testing.T) {
got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{
Enabled: true,
Models: []string{"claude-*"},
})
require.Empty(t, got)
})
}
@@ -0,0 +1,114 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func requestModelForTest(h *GatewayHandler, group *service.Group, modelID, etag string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
c.Request.Header.Set("If-None-Match", etag)
if modelID != "" {
c.Params = gin.Params{{Key: "model", Value: modelID}}
}
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group})
h.Models(c)
return rec
}
func TestRetrieveModelMatchesVisibleCatalogue(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, platform := range []string{service.PlatformOpenAI, service.PlatformAnthropic, service.PlatformGemini, service.PlatformGrok, service.PlatformComposite} {
for _, mapped := range []bool{false, true} {
name := platform + "/fallback"
if mapped {
name = platform + "/mapped"
}
t.Run(name, func(t *testing.T) {
accountPlatform := platform
if platform == service.PlatformComposite {
accountPlatform = service.PlatformOpenAI
}
credentials := map[string]any{}
if mapped {
credentials["model_mapping"] = map[string]any{"custom-model": "upstream-only-model"}
}
group := &service.Group{ID: 71, Platform: platform}
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{
group.ID: {{ID: 1, Platform: accountPlatform, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, Credentials: credentials}},
}})
list := requestModelForTest(h, group, "", "")
require.Equal(t, http.StatusOK, list.Code, list.Body.String())
var catalog struct {
Data []json.RawMessage `json:"data"`
}
require.NoError(t, json.Unmarshal(list.Body.Bytes(), &catalog))
require.NotEmpty(t, catalog.Data)
var model struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(catalog.Data[0], &model))
retrieved := requestModelForTest(h, group, model.ID, "")
require.Equal(t, http.StatusOK, retrieved.Code, retrieved.Body.String())
require.JSONEq(t, string(catalog.Data[0]), retrieved.Body.String())
require.Equal(t, http.StatusNotFound, requestModelForTest(h, group, "unknown-model", "").Code)
group.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{"absent-from-source"}}
hidden := requestModelForTest(h, group, model.ID, "")
require.Equal(t, http.StatusNotFound, hidden.Code, hidden.Body.String())
require.Contains(t, hidden.Body.String(), `"code":"model_not_found"`)
})
}
}
}
func TestRetrievePinnedModelPreservesMetadataFilteringAndErrors(t *testing.T) {
for _, tc := range []struct {
name string
status int
selected []string
wantStatus int
}{
{name: "metadata", wantStatus: http.StatusOK},
{name: "hidden", selected: []string{"other-model"}, wantStatus: http.StatusNotFound},
{name: "upstream failure", status: http.StatusServiceUnavailable, wantStatus: http.StatusBadGateway},
} {
t.Run(tc.name, func(t *testing.T) {
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
2: `{"data":[{"id":"special-model","ID":"not-a-model-id","owned_by":"source-owner","created":123,"extra":{"context":999}}]}`,
}, statuses: map[int64]int{}}
if tc.status != 0 {
upstream.statuses[2] = tc.status
}
codex := newPinnedCodexTestHandler([]service.Account{newPinnedCodexAccount(2, service.StatusActive, true, false)}, upstream, 3)
h := &GatewayHandler{openAIGatewayService: codex.gatewayService, maxAccountSwitches: 3}
group := &service.Group{ID: 72, Platform: service.PlatformOpenAI,
ModelAllowlist: service.GroupModelAllowlist{Enabled: len(tc.selected) > 0, Models: tc.selected},
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2}}}
got := requestModelForTest(h, group, "special-model", "collection-etag")
require.Equal(t, tc.wantStatus, got.Code, got.Body.String())
if tc.wantStatus == http.StatusOK {
list := requestModelForTest(h, group, "", "")
var catalog struct {
Data []json.RawMessage `json:"data"`
}
require.NoError(t, json.Unmarshal(list.Body.Bytes(), &catalog))
require.JSONEq(t, string(catalog.Data[0]), got.Body.String())
require.Contains(t, got.Body.String(), `"owned_by":"source-owner"`)
require.Contains(t, got.Body.String(), `"created":123`)
again := requestModelForTest(h, group, "special-model", list.Header().Get("ETag"))
require.Equal(t, http.StatusOK, again.Code)
require.Empty(t, again.Header().Get("ETag"))
require.Equal(t, http.StatusNotFound, requestModelForTest(h, group, "not-a-model-id", "").Code)
}
})
}
}
+126 -18
View File
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -181,7 +182,7 @@ func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
}
}
// Scenario: Composite manifests aggregate only administrator-configured models.
// Composite manifests include defaults from unmapped accounts and explicit mappings.
func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) {
gin.SetMode(gin.TestMode)
const groupID int64 = 120
@@ -225,7 +226,105 @@ func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T
require.Equal(t, http.StatusOK, rec.Code)
var got codexModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models))
want := service.FilterCodexModelIDsForGroup(openai.DefaultModelIDs(), nil)
require.ElementsMatch(t, append(want, "grok-4.6"), codexModelSlugsForTest(got.Models))
}
func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T) {
gin.SetMode(gin.TestMode)
const groupID int64 = 28
const sparkModel = "gpt-5.3-codex-spark"
const alias = "team-coder"
parentID := int64(1)
accounts := []service.Account{
{ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth},
{
ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
ParentAccountID: &parentID, QuotaDimension: "spark",
Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}},
},
{
ID: 3, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"model_mapping": map[string]any{alias: "gpt-5.6-sol"}},
},
}
tests := []struct {
name string
accounts []service.Account
config service.GroupModelAllowlist
want []string
}{
{
name: "unmapped parent and Spark shadow retain defaults and aliases",
accounts: accounts,
want: append(openai.DefaultModelIDs(), alias),
},
{
name: "unmapped API key account also contributes defaults",
accounts: append([]service.Account{{ID: 4, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}}, accounts[1:]...),
want: append(openai.DefaultModelIDs(), alias),
},
{
name: "unmapped accounts alone retain default response shape",
accounts: accounts[:1],
want: openai.DefaultModelIDs(),
},
{
name: "custom list can select defaults and aliases",
accounts: accounts,
config: service.GroupModelAllowlist{Enabled: true, Models: []string{alias, "gpt-5.6-sol", sparkModel, "unknown-model"}},
want: []string{alias, "gpt-5.6-sol", sparkModel},
},
{
name: "unavailable custom selection remains empty",
accounts: accounts,
config: service.GroupModelAllowlist{Enabled: true, Models: []string{"unknown-model"}},
want: []string{},
},
{
name: "mapped accounts alone do not gain defaults",
accounts: accounts[1:],
want: []string{sparkModel, alias},
},
{
name: "unmapped accounts from another platform do not add defaults",
accounts: append([]service.Account{{ID: 4, Platform: service.PlatformAnthropic}}, accounts[1:]...),
want: []string{sparkModel, alias},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{groupID: tt.accounts},
})
for range 2 {
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.PlatformOpenAI, ModelAllowlist: tt.config},
})
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, "list", got.Object)
require.ElementsMatch(t, tt.want, modelIDsForTest(got.Data))
for _, model := range got.Data {
require.Equal(t, "model", model.Object, model.ID)
require.Positive(t, model.Created, model.ID)
require.Equal(t, "openai", model.OwnedBy, model.ID)
require.Empty(t, model.CreatedAt, model.ID)
}
if tt.config.Enabled {
require.Equal(t, tt.want, modelIDsForTest(got.Data))
}
}
})
}
require.Empty(t, accounts[0].GetModelMapping())
require.True(t, accounts[0].IsModelSupported("gpt-future-model"))
require.False(t, accounts[1].IsModelSupported("gpt-5.6-sol"))
}
func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
@@ -267,7 +366,7 @@ func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
require.Equal(t, etag, second.Header().Get("ETag"))
}
// Scenario: group models_list_config limits the generated Codex manifest.
// Scenario: group model_allowlist limits the generated Codex manifest.
func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) {
gin.SetMode(gin.TestMode)
const groupID int64 = 121
@@ -299,7 +398,7 @@ func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.
Group: &service.Group{
ID: groupID,
Platform: service.PlatformComposite,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"grok-4.6"},
},
@@ -572,7 +671,7 @@ func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T)
Group: &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: false,
Models: []string{"gpt-5.5"},
},
@@ -619,7 +718,7 @@ func TestGatewayModels_CustomModelsListFiltersAndOrdersMappedModels(t *testing.T
Group: &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gpt-5.5", "missing-model", "gpt-5.4"},
},
@@ -692,6 +791,13 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
"model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"},
},
},
{
ID: 7,
Platform: service.PlatformMiniMax,
Credentials: map[string]any{
"model_mapping": map[string]any{"minimax-custom": "MiniMax-M3"},
},
},
},
},
},
@@ -704,9 +810,9 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
Group: &service.Group{
ID: groupID,
Platform: service.PlatformComposite,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"},
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"},
},
},
})
@@ -717,7 +823,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, modelIDsForTest(got.Data))
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"}, modelIDsForTest(got.Data))
}
func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) {
@@ -770,6 +876,7 @@ func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testin
{ID: 2, Platform: service.PlatformKimi},
{ID: 3, Platform: service.PlatformZhipu},
{ID: 4, Platform: service.PlatformDeepseek},
{ID: 5, Platform: service.PlatformMiniMax},
},
},
},
@@ -801,13 +908,14 @@ func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T)
for _, model := range claude.DefaultModels {
want = append(want, model.ID)
}
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
require.Equal(t, want, defaultModelIDsForPlatform(platform), "platform=%s", platform)
}
}
func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) {
require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
require.Equal(t, []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}, defaultCodexModelIDsForPlatform(service.PlatformMiniMax))
require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
}
@@ -918,7 +1026,7 @@ func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMappin
Group: &service.Group{
ID: groupID,
Platform: service.PlatformAnthropic,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"claude-sonnet-4-6"},
},
@@ -969,7 +1077,7 @@ func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeAndMappedDeep
Group: &service.Group{
ID: groupID,
Platform: service.PlatformAnthropic,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"},
},
@@ -1020,7 +1128,7 @@ func TestGatewayModels_AnthropicCustomModelsListDisabledKeepsMappedModelList(t *
Group: &service.Group{
ID: groupID,
Platform: service.PlatformAnthropic,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: false,
Models: []string{"claude-fable-5", "deepseek-v4-pro"},
},
@@ -1061,7 +1169,7 @@ func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeWithoutMappin
Group: &service.Group{
ID: groupID,
Platform: service.PlatformAnthropic,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"},
},
@@ -1106,7 +1214,7 @@ func TestGatewayModels_CustomModelsListCanReturnEmptyWhenSelectionsUnavailable(t
Group: &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gpt-5.5"},
},
@@ -1143,7 +1251,7 @@ func TestGatewayModels_CustomModelsListFiltersDefaultFallbackModels(t *testing.T
Group: &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gpt-5.5", "legacy-gpt-2024", "gpt-5.4"},
},
@@ -1180,7 +1288,7 @@ func TestGatewayModels_OpenAICustomModelsListKeepsOpenAIResponseShapeForDefaultF
Group: &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gpt-5.5", "gpt-5.4"},
},
@@ -46,14 +46,34 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
return
}
// 强制 antigravity 模式:返回 antigravity 支持的模型列表
if forcePlatform == service.PlatformAntigravity {
c.JSON(http.StatusOK, antigravity.FallbackGeminiModelsList())
return
// 分组级模型白名单开启时过滤 models[].name(名字形如 models/xxx)。
filterGeminiModels := func(models []gemini.Model) []gemini.Model {
if apiKey.Group == nil || !apiKey.Group.ModelAllowlistEnabled() {
return models
}
filtered := make([]gemini.Model, 0, len(models))
for _, model := range models {
if apiKey.Group.ModelAllowlist.Allows(model.Name) {
filtered = append(filtered, model)
}
}
return filtered
}
if models, ok := customGeminiModelsList(apiKey.Group); ok {
c.JSON(http.StatusOK, models)
// 强制 antigravity 模式:返回 antigravity 支持的模型列表
if forcePlatform == service.PlatformAntigravity {
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
agModels := antigravity.DefaultGeminiModels()
filtered := make([]antigravity.GeminiModel, 0, len(agModels))
for _, model := range agModels {
if apiKey.Group.ModelAllowlist.Allows(model.Name) {
filtered = append(filtered, model)
}
}
c.JSON(http.StatusOK, antigravity.GeminiModelsListResponse{Models: filtered})
return
}
c.JSON(http.StatusOK, antigravity.FallbackGeminiModelsList())
return
}
@@ -63,7 +83,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID)
if hasAntigravity {
// antigravity 账户使用静态模型列表
c.JSON(http.StatusOK, gemini.FallbackModelsList())
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
return
}
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
@@ -77,21 +97,64 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
return
}
if shouldFallbackGeminiModels(res) {
c.JSON(http.StatusOK, gemini.FallbackModelsList())
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
return
}
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
if filtered, dropped, ok := filterUpstreamGeminiModelsBody(res.Body, apiKey.Group.ModelAllowlist); ok && dropped {
// 只在确有条目被过滤时替换响应体;全命中或解析失败时保持原始响应,
// 统一经 writeUpstreamResponse 写出(保留全部上游响应头)。
res.Body = filtered
}
}
writeUpstreamResponse(c, res)
}
func customGeminiModelsList(group *service.Group) (gemini.ModelsListResponse, bool) {
if group == nil || !group.CustomModelsListEnabled() {
return gemini.ModelsListResponse{}, false
// filterUpstreamGeminiModelsBody 按白名单过滤上游 /v1beta/models 响应中的
// models[].name,其余信封字段(如 nextPageToken)原样保留。
// 返回值:filtered 为过滤后的响应体;dropped 表示是否有条目被移除(全命中时
// 为 false,调用方应保持原始响应以完整透传上游头);ok=false 表示解析失败,
// 调用方同样应透传原始响应。
func filterUpstreamGeminiModelsBody(body []byte, allowlist service.GroupModelAllowlist) (filtered []byte, dropped bool, ok bool) {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, false, false
}
models := make([]gemini.Model, 0, len(group.ModelsListConfig.Models))
for _, modelID := range group.ModelsListConfig.Models {
models = append(models, gemini.FallbackModel(modelID))
rawModels, hasModels := envelope["models"]
if !hasModels {
return body, false, true
}
return gemini.ModelsListResponse{Models: models}, true
type geminiModelName struct {
Name string `json:"name"`
}
var models []json.RawMessage
if err := json.Unmarshal(rawModels, &models); err != nil {
return nil, false, false
}
kept := make([]json.RawMessage, 0, len(models))
for _, raw := range models {
var model geminiModelName
if err := json.Unmarshal(raw, &model); err != nil {
return nil, false, false
}
if allowlist.Allows(model.Name) {
kept = append(kept, raw)
}
}
if len(kept) == len(models) {
// 全部命中时直接透传原始响应体。
return body, false, true
}
mergedModels, err := json.Marshal(kept)
if err != nil {
return nil, false, false
}
envelope["models"] = mergedModels
merged, err := json.Marshal(envelope)
if err != nil {
return nil, false, false
}
return merged, true, true
}
// GeminiV1BetaGetModel proxies:
@@ -9,40 +9,21 @@ import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/gemini"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestGeminiV1BetaListModels_CustomGroupListUsesNativeResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{
Platform: service.PlatformGemini,
ModelsListConfig: service.GroupModelsListConfig{
Enabled: true,
Models: []string{"gemini-2.5-pro", "models/gemini-custom"},
},
},
})
(&GatewayHandler{}).GeminiV1BetaListModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got gemini.ModelsListResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []gemini.Model{
gemini.FallbackModel("gemini-2.5-pro"),
gemini.FallbackModel("models/gemini-custom"),
}, got.Models)
func TestGeminiV1BetaListModels_AllowlistFiltersNativeResponse(t *testing.T) {
body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-flash"}],"nextPageToken":"next"}`)
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-2.5-pro"}})
require.True(t, ok)
require.True(t, dropped)
require.JSONEq(t, `{"models":[{"name":"models/gemini-2.5-pro"}],"nextPageToken":"next"}`, string(filtered))
}
func TestGeminiV1BetaListModels_ForcedAntigravityIgnoresCustomGroupList(t *testing.T) {
func TestGeminiV1BetaListModels_ForcedAntigravityAppliesAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -50,7 +31,7 @@ func TestGeminiV1BetaListModels_ForcedAntigravityIgnoresCustomGroupList(t *testi
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{
Platform: service.PlatformGemini,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gemini-custom"},
},
@@ -63,19 +44,15 @@ func TestGeminiV1BetaListModels_ForcedAntigravityIgnoresCustomGroupList(t *testi
require.Equal(t, http.StatusOK, rec.Code)
var got antigravity.GeminiModelsListResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, antigravity.FallbackGeminiModelsList(), got)
require.Empty(t, got.Models)
}
func TestCustomGeminiModelsList_DisabledKeepsExistingFlow(t *testing.T) {
group := &service.Group{
ModelsListConfig: service.GroupModelsListConfig{
Enabled: false,
Models: []string{"gemini-2.5-pro"},
},
}
_, ok := customGeminiModelsList(group)
require.False(t, ok)
func TestGeminiModelAllowlist_DisabledPreservesNativeResponse(t *testing.T) {
body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"}]}`)
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, service.GroupModelAllowlist{Enabled: false, Models: []string{"other"}})
require.True(t, ok)
require.False(t, dropped)
require.Equal(t, body, filtered)
}
// TestGeminiV1BetaHandler_PlatformRoutingInvariant 文档化并验证 Handler 层的平台路由逻辑不变量
+56 -20
View File
@@ -198,30 +198,57 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
}
routingStart := time.Now()
requiredCapability := grokMediaRequiredCapability(endpoint)
var accountReleaseFunc func()
releaseAccount := func() {
if accountReleaseFunc != nil {
accountReleaseFunc()
accountReleaseFunc = nil
}
}
defer releaseAccount()
for {
releaseAccount()
if failoverClientGone(c) {
return
}
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
requestCtx,
apiKey.GroupID,
"",
sessionHash,
routingModel,
failedAccountIDs,
service.OpenAIUpstreamTransportHTTPSSE,
requiredCapability,
false,
false,
false,
service.PlatformGrok,
)
var selection *service.AccountSelectionResult
var scheduleDecision service.OpenAIAccountScheduleDecision
if boundLookupAccountID > 0 {
selection, scheduleDecision, err = h.gatewayService.SelectGrokMediaVideoRequestAccount(
requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel,
)
} else {
selection, scheduleDecision, err = h.gatewayService.SelectAccountWithSchedulerForCapability(
requestCtx,
apiKey.GroupID,
"",
sessionHash,
routingModel,
failedAccountIDs,
service.OpenAIUpstreamTransportHTTPSSE,
requiredCapability,
false,
false,
false,
service.PlatformGrok,
)
}
// Own an eagerly acquired slot before any rejection or eligibility probe.
// Forwarding takes over the same once-only release after admission.
if selection != nil && selection.Acquired {
selection.ReleaseFunc = wrapReleaseOnDone(requestCtx, selection.ReleaseFunc)
accountReleaseFunc = selection.ReleaseFunc
}
if err != nil {
if failoverClientGone(c) {
reqLog.Info("grok_media.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if boundLookupAccountID > 0 && errors.Is(err, service.ErrNoAvailableAccounts) {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Video request not found")
return
}
reqLog.Warn("grok_media.account_select_failed",
zap.Error(err),
zap.Int("excluded_account_count", len(failedAccountIDs)),
@@ -260,6 +287,9 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
return
}
if failoverClientGone(c) {
return
}
if boundLookupAccountID > 0 && selection.Account.ID != boundLookupAccountID {
reqLog.Warn("grok_media.video_lookup_bound_account_unavailable",
zap.Int64("bound_account_id", boundLookupAccountID),
@@ -282,6 +312,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
if endpoint.IsGenerationRequest() {
eligible, eligibilityReason, eligibilityErr := h.ensureGrokMediaAccountEligibility(requestCtx, account)
if !eligible {
releaseAccount()
mediaEligibilityRejected = true
failedAccountIDs[account.ID] = struct{}{}
reqLog.Warn("grok_media.account_eligibility_rejected",
@@ -298,10 +329,19 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
continue
}
}
if failoverClientGone(c) {
return
}
sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account)
setOpsSelectedAccount(c, account.ID, account.Platform)
accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog)
admissionSessionHash := sessionHash
if boundLookupAccountID > 0 {
// Waiting must not replace the video ownership TTL with text sticky TTL.
admissionSessionHash = ""
}
var slotResult openAISlotAcquireResult
accountReleaseFunc, slotResult = h.acquireResponsesAccountSlot(c, apiKey.GroupID, admissionSessionHash, selection, false, &streamStarted, reqLog)
if slotResult == openAISlotAcquireProfitVetoed {
// 媒体路径已显式豁免利润门(suppress 标记),此分支仅防御性兜底,
// 同样受否决上限约束。
@@ -319,11 +359,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
forwardStart := time.Now()
writerSizeBeforeForward := c.Writer.Size()
result, err := func() (*service.OpenAIForwardResult, error) {
defer func() {
if accountReleaseFunc != nil {
accountReleaseFunc()
}
}()
defer releaseAccount()
return h.gatewayService.ForwardGrokMedia(requestCtx, c, account, endpoint, requestID, body, contentType)
}()
@@ -0,0 +1,405 @@
//go:build unit
package handler
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/testutil"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
type grokMediaSlotsCache struct {
testutil.StubConcurrencyCache
mu sync.Mutex
accounts map[string]int64
users map[string]int64
acquired, released, userAcquired, userReleased int
waiting, denied int
full bool
queueFull bool
onWait func()
duplicateRelease bool
peak int
}
func (s *grokMediaSlotsCache) AcquireAccountSlot(_ context.Context, id int64, _ int, request string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.full || s.denied > 0 {
if s.denied > 0 {
s.denied--
}
return false, nil
}
s.accounts[request] = id
s.acquired++
if len(s.accounts) > s.peak {
s.peak = len(s.accounts)
}
return true, nil
}
func (s *grokMediaSlotsCache) ReleaseAccountSlot(_ context.Context, _ int64, request string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.accounts[request]; !ok {
s.duplicateRelease = true
}
delete(s.accounts, request)
s.released++
return nil
}
func (s *grokMediaSlotsCache) AcquireUserSlot(_ context.Context, id int64, _ int, request string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.users[request] = id
s.userAcquired++
return true, nil
}
func (s *grokMediaSlotsCache) ReleaseUserSlot(_ context.Context, _ int64, request string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.users[request]; !ok {
s.duplicateRelease = true
}
delete(s.users, request)
s.userReleased++
return nil
}
func (s *grokMediaSlotsCache) IncrementAccountWaitCount(context.Context, int64, int) (bool, error) {
s.mu.Lock()
if s.queueFull {
s.mu.Unlock()
return false, nil
}
s.waiting++
onWait := s.onWait
s.mu.Unlock()
if onWait != nil {
onWait()
}
return true, nil
}
func (s *grokMediaSlotsCache) DecrementAccountWaitCount(context.Context, int64) error {
s.mu.Lock()
defer s.mu.Unlock()
s.waiting--
return nil
}
func (s *grokMediaSlotsCache) assertReleased(t *testing.T) {
t.Helper()
s.mu.Lock()
defer s.mu.Unlock()
require.Empty(t, s.accounts)
require.Empty(t, s.users)
require.Equal(t, s.acquired, s.released)
require.Equal(t, s.userAcquired, s.userReleased)
require.False(t, s.duplicateRelease)
require.Zero(t, s.waiting)
}
type grokMediaSlotBindings struct {
testutil.StubGatewayCache
owner int64
writes int
key string
billed map[string]bool
}
func (s *grokMediaSlotBindings) GetSessionAccountID(_ context.Context, groupID int64, key string) (int64, error) {
if groupID != 24 || key != s.key {
return 0, service.ErrStickySessionNotFound
}
return s.owner, nil
}
func (s *grokMediaSlotBindings) SetSessionAccountID(_ context.Context, _ int64, key string, owner int64, _ time.Duration) error {
s.key, s.owner = key, owner
s.writes++
return nil
}
func (s *grokMediaSlotBindings) RefreshSessionTTL(context.Context, int64, string, time.Duration) error {
s.writes++
return nil
}
func (s *grokMediaSlotBindings) DeleteSessionAccountID(context.Context, int64, string) error {
s.writes++
return nil
}
func (s *grokMediaSlotBindings) ClaimGrokVideoBilled(_ context.Context, key string, _ time.Duration) (bool, error) {
if s.billed == nil {
s.billed = make(map[string]bool)
}
if s.billed[key] {
return false, nil
}
s.billed[key] = true
return true, nil
}
type grokMediaSlotRepo struct {
openAIImagesFailoverAccountRepo
mismatch bool
}
func (r grokMediaSlotRepo) GetByID(ctx context.Context, id int64) (*service.Account, error) {
if r.mismatch {
return r.openAIImagesFailoverAccountRepo.GetByID(ctx, 2)
}
return r.openAIImagesFailoverAccountRepo.GetByID(ctx, id)
}
type grokMediaSlotUpstream struct {
service.HTTPUpstream
call func(*http.Request, int64) (*http.Response, error)
calls int
}
func (u *grokMediaSlotUpstream) Do(req *http.Request, _ string, id int64, _ int) (*http.Response, error) {
u.calls++
return u.call(req, id)
}
type grokMediaSlotProber func(context.Context, int64) (bool, string, error)
func (p grokMediaSlotProber) ProbeMediaEligibility(ctx context.Context, id int64) (bool, string, error) {
return p(ctx, id)
}
func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool) (*OpenAIGatewayHandler, *grokMediaSlotsCache, *grokMediaSlotBindings, *grokMediaSlotUpstream) {
t.Helper()
gin.SetMode(gin.TestMode)
accounts := make([]service.Account, 3)
for i := range accounts {
accounts[i] = service.Account{ID: int64(i + 1), Platform: service.PlatformGrok, Type: service.AccountTypeAPIKey,
Status: service.StatusActive, Schedulable: true, Concurrency: 50, Priority: i,
GroupIDs: []int64{24}, Credentials: map[string]any{"api_key": "test-key", "access_token": "test-token"}}
if oauth {
accounts[i].Type = service.AccountTypeOAuth
accounts[i].Credentials["refresh_token"] = "test-refresh"
accounts[i].Credentials["expires_at"] = time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339)
}
}
slots := &grokMediaSlotsCache{accounts: map[string]int64{}, users: map[string]int64{}}
concurrency := service.NewConcurrencyService(slots)
bindings := &grokMediaSlotBindings{owner: 1}
upstream := &grokMediaSlotUpstream{call: func(*http.Request, int64) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"request_id":"task","status":"pending"}`))}, nil
}}
cfg := &config.Config{RunMode: config.RunModeSimple}
cfg.Gateway.Scheduling.StickySessionWaitTimeout = 20 * time.Millisecond
cfg.Gateway.Scheduling.StickySessionMaxWaiting = 3
cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled = true
repo := grokMediaSlotRepo{openAIImagesFailoverAccountRepo: openAIImagesFailoverAccountRepo{accounts: accounts}, mismatch: mismatch}
provider := service.NewGrokTokenProvider(repo, nil)
if oauth {
_, err := provider.GetAccessToken(context.Background(), &accounts[1])
require.NoError(t, err)
}
gateway := service.NewOpenAIGatewayService(repo, nil, nil, nil, nil, nil, bindings, cfg, nil, concurrency, nil, nil, nil, upstream, nil, nil, provider, nil, nil, nil, nil, nil)
groupID := int64(24)
require.NoError(t, gateway.BindGrokMediaVideoRequestAccount(context.Background(), &groupID, "task", 10, 20, 1))
bindings.writes = 0
billing := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
t.Cleanup(billing.Stop)
handler := NewOpenAIGatewayHandler(gateway, concurrency, billing, service.NewAPIKeyService(nil, nil, nil, nil, nil, nil, cfg), nil, nil, nil, nil, cfg)
return handler, slots, bindings, upstream
}
func grokMediaSlotContext(ctx context.Context, generation bool) (*gin.Context, *httptest.ResponseRecorder) {
groupID := int64(24)
method, path, body := http.MethodGet, "/v1/videos/task", ""
if generation {
method, path, body = http.MethodPost, "/v1/videos/generations", `{"model":"grok-imagine-video","prompt":"test","duration":6}`
}
req := httptest.NewRequest(method, path, strings.NewReader(body)).WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
if !generation {
req.Header.Set("session-id", "unrelated-client-session")
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Params = gin.Params{{Key: "request_id", Value: "task"}}
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ID: 20, UserID: 10, GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformGrok, AllowImageGeneration: true}, User: &service.User{ID: 10}})
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 10, Concurrency: 5})
return c, w
}
func TestGrokMediaLookupSlotLifecycle(t *testing.T) {
for _, scenario := range []string{"normal", "mismatch acquired", "mismatch wait", "full", "queue full", "cancel while waiting", "wait then acquired", "upstream error", "cancel", "panic"} {
t.Run(scenario, func(t *testing.T) {
h, slots, bindings, upstream := newGrokMediaSlotHandler(t, false, strings.HasPrefix(scenario, "mismatch"))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if scenario == "full" || scenario == "mismatch wait" || scenario == "queue full" || scenario == "cancel while waiting" {
slots.full = true
}
if scenario == "queue full" {
slots.queueFull = true
}
if scenario == "cancel while waiting" {
slots.onWait = cancel
}
if scenario == "wait then acquired" {
slots.denied = 1
}
original := upstream.call
upstream.call = func(req *http.Request, id int64) (*http.Response, error) {
require.Equal(t, int64(1), id)
switch scenario {
case "upstream error":
return nil, errors.New("upstream unavailable")
case "cancel":
cancel()
return nil, context.Canceled
case "panic":
panic("test upstream panic")
}
return original(req, id)
}
for range 20 {
c, w := grokMediaSlotContext(ctx, false)
h.GrokVideoStatus(c)
slots.assertReleased(t)
if strings.HasPrefix(scenario, "mismatch") {
require.Equal(t, 404, w.Code)
}
if scenario == "normal" || scenario == "wait then acquired" {
require.Equal(t, 200, w.Code)
}
if scenario == "full" || scenario == "queue full" {
require.Equal(t, http.StatusTooManyRequests, w.Code)
}
}
require.Zero(t, bindings.writes, "lookups must preserve owner and TTL")
if scenario == "mismatch acquired" {
require.Equal(t, 20, slots.released)
}
if slots.full {
require.Zero(t, slots.released)
require.Zero(t, upstream.calls)
}
if scenario == "full" || strings.HasPrefix(scenario, "mismatch") {
require.Zero(t, upstream.calls)
}
switch scenario {
case "normal", "wait then acquired", "upstream error", "panic":
require.Equal(t, 20, upstream.calls)
require.Equal(t, 20, slots.released)
case "cancel":
require.Equal(t, 1, upstream.calls)
require.Equal(t, 1, slots.released)
}
})
}
}
func TestGrokMediaEligibilityReleasesBeforeSwitch(t *testing.T) {
for _, scenario := range []string{"switch", "exhausted", "cancel during probe"} {
t.Run(scenario, func(t *testing.T) {
h, slots, _, upstream := newGrokMediaSlotHandler(t, true, false)
h.maxAccountSwitches = 1
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
probes := 0
h.grokMediaEligibilityProber = grokMediaSlotProber(func(context.Context, int64) (bool, string, error) {
probes++
if scenario == "cancel during probe" {
cancel()
require.Eventually(t, func() bool { slots.mu.Lock(); defer slots.mu.Unlock(); return len(slots.accounts) == 0 }, time.Second, time.Millisecond)
}
if scenario == "switch" && probes == 2 {
return true, "eligible", nil
}
return false, "billing_unobserved", errors.New("probe failed")
})
c, w := grokMediaSlotContext(ctx, true)
h.GrokVideoGeneration(c)
slots.assertReleased(t)
require.Equal(t, 1, slots.peak, "rejected attempts must release before the next selection")
if scenario == "switch" {
events, _ := c.Get(service.OpsUpstreamErrorsKey)
detail, err := json.Marshal(events)
require.NoError(t, err)
require.Equal(t, 200, w.Code, "body=%s probes=%d upstream=%d events=%s", w.Body.String(), probes, upstream.calls, detail)
require.Equal(t, 1, upstream.calls)
}
if scenario == "exhausted" {
require.Equal(t, 503, w.Code)
require.Equal(t, 2, slots.released)
require.Zero(t, upstream.calls)
}
})
}
}
func TestGrokMediaVideoLookupOwnerIsolation(t *testing.T) {
for _, other := range []string{"user", "api key", "group", "task"} {
t.Run(other, func(t *testing.T) {
h, slots, bindings, upstream := newGrokMediaSlotHandler(t, false, false)
c, w := grokMediaSlotContext(context.Background(), false)
key, ok := middleware2.GetAPIKeyFromContext(c)
require.True(t, ok)
switch other {
case "user":
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11, Concurrency: 5})
case "api key":
key.ID = 21
case "group":
groupID := int64(25)
key.GroupID = &groupID
case "task":
c.Params = gin.Params{{Key: "request_id", Value: "other-task"}}
}
h.GrokVideoStatus(c)
require.Equal(t, http.StatusNotFound, w.Code)
slots.assertReleased(t)
require.Zero(t, slots.acquired)
require.Zero(t, upstream.calls)
require.Zero(t, bindings.writes)
})
}
}
func TestGrokMediaVideoCompletionStillClaimsBillingOnce(t *testing.T) {
h, _, bindings, _ := newGrokMediaSlotHandler(t, false, false)
c, _ := grokMediaSlotContext(context.Background(), false)
key, ok := middleware2.GetAPIKeyFromContext(c)
require.True(t, ok)
subject := middleware2.AuthSubject{UserID: 10, Concurrency: 5}
result := &service.OpenAIForwardResult{ResponseID: "task", Model: "grok-imagine-video",
VideoCount: 1, VideoDurationSeconds: 6}
for i := range 20 {
bill := prepareGrokVideoCompletionBilling(c.Request.Context(), h, zap.NewNop(), key, subject, "task", result)
if i == 0 {
require.NotNil(t, bill)
require.Equal(t, service.StableGrokVideoBillingRequestID("task"), bill.RequestID)
} else {
require.Nil(t, bill)
}
}
require.Len(t, bindings.billed, 1)
}
@@ -15,7 +15,7 @@ import (
// 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则
// 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同):
// - 匿名:仅非专属分组(订阅型照常展示);
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性);
// - 登录:非专属分组 + user_allowed_groups 授权或持有有效订阅的专属分组;
// 若该用户开启了公开分组限制,则公开分组同样需要落在授权集合内。
type ModelPlazaHandler struct {
plazaService *service.ModelPlazaService
@@ -160,7 +160,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
}
// filterPlazaVisibleGroups 按登录态裁剪分组可见性。
// allowedGroups == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。
// allowedGroups == nil 表示匿名(仅非专属);非 nil 包含普通授权及有效订阅分组。
// restrictPublicGroups 为 true 时,公开分组也必须落在 allowedGroups 内,否则用户会
// 在广场看到自己实际绑定不了的分组。
func filterPlazaVisibleGroups(
@@ -244,3 +244,17 @@ func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) {
weekdaysTP := weekdaysModel["time_pricing"].(map[string]any)
require.Equal(t, true, weekdaysTP["weekdays_only"])
}
func TestFilterPlazaVisibleGroups_SubscribedExclusiveGroup(t *testing.T) {
groups := []service.PlazaGroup{
{ID: 42, IsExclusive: true, SubscriptionType: "subscription"},
{ID: 43, IsExclusive: true, SubscriptionType: "subscription"},
{ID: 44, IsExclusive: true, SubscriptionType: "standard"},
}
require.Empty(t, filterPlazaVisibleGroups(groups, nil, false))
for _, restricted := range []bool{false, true} {
visible := filterPlazaVisibleGroups(groups, map[int64]struct{}{42: {}}, restricted)
require.Len(t, visible, 1)
require.Equal(t, int64(42), visible[0].ID)
}
}
@@ -166,3 +166,44 @@ func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testi
require.False(t, changedAgain)
require.Equal(t, got, again)
}
func TestNormalizeCodexAutomationBootstrapFullHeartbeat(t *testing.T) {
const output = `<heartbeat>
<automation_id>flutter</automation_id>
<current_time_iso>2026-09-09T00:33:34.775Z</current_time_iso>
<instructions>
Read the reference index and report changes. Preserve A &amp; B and &lt;tags&gt;.
</instructions>
</heartbeat>`
body := codexAutomationBootstrapBody(t, output, "")
got, changed := normalizeCodexAutomationBootstrap(body)
require.True(t, changed)
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String())
again, changedAgain := normalizeCodexAutomationBootstrap(got)
require.False(t, changedAgain)
require.Equal(t, got, again)
for _, field := range []string{"automation_id", "current_time_iso", "instructions"} {
duplicate := strings.Replace(output, "</heartbeat>", "<"+field+">duplicate</"+field+"></heartbeat>", 1)
require.False(t, validCodexAutomationHeartbeat(duplicate), field)
}
for _, invalid := range []string{
strings.Replace(output, "2026-09-09T00:33:34.775Z", "yesterday", 1),
strings.Replace(output, "<current_time_iso>2026-09-09T00:33:34.775Z</current_time_iso>", "", 1),
strings.Replace(output, "Read the reference index and report changes. Preserve A &amp; B and &lt;tags&gt;.", " ", 1),
strings.Replace(output, "<instructions>", `<instructions source="other">`, 1),
strings.Replace(output, "<instructions>", "<instructions><nested/>", 1),
} {
require.False(t, validCodexAutomationHeartbeat(invalid))
}
for _, guarded := range [][]byte{
codexAutomationBootstrapBody(t, output, `,"call_id":"call-1"`),
[]byte(strings.Replace(string(body), `"model":"gpt-5"`, `"model":"gpt-5","previous_response_id":"resp-1"`, 1)),
[]byte(strings.Replace(string(body), `"namespace":"codex_app"`, `"namespace":"other"`, 1)),
} {
unchanged, normalized := normalizeCodexAutomationBootstrap(guarded)
require.False(t, normalized)
require.Equal(t, guarded, unchanged)
}
}
@@ -0,0 +1,25 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestOpenAIEnsureForwardErrorResponse_SkipsCanceledClient(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
ctx, cancel := context.WithCancel(context.Background())
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil).WithContext(ctx)
cancel()
h := &OpenAIGatewayHandler{}
require.False(t, h.ensureForwardErrorResponse(c, true))
require.Equal(t, statusClientClosedRequest, c.Writer.Status())
require.Empty(t, recorder.Body.String())
}
@@ -16,7 +16,8 @@ import (
// Codex CLI and the Codex desktop app refresh their model picker from
// GET {base_url}/models?client_version=... (custom provider mode) or
// GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land
// here. Groups with explicit account model mappings are generated locally;
// here. Pinned discovery takes precedence over local account model mappings;
// when disabled, groups with explicit mappings are generated locally;
// otherwise ChatGPT manifests are proxied verbatim and custom API key manifests
// receive provider-compatibility normalization plus short-lived caching.
func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
@@ -34,28 +35,10 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
}
ifNoneMatch := c.GetHeader("If-None-Match")
configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest(
c.Request.Context(),
apiKey.Group,
ifNoneMatch,
)
if err != nil {
if c.Request.Context().Err() != nil {
return
}
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
return
}
if configured {
writeCodexModelsManifestResponse(c, configuredManifest)
return
}
// 固定账号分支:开启后只用选定账号拉取 manifest,不经过调度器;
// 全部不可用/全部失败时按 FallbackToScheduler 决定回退调度器或返回错误。
if apiKey.Group.Platform == service.PlatformOpenAI &&
apiKey.Group.CodexModelsManifestConfig.Enabled &&
len(apiKey.Group.CodexModelsManifestConfig.AccountIDs) > 0 {
apiKey.Group.CodexModelsManifestConfig.Enabled {
pinnedManifest, pinnedAccount, pinnedErr := h.gatewayService.FetchPinnedCodexModelsManifest(
c.Request.Context(),
apiKey.Group,
@@ -84,7 +67,26 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
if c.Request.Context().Err() != nil {
return
}
writeCodexModelsManifestResponse(c, pinnedManifest)
writeOpenAIModelsResponse(c, pinnedManifest)
return
}
}
if !apiKey.Group.CodexModelsManifestConfig.Enabled {
configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest(
c.Request.Context(),
apiKey.Group,
ifNoneMatch,
)
if err != nil {
if c.Request.Context().Err() != nil {
return
}
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
return
}
if configured {
writeOpenAIModelsResponse(c, configuredManifest)
return
}
}
@@ -133,6 +135,10 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest")
return
}
if err := service.ApplyPinnedCodexModelsMapping(manifest, account, apiKey.Group); err != nil {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to apply model mappings")
return
}
if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, manifest, ifNoneMatch); err != nil {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
return
@@ -141,19 +147,7 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
return
}
writeCodexModelsManifestResponse(c, manifest)
writeOpenAIModelsResponse(c, manifest)
return
}
}
func writeCodexModelsManifestResponse(c *gin.Context, manifest *service.CodexModelsManifest) {
if manifest.ETag != "" {
c.Header("ETag", manifest.ETag)
}
if manifest.NotModified {
c.Status(http.StatusNotModified)
c.Writer.WriteHeaderNow()
return
}
c.Data(http.StatusOK, "application/json", manifest.Body)
}
@@ -163,7 +163,7 @@ func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) {
group := &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"codex-auto-review", "gpt-5.6"},
},
@@ -181,7 +181,7 @@ func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) {
t.Fatal("first response did not include an ETag")
}
group.ModelsListConfig.Enabled = false
group.ModelAllowlist.Enabled = false
second := performCodexModelsRequestForGroup(t, handler, group, oldETag)
if second.Code != http.StatusOK {
t.Fatalf("second status: got %d, want %d; body=%s", second.Code, http.StatusOK, second.Body.String())
@@ -232,7 +232,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
groupA := &service.Group{
ID: 91,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"model-a"},
},
@@ -240,7 +240,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
groupB := &service.Group{
ID: 92,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"model-b"},
},
@@ -292,8 +292,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
require.True(t, sawGroupB)
}
// Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。
func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) {
func TestCodexModelsSupplementsConfiguredModelsWithUnmappedAccountDefaults(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(44)
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
@@ -354,12 +353,59 @@ func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) {
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
}
if len(envelope.Models) != 1 || envelope.Models[0]["slug"] != "glm-5.3" {
t.Fatalf("models: got %v, want only glm-5.3", envelope.Models)
require.Contains(t, codexHandlerManifestSlugs(t, recorder), "gpt-5.6-sol")
require.Contains(t, codexHandlerManifestSlugs(t, recorder), "glm-5.3")
for _, model := range envelope.Models {
require.Contains(t, model, "supported_reasoning_levels")
}
if _, ok := envelope.Models[0]["supported_reasoning_levels"]; !ok {
t.Fatalf("configured model is missing the Codex descriptor contract: %v", envelope.Models[0])
}
func TestCodexModelsUnmappedParentAndSparkShadowHonorCustomListAndETag(t *testing.T) {
gin.SetMode(gin.TestMode)
const sparkModel = "gpt-5.3-codex-spark"
parentID := int64(1)
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
{
ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
Status: service.StatusActive, Schedulable: true,
},
{
ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
Status: service.StatusActive, Schedulable: true,
ParentAccountID: &parentID, QuotaDimension: "spark",
Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}},
},
}}
upstream := &codexModelsFailoverHTTPUpstream{firstStatus: http.StatusNotFound}
gatewayService := service.NewOpenAIGatewayService(
repo,
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
upstream,
nil, nil, nil, nil, nil, nil, nil, nil,
)
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
group := &service.Group{ID: 45, Platform: service.PlatformOpenAI}
first := performCodexModelsRequestForGroup(t, handler, group, "")
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
slugs := codexHandlerManifestSlugs(t, first)
require.Contains(t, slugs, "gpt-5.6-sol")
require.Contains(t, slugs, sparkModel)
require.NotContains(t, slugs, "gpt-image-2")
require.NotContains(t, slugs, "codex-auto-review")
firstETag := first.Header().Get("ETag")
require.NotEmpty(t, firstETag)
group.ModelAllowlist = service.GroupModelAllowlist{
Enabled: true, Models: []string{"gpt-5.6-sol", sparkModel, "unknown-model"},
}
second := performCodexModelsRequestForGroup(t, handler, group, firstETag)
require.Equal(t, http.StatusOK, second.Code, second.Body.String())
require.ElementsMatch(t, []string{"gpt-5.6-sol", sparkModel}, codexHandlerManifestSlugs(t, second))
require.NotEqual(t, firstETag, second.Header().Get("ETag"))
third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag"))
require.Equal(t, http.StatusNotModified, third.Code)
require.Empty(t, third.Body.Bytes())
require.Empty(t, upstream.calls())
}
func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) {
@@ -912,7 +958,7 @@ func TestCodexModelsPinnedAccountsStillApplyCustomModelsListFilter(t *testing.T)
group := &service.Group{
ID: 84,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"model-b"},
},
@@ -18,7 +18,7 @@ import (
func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) {
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行")
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformGrok} {
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformGrok} {
apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}}
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey),
"%s 分组必须豁免 allow_messages_dispatch 闸门", platform)
@@ -74,4 +74,11 @@ func TestResolveOpenAIMessagesDispatchMappedModel_CompositeCNTargetsSkipGroupMap
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"), "model=%s", model)
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformOpenCodeGo))
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"),
"composite → opencode_go 不得注入 openai 默认 gpt-5.x 映射")
}
@@ -128,6 +128,44 @@ func TestOpenAICapacityFailoverExhaustionPreservesMessageAsServerError(t *testin
})
}
func TestOpenAIManagedSingleAccountModelNotFoundExhaustionPreservesStructured400(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
body := []byte(`{"error":{"type":"invalid_request_error","code":"model_not_found","param":"model","message":"The requested model is unavailable on this account"}}`)
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
StatusCode: http.StatusBadRequest,
ResponseBody: body,
}, false)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String())
require.Equal(t, "model_not_found", gjson.Get(recorder.Body.String(), "error.code").String())
require.Equal(t, "model", gjson.Get(recorder.Body.String(), "error.param").String())
require.Equal(t, "The requested model is unavailable on this account", gjson.Get(recorder.Body.String(), "error.message").String())
}
func TestOpenAIManagedModelNotFoundExhaustionSanitizesMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
body := []byte(`{"error":{"code":"model_not_found","message":"Model not found; inspect https://upstream.example/debug?access_token=super-secret-value&model=x"}}`)
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
StatusCode: http.StatusBadRequest,
ResponseBody: body,
}, false)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.NotContains(t, recorder.Body.String(), "super-secret-value")
require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "access_token=***")
recorded, ok := c.Get(service.OpsUpstreamErrorMessageKey)
require.True(t, ok)
require.NotContains(t, recorded, "super-secret-value")
require.Contains(t, recorded, "access_token=***")
}
func TestResponsesFailoverExhaustedAfterForwardedTerminalMarksOpsWithoutDuplicateFrame(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
@@ -20,6 +20,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel"
"github.com/Wei-Shaw/sub2api/internal/securityaudit"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -197,11 +198,11 @@ func resolveOpenAIMessagesDispatchMappedModel(c *gin.Context, apiKey *service.AP
if apiKey == nil || apiKey.Group == nil {
return ""
}
// composite 解析到 grok/CN 目标时调度级映射不适用(Group 级映射的 gpt-5.x
// 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。
// composite 解析到 grok/CN/OpenCode 目标时调度级映射不适用(Group 级映射的
// gpt-5.x 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。
if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil {
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok &&
(platform == service.PlatformGrok || service.IsCNProvider(platform)) {
(platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) {
return ""
}
}
@@ -304,14 +305,14 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe
// 协议账号原生直通 Claude Code),无需 allow_messages_dispatch 开关授权——
// 该开关对非 openai/composite 平台恒被 sanitizeGroupMessagesDispatchFields 置 false,
// 若不豁免,CN 分组将永远 403。
if service.IsCNProvider(apiKey.Group.Platform) {
if service.IsMultiProtocolAPIKeyProvider(apiKey.Group.Platform) {
return true
}
// composite 分组解析到 grok/CN 目标时与对应独立分组同语义豁免;
// composite 分组解析到 grok/CN/OpenCode Go 目标时与对应独立分组同语义豁免;
// 解析到 openai 目标则受 composite 分组自身的可配置开关控制。
if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil {
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok &&
(platform == service.PlatformGrok || service.IsCNProvider(platform)) {
(platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) {
return true
}
}
@@ -321,7 +322,8 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe
func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool {
return compositeTargetPlatformAllowed(c, apiKey, model,
service.PlatformOpenAI, service.PlatformGrok,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek)
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
service.PlatformMiniMax, service.PlatformOpenCodeGo)
}
// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket
@@ -834,6 +836,22 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
})
}
if err != nil {
if result != nil && result.ClientDisconnect {
reqLog.Info("openai.client_disconnected",
zap.Int64("account_id", account.ID),
zap.Error(err),
)
submitResponsesUsage(result)
return
}
if failoverClientGone(c) {
reqLog.Info("openai.client_disconnected",
zap.Int64("account_id", account.ID),
zap.Error(err),
)
submitResponsesUsage(result)
return
}
if result != nil && result.ImageCount > 0 {
reqLog.Warn("openai.forward_partial_error_with_image_result",
zap.Int64("account_id", account.ID),
@@ -1872,12 +1890,24 @@ func validCodexAutomationLastRun(value string) bool {
func validCodexAutomationHeartbeat(value string) bool {
decoder := xml.NewDecoder(strings.NewReader(value))
var rootSeen, automationIDSeen bool
var automationID bytes.Buffer
var childName string
var childText bytes.Buffer
fields := make(map[string]string)
depth := 0
for {
token, err := decoder.Token()
if err == io.EOF {
id := automationID.String()
id := fields["automation_id"]
timestamp, hasTime := fields["current_time_iso"]
instructions, hasInstructions := fields["instructions"]
if hasTime != hasInstructions {
return false
}
if hasTime {
if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil || strings.TrimSpace(instructions) == "" {
return false
}
}
return rootSeen && automationIDSeen && depth == 0 &&
strings.TrimSpace(id) == id && validCodexAutomationID(id)
}
@@ -1895,21 +1925,33 @@ func validCodexAutomationHeartbeat(value string) bool {
return false
}
rootSeen = true
} else if automationIDSeen || current.Name.Local != "automation_id" {
return false
} else {
childName = current.Name.Local
switch childName {
case "automation_id", "current_time_iso", "instructions":
default:
return false
}
if _, duplicate := fields[childName]; duplicate {
return false
}
childText.Reset()
}
automationIDSeen = depth == 2
case xml.EndElement:
if current.Name.Space != "" {
return false
}
if depth == 2 {
fields[childName] = childText.String()
automationIDSeen = automationIDSeen || childName == "automation_id"
}
depth--
if depth < 0 {
return false
}
case xml.CharData:
if depth == 2 {
_, _ = automationID.Write(current)
_, _ = childText.Write(current)
} else if len(bytes.TrimSpace(current)) != 0 {
return false
}
@@ -2025,10 +2067,10 @@ const (
// 由 BeforeTurn 在每个 turn 开始时冻结,AfterTurn 的用量提交读取它;turn 在
// 连接内串行推进,互斥锁只为跨用量提交 goroutine 的读取安全。
//
// ws_v2 passthrough ingress 没有 BeforeTurn,因此本值会保持零;AfterTurn 必须
// 以 TurnStarted 已记录的所属 turn 开始时刻为回退,而不是用建连或记录时刻。
// 这样每个 passthrough turn 都按自己的开始时刻计价,但不改变其仅在建连时执行
// 准入门、没有 turn 级利润复核的既有行为。
// 零值语义(重要):首轮准入由握手路径完成,不调用 BeforeTurn,因此首轮保持
// 零值并回退到 TurnStarted 记录的首轮开始时刻。后续 turn 在 response.create
// 写入上游前调用 BeforeTurn,按当时的利润门复核并冻结定价。绝不能用建连时刻
// 初始化,否则会把长连接的所有 turn 钉死在建连时的峰谷因子。
type openAIWSTurnPricing struct {
mu sync.Mutex
at time.Time
@@ -2348,6 +2390,16 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "model is required in first response.create payload")
return
}
// 分组级模型白名单:首帧校验客户端模型,不通过则关闭连接并标记运维原因。
// 必须在 ensureCompositeTargetPlatform(合成路由改写)之前执行。
// 与 HTTP 准入一致:帧内重复 model 键/大小写变体可能被上游按末值绑定,
// 全部候选值逐一校验,任一未命中即拒绝。
if blocked := blockedModelAllowlistCandidate(apiKey.Group, requestmodel.FromBodyCandidates("", "application/json", firstMessage)); blocked != "" {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed)
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked))
return
}
ensureCompositeTargetPlatform(c, apiKey, reqModel)
ctx = c.Request.Context()
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
@@ -2728,8 +2780,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// turn-tagged slot preserves the exact mapping used for the in-flight request.
var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot]
turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS})
// turn 级定价:BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号;
// passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。
// turn 级定价:首轮回退到 TurnStarted 的所属 turn 时刻;后续 turn 由
// BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号。
var turnPricing openAIWSTurnPricing
hooks := &service.OpenAIWSIngressHooks{
ClientLifecycleContext: clientLifecycleCtx,
@@ -2743,10 +2795,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
c.Set(securityAuditWSTurnContextKey, turn)
service.BeginOpsStreamTurn(c, turn)
setCyberTurnBody(turn, payload)
// Passthrough ingress intentionally skips BeforeTurn, so enforce only
// the connection-level cyber session gate here as well. Native ingress
// visits this hook first and gets the same side-effect-free close error;
// its original BeforeTurn guard remains as defense in depth.
// 连接级 cyber session gate 也在 BeforeRequest 先执行,使 native 与
// passthrough ingress 都能在 BeforeTurn 及上游写入前无副作用地拒绝。
// BeforeTurn 中保留同一检查作为防御式兜底。
if cyberBlockedThisConn {
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil)
}
@@ -2763,6 +2814,17 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
if model == "" {
model = reqModel
}
// 分组级模型白名单:后续 turn 同样校验客户端模型(省略 model 时
// 沿用会话实际生效模型,含 session.update 轮换后的模型),不通过
// 则关闭整条连接,与推理强度 deny 一致。实际生效模型始终参与校验;
// 帧内重复 model 键/大小写变体/嵌套 session.model 额外逐一校验,
// 防止候选集非空时掩盖被轮换掉的禁用模型。
candidates := append([]string{model}, requestmodel.FromBodyCandidates("", "application/json", payload)...)
if blocked := blockedModelAllowlistCandidate(apiKey.Group, candidates); blocked != "" {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed)
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked), nil)
}
if decision := h.checkSecurityAuditStage(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, model, payload, "subsequent_turn"); decision != nil && !decision.AllowNextStage {
writeSecurityAuditWSError(ctx, wsConn, decision)
return service.NewOpenAIWSClientCloseError(securityAuditWSCloseStatus(decision), securityAuditWSCloseReason(decision), nil)
@@ -2955,7 +3017,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// WebSocket 首包可能很大,hash 必须在 hooks 外算成字符串,避免 AfterTurn 闭包保活请求体。
requestPayloadHash = service.HashUsageRequestPayload(wsFirstMessage)
if preemptCtx, cleanupPreempt, armed := h.gatewayService.BeginOpenAIWSIngressSessionPreemption(ctx, c, account, wsFirstMessage); armed {
if preemptCtx, cleanupPreempt, armed := h.gatewayService.BeginOpenAIWSIngressSessionPreemptionWithClient(ctx, c, account, wsFirstMessage, wsConn); armed {
ctx = preemptCtx
defer cleanupPreempt()
}
@@ -2967,6 +3029,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
return
}
if service.IsOpenAIWSSessionPreemptedError(err) {
// 关闭帧已由抢占登记在取消前发给本连接,这里只记录并释放。
reqLog.Info("openai.websocket_ingress_preempted", zap.Int64("account_id", account.ID))
return
}
var failoverErr *service.UpstreamFailoverError
@@ -3311,6 +3375,12 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE
}
statusCode := failoverErr.StatusCode
responseBody := failoverErr.ResponseBody
if statusCode == http.StatusBadRequest && service.IsOpenAICompatibleModelNotFound400(responseBody) && !streamStarted {
upstreamMsg := service.SanitizeUpstreamErrorMessage(service.ExtractUpstreamErrorMessage(responseBody))
service.SetOpsUpstreamError(c, statusCode, upstreamMsg, "")
service.WriteOpenAIUpstreamClientError(c, statusCode, responseBody, upstreamMsg)
return
}
if service.IsOpenAISilentRefusalErrorBody(responseBody) {
service.SetOpsUpstreamError(c, statusCode, service.OpenAISilentRefusalClientMessage(), "")
h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage(), streamStarted)
@@ -3501,6 +3571,10 @@ func (h *OpenAIGatewayHandler) ensureForwardErrorResponse(c *gin.Context, stream
if c == nil || c.Writer == nil {
return false
}
if c.Request != nil && errors.Is(c.Request.Context().Err(), context.Canceled) {
failoverClientGone(c)
return false
}
// 先停 compact 心跳再读 Writer 状态,避免与心跳 goroutine 竞争。
compactKeepaliveCommitted := service.StopOpenAICompactSSEKeepaliveCommitted(c)
if compactKeepaliveCommitted {
@@ -3670,6 +3744,21 @@ func isOpenAIWSUpgradeRequest(r *http.Request) bool {
return strings.Contains(strings.ToLower(strings.TrimSpace(r.Header.Get("Connection"))), "upgrade")
}
// blockedModelAllowlistCandidate 对全部候选模型逐一校验分组白名单,返回第一个
// 未命中的值(全部命中或白名单未开启返回空串)。WS 帧与 HTTP 请求体共用该
// 规则:重复 model 键/大小写变体可能被上游按末值绑定,任一未命中即拒绝。
func blockedModelAllowlistCandidate(group *service.Group, candidates []string) string {
if group == nil || !group.ModelAllowlistEnabled() {
return ""
}
for _, candidate := range candidates {
if !group.ModelAllowlist.Allows(candidate) {
return candidate
}
}
return ""
}
func closeOpenAIClientWS(conn *coderws.Conn, status coderws.StatusCode, reason string) {
if conn == nil {
return
@@ -1916,7 +1916,10 @@ func newOpenAIWSHandlerTestServer(t *testing.T, h *OpenAIGatewayHandler, subject
}
type openAIResponsesWSUsageLogCase struct {
firstPayload string
firstPayload string
// midPayload 在首个 turn 完成后发送(如 session.update),上游桩会为它
// 回一个 response.completed,客户端按普通事件读取。
midPayload string
secondPayload string
userAgent *string
ingressMode string
@@ -1924,6 +1927,12 @@ type openAIResponsesWSUsageLogCase struct {
billingModelSource string
accountModelMapping map[string]any
afterFirstUpstreamRequest func(channelSvc *service.ChannelService) error
// group 覆盖 apiKey.Group(分组级模型白名单测试用);nil 保持原有无分组行为。
group *service.Group
// firstFrameCloseExpected:首帧即被拒(连接被 1008 关闭),不期待任何响应帧。
firstFrameCloseExpected bool
// secondTurnCloseExpected:第二个 turn 被拒(连接被 1008 关闭)。
secondTurnCloseExpected bool
}
type openAIResponsesWSUsageLogResult struct {
@@ -2843,8 +2852,11 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
gin.SetMode(gin.TestMode)
turnCount := 1
if strings.TrimSpace(tc.midPayload) != "" {
turnCount++
}
if strings.TrimSpace(tc.secondPayload) != "" {
turnCount = 2
turnCount++
}
upstreamPayloadCh := make(chan []byte, turnCount)
upstreamErrCh := make(chan error, 1)
@@ -2948,7 +2960,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
BillingModelSource: tc.billingModelSource,
}},
groupPlatforms: map[int64]string{groupID: service.PlatformOpenAI},
}, nil, nil, nil)
}, nil, nil, nil, nil)
}
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
@@ -2997,6 +3009,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
GroupID: &groupID,
User: &service.User{ID: 1701, Status: service.StatusActive},
}
if tc.group != nil {
apiKey.Group = tc.group
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
@@ -3028,6 +3043,19 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
cancelWrite()
require.NoError(t, err)
if tc.firstFrameCloseExpected {
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
_, _, readErr := clientConn.Read(readCtx)
cancelRead()
require.Error(t, readErr, "first frame should have been rejected with a close")
var closeErr coderws.CloseError
require.ErrorAs(t, readErr, &closeErr)
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
require.Contains(t, closeErr.Reason, "not available for this group")
_ = clientConn.CloseNow()
return openAIResponsesWSUsageLogResult{}
}
clientEvents := make([][]byte, 0, turnCount)
readCompleted := func() {
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
@@ -3038,11 +3066,30 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
clientEvents = append(clientEvents, append([]byte(nil), event...))
}
readCompleted()
if turnCount == 2 {
if tc.midPayload != "" {
writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.midPayload))
cancelWrite()
require.NoError(t, err)
readCompleted()
}
if strings.TrimSpace(tc.secondPayload) != "" && (turnCount >= 2) {
writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.secondPayload))
cancelWrite()
require.NoError(t, err)
if tc.secondTurnCloseExpected {
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
_, _, readErr := clientConn.Read(readCtx)
cancelRead()
require.Error(t, readErr, "second turn should have been rejected with a close")
var closeErr coderws.CloseError
require.ErrorAs(t, readErr, &closeErr)
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
require.Contains(t, closeErr.Reason, "not available for this group")
_ = clientConn.CloseNow()
return openAIResponsesWSUsageLogResult{}
}
readCompleted()
}
_ = clientConn.Close(coderws.StatusNormalClosure, "done")
@@ -0,0 +1,179 @@
package handler
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func wsAllowlistGroup(enabled bool, models ...string) *service.Group {
return &service.Group{
ID: 4201,
Platform: service.PlatformOpenAI,
ModelAllowlist: service.GroupModelAllowlist{
Enabled: enabled,
Models: models,
},
}
}
// 首帧拒绝:模型不在白名单时连接被 1008 关闭(passthrough relay 模式)。
func TestOpenAIResponsesWebSocket_FirstFrameModelNotAllowedCloses_Passthrough(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: service.OpenAIWSIngressModePassthrough,
firstFrameCloseExpected: true,
})
}
// 首帧拒绝:原生 ingress 模式。
func TestOpenAIResponsesWebSocket_FirstFrameModelNotAllowedCloses_NativeIngress(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: service.OpenAIWSIngressModeDedicated,
firstFrameCloseExpected: true,
})
}
// 首帧命中白名单:连接正常建立并收到 response.completed(passthrough)。
func TestOpenAIResponsesWebSocket_FirstFrameAllowlistedModelProceeds(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
})
if len(got.clientEvents) != 1 {
t.Fatalf("expected one completed event, got %d", len(got.clientEvents))
}
}
// 后续 turn 切换到白名单外的模型:连接被 1008 关闭(passthrough relay 模式)。
func TestOpenAIResponsesWebSocket_SubsequentTurnModelNotAllowedCloses_Passthrough(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
secondPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: service.OpenAIWSIngressModePassthrough,
secondTurnCloseExpected: true,
})
}
// 后续 turn 切换到白名单外的模型:连接被 1008 关闭(原生 ingress 模式)。
func TestOpenAIResponsesWebSocket_SubsequentTurnModelNotAllowedCloses_NativeIngress(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
secondPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: service.OpenAIWSIngressModeDedicated,
secondTurnCloseExpected: true,
})
}
// 后续 turn 省略 model:沿用会话初始模型,白名单校验通过。
func TestOpenAIResponsesWebSocket_SubsequentTurnOmittedModelUsesSessionModel(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
secondPayload: `{"type":"response.create","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
})
if len(got.clientEvents) != 2 {
t.Fatalf("expected two completed events, got %d", len(got.clientEvents))
}
if len(got.upstreamPayloads) != 2 {
t.Fatalf("expected two upstream frames, got %d", len(got.upstreamPayloads))
}
}
// 白名单关闭:不受任何影响。
func TestOpenAIResponsesWebSocket_DisabledAllowlistDoesNotInterfere(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
secondPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
group: wsAllowlistGroup(false, "gpt-5.4"),
})
if len(got.clientEvents) != 2 {
t.Fatalf("expected two completed events, got %d", len(got.clientEvents))
}
}
// 首帧含重复 model 键(首个在白名单、末个不在):passthrough 会原样转发两个字段,
// 上游按末值绑定时得到禁用模型,必须在入口拒绝。
func TestOpenAIResponsesWebSocket_FirstFrameDuplicateModelKeysRejected(t *testing.T) {
for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} {
t.Run(mode, func(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: mode,
firstFrameCloseExpected: true,
})
})
}
}
// 首帧混用大小写变体键({"model":"allowed","Model":"blocked"}):绑定系上游
// 大小写不敏感且末值生效会绑定 blocked,必须在入口拒绝。仅含变体键的帧则会在
// 「model is required」处更早被关闭。
func TestOpenAIResponsesWebSocket_FirstFrameCaseVariantModelKeyRejected(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","Model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: service.OpenAIWSIngressModePassthrough,
firstFrameCloseExpected: true,
})
}
// 后续 turn 含重复 model 键(首个在白名单、末个不在):拒绝并关闭连接。
func TestOpenAIResponsesWebSocket_SubsequentTurnDuplicateModelKeysRejected(t *testing.T) {
for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} {
t.Run(mode, func(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
secondPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: mode,
secondTurnCloseExpected: true,
})
})
}
}
// 重复但同值的 model 键不误伤,连接正常完成两个 turn。
func TestOpenAIResponsesWebSocket_DuplicateIdenticalModelKeysAllowed(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-5.4","stream":false}`,
secondPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
})
if len(got.clientEvents) != 2 {
t.Fatalf("expected two completed events, got %d", len(got.clientEvents))
}
}
// session.update 轮换绕过:首帧用白名单内模型建立会话,session.update 把会话
// 模型改为白名单外的模型,随后 response.create 携带嵌套 session.model(白名单
// 内)让帧内候选非空。实际生效模型(轮换后的会话模型)必须始终参与校验。
func TestOpenAIResponsesWebSocket_SessionUpdateRotationBypassRejected(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
midPayload: `{"type":"session.update","session":{"model":"gpt-4.1"}}`,
secondPayload: `{"type":"response.create","session":{"model":"gpt-5.4"},"stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: service.OpenAIWSIngressModePassthrough,
secondTurnCloseExpected: true,
})
}
// 轮换后的会话模型本身在白名单内时,省略 model 的后续 turn 正常放行。
func TestOpenAIResponsesWebSocket_SessionUpdateToAllowedModelStillWorks(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
midPayload: `{"type":"session.update","session":{"model":"gpt-5.4"}}`,
secondPayload: `{"type":"response.create","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
})
if len(got.clientEvents) < 2 {
t.Fatalf("expected at least two completed events, got %d", len(got.clientEvents))
}
}
@@ -157,7 +157,7 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen
)
handler.maxAccountSwitches = 10
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","quality":"high","size":"1536x1024"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","quality":"high","size":"1536x1024"}`)
core, observedLogs := observer.New(zap.DebugLevel)
requestCtx := logger.IntoContext(context.Background(), zap.New(core))
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)).WithContext(requestCtx)

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