Compare commits

...
70 Commits
Author SHA1 Message Date
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
hansnow 7ccc8a6f50 fix(openai): 支持 Image 2.5 并修复 OAuth 生图主控模型 2026-09-09 11:04:59 +08:00
shaw 270eac6973 chore: update sponsors 2026-09-08 21:18:24 +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
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
chihao.ou 941a0487ab fix(accounts): allow removing inactive group assignments 2026-09-07 20:05:08 +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
刘佳林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
wucm667 35e69af41b feat(accounts): show estimated OpenAI weekly cost 2026-09-07 18:37:15 +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
ccemoji be4ab92b2b fix: cancel streaming upstream before closing body 2026-09-07 16:47:51 +08:00
chihao.ou 76efc52fb6 feat(custom-page): make the open-link button draggable 2026-09-06 17:52:24 +08:00
anguobao123 1859519573 feat(deploy): support optional Apple network subnet 2026-09-02 20:08:57 +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
Ray a10ff1255c fix(openai-ws): classify canceled client streams 2026-08-31 21:04:29 +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
266 changed files with 5697 additions and 727 deletions
+10
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
+18
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>
## 项目概述
@@ -696,6 +706,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.2
0.2.3
+2 -1
View File
@@ -137,7 +137,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)
+2 -1
View File
@@ -182,6 +182,7 @@ const (
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
ProviderMinimax Provider = "minimax"
)
func (pr Provider) String() string {
@@ -191,7 +192,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:
return nil
default:
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
@@ -108,6 +108,7 @@ const (
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
ProviderMinimax Provider = "minimax"
)
func (pr Provider) String() string {
@@ -117,7 +118,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:
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
+3 -3
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"}},
{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"}},
{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},
@@ -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{
+113 -30
View File
@@ -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),
},
+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"),
// 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"),
field.String("api_mode").
Default("chat_completions").
MaxLen(32).
+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":
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=
+9 -4
View File
@@ -1613,10 +1613,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 +2046,8 @@ 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
"generativelanguage.googleapis.com",
"cloudcode-pa.googleapis.com",
"*.openai.azure.com",
@@ -2250,6 +2251,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)
@@ -3673,6 +3675,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")
}
+20
View File
@@ -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 },
+2 -1
View File
@@ -27,6 +27,7 @@ const (
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformMiniMax = "minimax" // MiniMax (M 系列)
PlatformComposite = "composite"
)
@@ -43,7 +44,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" // 按入站协议优先选择供应商原生端点
)
@@ -309,9 +309,11 @@ 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,
@@ -383,9 +385,11 @@ 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,
@@ -643,6 +643,7 @@ var platformToLiteLLMProvider = map[string]string{
service.PlatformKimi: "moonshot",
service.PlatformZhipu: "zhipu",
service.PlatformDeepseek: "deepseek",
service.PlatformMiniMax: "minimax",
}
// 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"`
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"`
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"`
@@ -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)
})
}
}
@@ -184,7 +184,7 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
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 composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -258,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 composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -332,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"`
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"`
@@ -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", "composite",
}
for _, platform := range allowed {
t.Run("create_"+platform, func(t *testing.T) {
@@ -72,7 +72,7 @@ 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"} {
var req CompositeRouteRequest
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
+6 -2
View File
@@ -172,9 +172,11 @@ 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,
@@ -255,9 +257,11 @@ 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,
+16 -14
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,8 +183,8 @@ 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
}
proxy, err := h.adminService.UpdateProxy(c.Request.Context(), proxyID, &service.UpdateProxyInput{
@@ -196,8 +196,10 @@ func (h *ProxyHandler) Update(c *gin.Context) {
Password: strings.TrimSpace(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)
}
})
}
}
@@ -376,6 +376,7 @@ 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,
@@ -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"`
@@ -1906,6 +1907,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
@@ -2366,6 +2373,7 @@ 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,
@@ -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)
+2
View File
@@ -307,6 +307,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"`
@@ -417,6 +418,7 @@ 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"`
+4 -2
View File
@@ -1278,7 +1278,7 @@ 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} {
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
if len(platformModels) == 0 {
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
@@ -1441,6 +1441,8 @@ func defaultCodexModelIDsForPlatform(platform string) []string {
switch platform {
case service.PlatformDeepseek:
return []string{"deepseek-v4-pro", "deepseek-v4-flash"}
case service.PlatformMiniMax:
return []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}
default:
return defaultModelIDsForPlatform(platform)
}
@@ -1470,7 +1472,7 @@ func defaultModelIDsForPlatform(platform string) []string {
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} {
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
if _, ok := seen[id]; ok {
continue
@@ -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()))
}
@@ -791,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"},
},
},
},
},
},
@@ -805,7 +812,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
Platform: service.PlatformComposite,
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"},
},
},
})
@@ -816,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) {
@@ -869,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},
},
},
},
@@ -900,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{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}, defaultCodexModelIDsForPlatform(service.PlatformMiniMax))
require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
}
@@ -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)
}
}
@@ -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())
}
@@ -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.PlatformGrok} {
apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}}
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey),
"%s 分组必须豁免 allow_messages_dispatch 闸门", platform)
@@ -322,7 +322,7 @@ 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)
}
// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket
@@ -835,6 +835,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),
@@ -3528,6 +3544,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 {
@@ -2960,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)
@@ -108,6 +108,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
@@ -44,6 +44,7 @@ const (
PlatformKimi = domain.PlatformKimi
PlatformZhipu = domain.PlatformZhipu
PlatformDeepseek = domain.PlatformDeepseek
PlatformMiniMax = domain.PlatformMiniMax
)
// AllPlatforms 返回所有支持的平台列表
@@ -57,6 +58,7 @@ func AllPlatforms() []string {
PlatformKimi,
PlatformZhipu,
PlatformDeepseek,
PlatformMiniMax,
}
}
@@ -16,5 +16,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) {
"kimi",
"zhipu",
"deepseek",
"minimax",
}, AllPlatforms())
}
+2
View File
@@ -33,6 +33,8 @@ var DefaultModels = []Model{
{ID: "gpt-image-1", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 1"},
{ID: "gpt-image-1.5", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 1.5"},
{ID: "gpt-image-2", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2"},
{ID: "gpt-image-2.5-flare", Object: "model", Created: 1788825600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2.5 Flare"},
{ID: "gpt-image-2.5-sunburst", Object: "model", Created: 1788825600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2.5 Sunburst"},
}
// DefaultModelIDs returns the default model ID list
@@ -27,3 +27,8 @@ func TestDefaultModelsPreferConcreteGPT56SolForAccountTests(t *testing.T) {
require.NotEmpty(t, DefaultModels)
require.Equal(t, "gpt-5.6-sol", DefaultModels[0].ID)
}
func TestDefaultModelsIncludeGPTImage25(t *testing.T) {
require.Contains(t, DefaultModelIDs(), "gpt-image-2.5-flare")
require.Contains(t, DefaultModelIDs(), "gpt-image-2.5-sunburst")
}
@@ -18,7 +18,7 @@ const (
// ollamaCloudUsagePlatformsSQL 是 service.isOllamaCloudUsagePlatform 的 SQL
// 镜像:Ollama Cloud key 允许挂在 openai/anthropic 与国产 OpenAI 兼容平台
// 下复用。所有平台白名单 SQL 只允许引用本常量,不得各处重写字面量,防止漂移。
ollamaCloudUsagePlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek'"
ollamaCloudUsagePlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'"
ollamaCloudUsageEligibleSQL = `
platform IN (` + ollamaCloudUsagePlatformsSQL + `)
AND type = 'apikey'
@@ -179,7 +179,7 @@ func TestListOllamaCloudUsageGroupAccountsUsesOneStrictBatchQuery(t *testing.T)
require.Empty(t, accounts)
query := normalizeSQLWhitespace(capturedSQL)
require.Contains(t, query, "credentials ->> 'api_key' = ANY($1)")
require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek')")
require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax')")
require.Contains(t, query, "jsonb_typeof(credentials -> 'api_key') = 'string'")
require.Contains(t, query, ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"))
require.NotContains(t, query, "~*")
@@ -207,7 +207,7 @@ func TestListDueOllamaCloudUsageAccountsFiltersOrdersAndLimits(t *testing.T) {
for _, clause := range []string{
"deleted_at IS NULL",
"status = 'active'",
"platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek')",
"platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax')",
"type = 'apikey'",
ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"),
"jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string'",
@@ -251,7 +251,7 @@ func TestBulkUpdateOllamaIdentityCleanupIsValueConditional(t *testing.T) {
require.Contains(t, query, "NOT ("+ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"))
require.Contains(t, query, ollamaCloudBaseURLMatchesSQL("$1::jsonb ->> 'base_url'"))
require.NotContains(t, query, "~*")
require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek') AND type = 'apikey'")
require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax') AND type = 'apikey'")
require.Contains(t, query, "- 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'")
payload, ok := exec.execArgs[0][0].([]byte)
require.True(t, ok)
@@ -326,10 +326,10 @@ func TestOllamaCloudUsagePlatformWhitelistMatchesServicePredicate(t *testing.T)
for _, match := range matches {
sqlPlatforms[match[1]] = struct{}{}
}
require.Len(t, sqlPlatforms, 5)
require.Len(t, sqlPlatforms, 6)
for _, platform := range []string{
service.PlatformOpenAI, service.PlatformAnthropic,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax,
service.PlatformGemini, service.PlatformGrok, service.PlatformAntigravity,
service.PlatformComposite, "kiro",
} {
@@ -373,7 +373,7 @@ func TestUpdateCredentialsPlainCNAPIKeyAccountCleanupStaysSemanticallyEquivalent
require.NoError(t, err)
query := normalizeSQLWhitespace(capturedSQL)
require.Contains(t, query,
"platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek') AND type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb")
"platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax') AND type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb")
require.Contains(t, query,
"THEN COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe' - 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'")
require.NotContains(t, query, "- 'upstream_billing_probe_enabled'")
@@ -0,0 +1,53 @@
package repository
import (
"context"
"log/slog"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
const channelCachePubSubKey = "channel_cache_updated"
type channelCache struct {
rdb *redis.Client
}
// NewChannelCache creates the Redis-backed channel cache invalidation bus.
func NewChannelCache(rdb *redis.Client) service.ChannelCachePubSub {
return &channelCache{rdb: rdb}
}
// NotifyUpdate notifies all instances to invalidate their local channel cache.
func (c *channelCache) NotifyUpdate(ctx context.Context) error {
return c.rdb.Publish(ctx, channelCachePubSubKey, "refresh").Err()
}
// SubscribeUpdates subscribes to channel cache invalidation notifications.
func (c *channelCache) SubscribeUpdates(ctx context.Context, handler func()) {
go func() {
pubsub := c.rdb.Subscribe(ctx, channelCachePubSubKey)
defer func() {
if err := pubsub.Close(); err != nil {
slog.Warn("failed to close channel cache subscriber", "error", err)
}
}()
messages := pubsub.Channel()
for {
select {
case <-ctx.Done():
return
case message, ok := <-messages:
if !ok {
slog.Warn("channel cache subscriber stopped", "reason", "channel_closed")
return
}
if message != nil {
handler()
}
}
}
}()
}
@@ -0,0 +1,44 @@
package repository
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func TestChannelCachePubSub(t *testing.T) {
redisServer := miniredis.RunT(t)
publisherRedis := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
subscriberRedis := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
t.Cleanup(func() {
_ = publisherRedis.Close()
_ = subscriberRedis.Close()
})
publisher := NewChannelCache(publisherRedis)
subscriber := NewChannelCache(subscriberRedis)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
received := make(chan struct{}, 1)
subscriber.SubscribeUpdates(ctx, func() {
select {
case received <- struct{}{}:
default:
}
})
require.Eventually(t, func() bool {
return redisServer.PubSubNumSub(channelCachePubSubKey)[channelCachePubSubKey] == 1
}, time.Second, 10*time.Millisecond)
require.NoError(t, publisher.NotifyUpdate(context.Background()))
select {
case <-received:
case <-time.After(time.Second):
t.Fatal("channel cache invalidation notification was not received")
}
}
@@ -1102,10 +1102,12 @@ func (c *concurrencyCache) reconcileExpiredIndexCandidates(ctx context.Context,
if err != nil {
return err
}
members, err := c.rdb.ZRangeByScore(ctx, spec.indexKey, &redis.ZRangeBy{
Min: "-inf",
Max: strconv.FormatInt(now, 10),
Count: activeIndexCleanupBatchSize,
members, err := c.rdb.ZRangeArgs(ctx, redis.ZRangeArgs{
Key: spec.indexKey,
Start: "-inf",
Stop: strconv.FormatInt(now, 10),
ByScore: true,
Count: activeIndexCleanupBatchSize,
}).Result()
if err != nil {
return fmt.Errorf("read expired index %s: %w", spec.indexKey, err)
+14 -10
View File
@@ -76,13 +76,13 @@ const (
defaultOpenAIHTTP2FallbackErrorThreshold = 2
defaultOpenAIHTTP2FallbackWindow = 60 * time.Second
defaultOpenAIHTTP2FallbackTTL = 10 * time.Minute
// OpenAI HTTP/2 连接健康探测:Codex 上游改走 HTTP/2 后,池化连接被代理/NAT
// 长流 HTTP/2 连接健康探测:池化连接被代理/NAT
// 静默掐断会成为“死连接”(两端都以为存活),请求落上去会挂到 TCP 重传超时
// (分钟级)。Go 的 http2.Transport 默认 ReadIdleTimeout=0(不发健康 PING),
// 无法检测。启用主动 PING 探测:连接空闲 ReadIdleTimeout 后发 PING,PingTimeout
// 内无响应即判定死连接并关闭,从源头避免请求挂在死连接上。
openAIHTTP2ReadIdleTimeout = 15 * time.Second
openAIHTTP2PingTimeout = 15 * time.Second
longStreamHTTP2ReadIdleTimeout = 10 * time.Second
longStreamHTTP2PingTimeout = 5 * time.Second
// The Grok CLI proxy rejects requests that do not identify a supported
// client version. Host/env/version pins live in package xai so service,
@@ -96,6 +96,7 @@ const (
const (
upstreamProtocolModeDefault = "default"
upstreamProtocolModeLongStreamH2 = "long_stream_h2"
upstreamProtocolModeOpenAIH1 = "openai_h1"
upstreamProtocolModeOpenAIH2 = "openai_h2"
upstreamProtocolModeOpenAIH1Fallback = "openai_h1_fallback"
@@ -128,7 +129,7 @@ type upstreamClientEntry struct {
client *http.Client // HTTP 客户端实例
proxyKey string // 代理标识(用于检测代理变更)
poolKey string // 连接池配置标识(用于检测配置变更)
protocolMode string // 协议模式(default/openai_h1/openai_h2/openai_h1_fallback)
protocolMode string // 协议模式(default/long_stream_h2/openai_h1/openai_h2/openai_h1_fallback)
lastUsed int64 // 最后使用时间戳(纳秒),用于 LRU 淘汰
inFlight int64 // 当前进行中的请求数,>0 时不可淘汰
}
@@ -1007,6 +1008,9 @@ func (s *httpUpstreamService) resolveOpenAIHTTP2Settings() openAIHTTP2Settings {
}
func (s *httpUpstreamService) resolveProtocolMode(profile service.HTTPUpstreamProfile, proxyKey string, parsedProxy *url.URL) string {
if profile == service.HTTPUpstreamProfileLongStream {
return upstreamProtocolModeLongStreamH2
}
if profile == service.HTTPUpstreamProfileGrok {
return upstreamProtocolModeGrok
}
@@ -1332,11 +1336,11 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMo
ResponseHeaderTimeout: settings.responseHeaderTimeout,
}
switch protocolMode {
case upstreamProtocolModeOpenAIH2:
case upstreamProtocolModeLongStreamH2, upstreamProtocolModeOpenAIH2:
transport.ForceAttemptHTTP2 = true
// 显式配置 http2 并启用 PING 健康探测,剔除代理/NAT 静默掐断的死连接,
// 避免请求挂在死连接上直到 TCP 重传超时(分钟级)。
if _, err := enableOpenAIHTTP2KeepAlive(transport); err != nil {
if _, err := enableHTTP2KeepAlive(transport); err != nil {
return nil, err
}
case upstreamProtocolModeOpenAIH1:
@@ -1353,18 +1357,18 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMo
return transport, nil
}
// enableOpenAIHTTP2KeepAlive 在 http.Transport 上显式配置 HTTP/2 并启用连接健康探测。
// enableHTTP2KeepAlive 在 http.Transport 上显式配置 HTTP/2 并启用连接健康探测。
// Go 默认惰性配置 http2 且 ReadIdleTimeout=0(不发健康 PING),无法检测被代理/NAT
// 静默掐断的死连接。此处主动设置 ReadIdleTimeout/PingTimeout,让死连接被提前 PING
// 出并关闭,请求得以重建连接而非挂到 TCP 重传超时。返回底层 *http2.Transport 便于测试。
func enableOpenAIHTTP2KeepAlive(transport *http.Transport) (*http2.Transport, error) {
func enableHTTP2KeepAlive(transport *http.Transport) (*http2.Transport, error) {
h2, err := http2.ConfigureTransports(transport)
if err != nil {
return nil, err
}
if h2 != nil {
h2.ReadIdleTimeout = openAIHTTP2ReadIdleTimeout
h2.PingTimeout = openAIHTTP2PingTimeout
h2.ReadIdleTimeout = longStreamHTTP2ReadIdleTimeout
h2.PingTimeout = longStreamHTTP2PingTimeout
}
return h2, nil
}
@@ -32,44 +32,44 @@ func requireHTTP2Configured(t *testing.T, tr *http.Transport, msg string) {
require.True(t, tr.Protocols.HTTP2(), msg)
}
// Codex/OpenAI 上游改走 HTTP/2 后,池化连接被代理/NAT 静默掐断会成为“死连接”:
// 长流 / OpenAI 上游改走 HTTP/2 后,池化连接被代理/NAT 静默掐断会成为“死连接”:
// 两端都以为连接存活,请求落上去会挂到 TCP 重传超时(分钟级)才失败。Go 的
// http2.Transport 默认 ReadIdleTimeout=0(不发健康 PING),无法检测这种死连接。
// 必须显式启用主动 PING 探测,让死连接被提前剔除,而不是只靠 ResponseHeaderTimeout
// 事后兜底。
func TestEnableOpenAIHTTP2KeepAlive_EnablesPingHealthCheck(t *testing.T) {
func TestEnableHTTP2KeepAlive_EnablesPingHealthCheck(t *testing.T) {
tr := &http.Transport{}
h2, err := enableOpenAIHTTP2KeepAlive(tr)
h2, err := enableHTTP2KeepAlive(tr)
require.NoError(t, err)
require.NotNil(t, h2, "必须返回已配置的 *http2.Transport")
require.Positive(t, h2.ReadIdleTimeout, "必须启用空闲 PING 探测以剔除死连接")
require.Equal(t, openAIHTTP2ReadIdleTimeout, h2.ReadIdleTimeout)
require.Equal(t, openAIHTTP2PingTimeout, h2.PingTimeout, "PING 无响应必须有超时判定")
require.Equal(t, longStreamHTTP2ReadIdleTimeout, h2.ReadIdleTimeout)
require.Equal(t, longStreamHTTP2PingTimeout, h2.PingTimeout, "PING 无响应必须有超时判定")
requireHTTP2Configured(t, tr, "http2 必须已挂到底层 http.Transport 上")
}
// openai_h2 模式构建的 Transport 必须带上 H2 PING 健康探测,从源头剔除死连接。
func TestBuildUpstreamTransport_OpenAIH2_EnablesPingHealthCheck(t *testing.T) {
tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeOpenAIH2)
// long_stream_h2 模式构建的 Transport 必须带上 H2 PING 健康探测,从源头剔除死连接。
func TestBuildUpstreamTransport_LongStreamH2_EnablesPingHealthCheck(t *testing.T) {
tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeLongStreamH2)
require.NoError(t, err)
require.True(t, tr.ForceAttemptHTTP2, "openai_h2 必须启用 HTTP/2")
requireHTTP2Configured(t, tr, "openai_h2 必须显式配置 http2 以启用 ReadIdleTimeout")
require.True(t, tr.ForceAttemptHTTP2, "long_stream_h2 必须启用 HTTP/2")
requireHTTP2Configured(t, tr, "long_stream_h2 必须显式配置 http2 以启用 ReadIdleTimeout")
}
// 非 H2 模式(default/h1)不应因本次改动被误配置:default 走 Go 自动 H2(惰性配置,
// 构建时 Protocols/TLSNextProto 仍为空),h1 模式显式禁用 H2。避免波及 Claude/Gemini 热路径。
func TestBuildUpstreamTransport_NonOpenAIH2_NotEagerlyConfigured(t *testing.T) {
func TestBuildUpstreamTransport_NonLongStreamH2_NotEagerlyConfigured(t *testing.T) {
tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeDefault)
require.NoError(t, err)
require.Nil(t, tr.Protocols, "default 模式不应在构建期主动配置 http2 keepalive")
require.Nil(t, tr.TLSNextProto["h2"], "default 模式不应在构建期主动配置 http2 keepalive")
}
// openai_h2 模式构建的 Transport 必须真正以 HTTP/2 与上游通信,PING 健康探测才有载体:
// 自定义 DialContext 下 Go 不会自动启用 H2,全靠 enableOpenAIHTTP2KeepAlive 的显式配置。
func TestBuildUpstreamTransport_OpenAIH2_NegotiatesHTTP2(t *testing.T) {
// long_stream_h2 模式构建的 Transport 必须真正以 HTTP/2 与上游通信,PING 健康探测才有载体:
// 自定义 DialContext 下 Go 不会自动启用 H2,全靠 enableHTTP2KeepAlive 的显式配置。
func TestBuildUpstreamTransport_LongStreamH2_NegotiatesHTTP2(t *testing.T) {
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
@@ -77,7 +77,7 @@ func TestBuildUpstreamTransport_OpenAIH2_NegotiatesHTTP2(t *testing.T) {
srv.StartTLS()
defer srv.Close()
tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeOpenAIH2)
tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeLongStreamH2)
require.NoError(t, err)
defer tr.CloseIdleConnections()
require.NotNil(t, tr.TLSClientConfig)
@@ -91,18 +91,18 @@ func TestBuildUpstreamTransport_OpenAIH2_NegotiatesHTTP2(t *testing.T) {
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 2, resp.ProtoMajor, "openai_h2 必须协商到 HTTP/2")
require.Equal(t, 2, resp.ProtoMajor, "long_stream_h2 必须协商到 HTTP/2")
}
// 死连接在经 HTTP 代理(CONNECT 隧道)时最高发,这是带 proxy 账号的真实生产路径:
// 显式 http2 配置须与 Transport.Proxy 同时正确生效,不能相互干扰。
func TestBuildUpstreamTransport_OpenAIH2_WithHTTPProxy_EnablesKeepAlive(t *testing.T) {
func TestBuildUpstreamTransport_LongStreamH2_WithHTTPProxy_EnablesKeepAlive(t *testing.T) {
proxyURL, err := url.Parse("http://127.0.0.1:8080")
require.NoError(t, err)
tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), proxyURL, upstreamProtocolModeOpenAIH2)
tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), proxyURL, upstreamProtocolModeLongStreamH2)
require.NoError(t, err)
require.True(t, tr.ForceAttemptHTTP2)
requireHTTP2Configured(t, tr, "经代理的 openai_h2 也必须启用 http2 keepalive")
requireHTTP2Configured(t, tr, "经代理的 long_stream_h2 也必须启用 http2 keepalive")
require.NotNil(t, tr.Proxy, "HTTP 代理仍须通过 Transport.Proxy 生效")
}
@@ -629,6 +629,24 @@ func (s *HTTPUpstreamSuite) TestOpenAIProfileDefaultsToHTTP2AndNoHeaderTimeout()
require.Equal(s.T(), upstreamProtocolModeOpenAIH2, entry.protocolMode)
}
func (s *HTTPUpstreamSuite) TestLongStreamProfileUsesSharedHTTP2KeepAlive() {
s.cfg.Gateway = config.GatewayConfig{
ResponseHeaderTimeout: 600,
OpenAIHTTP2: config.GatewayOpenAIHTTP2Config{
Enabled: false,
},
}
svc := s.newService()
entry, err := svc.getClientEntry("", 1, 1, service.HTTPUpstreamProfileLongStream, false, false)
require.NoError(s.T(), err)
transport, ok := entry.client.Transport.(*http.Transport)
require.True(s.T(), ok, "expected *http.Transport")
require.Equal(s.T(), 600*time.Second, transport.ResponseHeaderTimeout, "long-stream profile should retain the generic header timeout")
require.True(s.T(), transport.ForceAttemptHTTP2, "long-stream profile must enable HTTP/2 independently of OpenAI settings")
require.True(s.T(), transport.Protocols.HTTP2(), "long-stream profile must install HTTP/2 PING health checks")
require.Equal(s.T(), upstreamProtocolModeLongStreamH2, entry.protocolMode)
}
func (s *HTTPUpstreamSuite) TestOpenAIProfileCustomHeaderTimeout() {
s.cfg.Gateway = config.GatewayConfig{
ResponseHeaderTimeout: 600,
@@ -0,0 +1,68 @@
//go:build integration
package repository
import "github.com/Wei-Shaw/sub2api/internal/service"
func (s *ProxyExpirySuite) TestBackupReferencesAreDirectedAndShareable() {
backupID := s.mkProxy("shared-backup", service.FallbackModeNone, nil, nil)
firstID := s.mkProxy("first-primary", service.FallbackModeProxy, nil, &backupID)
backup, err := s.repo.GetByID(s.ctx, backupID)
s.Require().NoError(err)
s.Require().Nil(backup.BackupProxyID, "assigning a backup must not overwrite its own configuration")
secondID := s.mkProxy("second-primary", service.FallbackModeProxy, nil, &backupID)
// Updating the shared backup must not clear its incoming references.
backup.Name = "renamed-shared-backup"
s.Require().NoError(s.repo.Update(s.ctx, backup))
for _, id := range []int64{firstID, secondID} {
primary, err := s.repo.GetByID(s.ctx, id)
s.Require().NoError(err)
s.Require().Equal(&backupID, primary.BackupProxyID)
}
backup, err = s.repo.GetByID(s.ctx, backupID)
s.Require().NoError(err)
s.Require().Nil(backup.BackupProxyID)
}
func (s *ProxyExpirySuite) TestBackupUpdateAndClearLeaveOtherReferencesIntact() {
backupID := s.mkProxy("old-backup", service.FallbackModeNone, nil, nil)
replacementID := s.mkProxy("new-backup", service.FallbackModeNone, nil, nil)
firstID := s.mkProxy("primary-to-change", service.FallbackModeProxy, nil, &backupID)
secondID := s.mkProxy("primary-to-keep", service.FallbackModeProxy, nil, &backupID)
first, err := s.repo.GetByID(s.ctx, firstID)
s.Require().NoError(err)
first.BackupProxyID = &replacementID
s.Require().NoError(s.repo.Update(s.ctx, first))
first, err = s.repo.GetByID(s.ctx, firstID)
s.Require().NoError(err)
s.Require().Equal(&replacementID, first.BackupProxyID)
first.BackupProxyID = nil
first.FallbackMode = service.FallbackModeNone
s.Require().NoError(s.repo.Update(s.ctx, first))
first, err = s.repo.GetByID(s.ctx, firstID)
s.Require().NoError(err)
s.Require().Nil(first.BackupProxyID)
second, err := s.repo.GetByID(s.ctx, secondID)
s.Require().NoError(err)
s.Require().Equal(&backupID, second.BackupProxyID)
for _, id := range []int64{backupID, replacementID} {
backup, err := s.repo.GetByID(s.ctx, id)
s.Require().NoError(err)
s.Require().Nil(backup.BackupProxyID)
}
}
func (s *ProxyExpirySuite) TestBackupReferencesSupportDirectedChains() {
lastID := s.mkProxy("chain-last", service.FallbackModeNone, nil, nil)
middleID := s.mkProxy("chain-middle", service.FallbackModeProxy, nil, &lastID)
firstID := s.mkProxy("chain-first", service.FallbackModeProxy, nil, &middleID)
for _, pair := range [][2]int64{{firstID, middleID}, {middleID, lastID}} {
primary, err := s.repo.GetByID(s.ctx, pair[0])
s.Require().NoError(err)
s.Require().Equal(&pair[1], primary.BackupProxyID)
}
last, err := s.repo.GetByID(s.ctx, lastID)
s.Require().NoError(err)
s.Require().Nil(last.BackupProxyID)
}
@@ -0,0 +1,69 @@
//go:build integration
package repository
import (
"encoding/json"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func (s *ProxyExpirySuite) TestSweep_RepeatedExpiryPreservesOriginalProxy() {
for _, direct := range []bool{false, true} {
s.Run(map[bool]string{false: "next backup", true: "direct"}[direct], func() {
now := time.Now()
past := now.Add(-time.Hour)
soon := now.Add(time.Hour)
later := now.Add(48 * time.Hour)
originalID := s.mkProxy("repeated-original", service.FallbackModeProxy, &past, nil)
middleID := s.mkProxy("repeated-middle", service.FallbackModeDirect, &soon, nil)
lastID := s.mkProxy("repeated-last", service.FallbackModeNone, &later, nil)
// Populate directed foreign keys directly so this regression does not depend
// on the separate ORM backup-edge fix.
_, err := s.tx.ExecContext(s.ctx, `UPDATE proxies SET backup_proxy_id=$1 WHERE id=$2`, middleID, originalID)
s.Require().NoError(err)
if !direct {
_, err = s.tx.ExecContext(s.ctx, `UPDATE proxies SET fallback_mode='proxy',backup_proxy_id=$1 WHERE id=$2`, lastID, middleID)
s.Require().NoError(err)
}
accountID := s.mkAccountWithProxy(originalID)
changed, err := s.repo.SweepExpiredProxies(s.ctx, now)
s.Require().NoError(err)
s.Require().EqualValues(1, changed)
s.Require().Equal(&middleID, s.accountProxyID(accountID))
changed, err = s.repo.SweepExpiredProxies(s.ctx, now.Add(2*time.Hour))
s.Require().NoError(err)
s.Require().EqualValues(1, changed, "accounts already in fallback must still be rerouted")
if direct {
s.Require().Nil(s.accountProxyID(accountID))
} else {
s.Require().Equal(&lastID, s.accountProxyID(accountID))
}
var origin *int64
err = scanSingleRow(s.ctx, s.tx, `SELECT proxy_fallback_origin_id FROM accounts WHERE id=$1`, []any{accountID}, &origin)
s.Require().NoError(err)
s.Require().Equal(&originalID, origin, "manual revert must retain the first original proxy")
var payloadRaw []byte
err = scanSingleRow(s.ctx, s.tx, `SELECT payload FROM scheduler_outbox WHERE event_type=$1 ORDER BY id DESC LIMIT 1`, []any{service.SchedulerOutboxEventAccountBulkChanged}, &payloadRaw)
s.Require().NoError(err)
var payload struct {
AccountIDs []int64 `json:"account_ids"`
}
s.Require().NoError(json.Unmarshal(payloadRaw, &payload))
s.Require().Equal([]int64{accountID}, payload.AccountIDs)
changed, err = s.repo.SweepExpiredProxies(s.ctx, now.Add(3*time.Hour))
s.Require().NoError(err)
s.Require().Zero(changed, "repeating the scan must be idempotent")
accounts := newAccountRepositoryWithSQL(s.tx.Client(), s.tx, nil)
s.Require().NoError(accounts.RevertProxyFallback(s.ctx, accountID))
s.Require().Equal(&originalID, s.accountProxyID(accountID))
err = scanSingleRow(s.ctx, s.tx, `SELECT proxy_fallback_origin_id FROM accounts WHERE id=$1`, []any{accountID}, &origin)
s.Require().NoError(err)
s.Require().Nil(origin)
})
}
}
+6 -4
View File
@@ -742,27 +742,29 @@ func (r *proxyRepository) sweepOneExpiredProxyOnExec(ctx context.Context, exec s
rows *sql.Rows
err error
)
// Match the current proxy even after an earlier fallback. Keep the first
// origin so manual revert still restores the originally assigned proxy.
if target == nil {
rows, err = exec.QueryContext(ctx, `
UPDATE accounts SET proxy_id=NULL, proxy_fallback_origin_id=$1,
UPDATE accounts SET proxy_id=NULL, proxy_fallback_origin_id=COALESCE(proxy_fallback_origin_id,$1),
extra=CASE
WHEN type='apikey' AND extra ? 'upstream_billing_probe'
THEN extra - 'upstream_billing_probe'
ELSE extra
END,
updated_at=NOW()
WHERE proxy_id=$1 AND proxy_fallback_origin_id IS NULL AND deleted_at IS NULL
WHERE proxy_id=$1 AND deleted_at IS NULL
RETURNING id`, proxyID)
} else {
rows, err = exec.QueryContext(ctx, `
UPDATE accounts SET proxy_id=$2, proxy_fallback_origin_id=$1,
UPDATE accounts SET proxy_id=$2, proxy_fallback_origin_id=COALESCE(proxy_fallback_origin_id,$1),
extra=CASE
WHEN type='apikey' AND extra ? 'upstream_billing_probe'
THEN extra - 'upstream_billing_probe'
ELSE extra
END,
updated_at=NOW()
WHERE proxy_id=$1 AND proxy_fallback_origin_id IS NULL AND deleted_at IS NULL
WHERE proxy_id=$1 AND deleted_at IS NULL
RETURNING id`, proxyID, *target)
}
if err != nil {
@@ -29,11 +29,8 @@ func TestProxyUpdateInvalidatesBoundProbeSnapshotsAndEnqueuesOutboxAtomically(t
WillReturnRows(sqlmock.NewRows([]string{"protocol", "host", "port", "username", "password", "status"}).
AddRow("http", "old.example", 8080, "user", "pass", service.StatusActive))
mock.ExpectExec(`(?s)UPDATE "proxies" SET`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE "proxies" SET "backup_proxy_id" = NULL WHERE "backup_proxy_id" = \$1`).
WithArgs(int64(9)).
WillReturnResult(sqlmock.NewResult(0, 0))
expectProxyUpdateReload(mock, 9, "new.example", "user", "pass")
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
WithArgs(int64(9)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)).AddRow(int64(18)))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")).
@@ -72,11 +69,8 @@ func TestProxyUpdateRollsBackWhenProbeInvalidationOutboxFails(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{"protocol", "host", "port", "username", "password", "status"}).
AddRow("http", "old.example", 8080, "", "", service.StatusActive))
mock.ExpectExec(`(?s)UPDATE "proxies" SET`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE "proxies" SET "backup_proxy_id" = NULL WHERE "backup_proxy_id" = \$1`).
WithArgs(int64(9)).
WillReturnResult(sqlmock.NewResult(0, 0))
expectProxyUpdateReload(mock, 9, "new.example", "", "")
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
WithArgs(int64(9)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")).
@@ -105,9 +99,6 @@ func TestProxyUpdateSkipsProbeInvalidationForNonIdentityChange(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{"protocol", "host", "port", "username", "password", "status"}).
AddRow("http", "same.example", 8080, "", "", service.StatusActive))
mock.ExpectExec(`(?s)UPDATE "proxies" SET`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE "proxies" SET "backup_proxy_id" = NULL WHERE "backup_proxy_id" = \$1`).
WithArgs(int64(9)).
WillReturnResult(sqlmock.NewResult(0, 0))
expectProxyUpdateReload(mock, 9, "same.example", "", "")
mock.ExpectCommit()
@@ -186,10 +186,12 @@ func (c *userMsgQueueCache) ReconcileExpiredLockCandidates(ctx context.Context,
if err != nil {
return 0, err
}
members, err := c.rdb.ZRangeByScore(ctx, umqLockIndexKey, &redis.ZRangeBy{
Min: "-inf",
Max: strconv.FormatInt(nowMs, 10),
Count: int64(maxCount),
members, err := c.rdb.ZRangeArgs(ctx, redis.ZRangeArgs{
Key: umqLockIndexKey,
Start: "-inf",
Stop: strconv.FormatInt(nowMs, 10),
ByScore: true,
Count: int64(maxCount),
}).Result()
if err != nil {
return 0, fmt.Errorf("umq read lock index: %w", err)
@@ -116,11 +116,12 @@ func TestUserPlatformQuotaRepository_BulkInsertInitial_CNProvidersAllowed(t *tes
{UserID: userID, Platform: "kimi", DailyLimitUSD: &daily},
{UserID: userID, Platform: "zhipu"},
{UserID: userID, Platform: "deepseek"},
{UserID: userID, Platform: "minimax"},
}
require.NoError(t, repo.BulkInsertInitial(txCtx, records),
"kimi/zhipu/deepseek 平台应可写入(迁移 224 后 CHECK 约束已含国产供应商)")
"kimi/zhipu/deepseek/minimax 平台应可写入(CHECK 约束已含国产供应商)")
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax"} {
rec, err := repo.GetByUserPlatform(txCtx, userID, platform)
require.NoError(t, err)
require.NotNil(t, rec, "%s 配额行应已写入", platform)
+1
View File
@@ -137,6 +137,7 @@ var ProviderSet = wire.NewSet(
NewRefreshTokenCache,
NewErrorPassthroughCache,
NewTLSFingerprintProfileCache,
NewChannelCache,
NewContentModerationHashCache,
// Encryptors
+4 -2
View File
@@ -863,7 +863,7 @@ func TestAPIContracts(t *testing.T) {
"force_email_on_third_party_signup": false,
"default_concurrency": 5,
"default_balance": 1.25,
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"auth_source_default_email_platform_quotas": null,
"auth_source_default_github_platform_quotas": null,
"auth_source_default_google_platform_quotas": null,
@@ -992,6 +992,7 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_mode": "v1",
"channel_monitor_hide_throughput": true,
"channel_monitor_show_quota": false,
"channel_monitor_hide_user_ranking": false,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
"model_plaza_enabled": false,
@@ -1180,7 +1181,7 @@ func TestAPIContracts(t *testing.T) {
"purchase_subscription_url": "",
"table_default_page_size": 20,
"table_page_size_options": [10, 20, 50],
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"auth_source_default_email_platform_quotas": null,
"auth_source_default_github_platform_quotas": null,
"auth_source_default_google_platform_quotas": null,
@@ -1305,6 +1306,7 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_mode": "v1",
"channel_monitor_hide_throughput": true,
"channel_monitor_show_quota": false,
"channel_monitor_hide_user_ranking": false,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
"model_plaza_enabled": false,
+2
View File
@@ -372,6 +372,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS)
accounts.PUT("/:id", h.Admin.Account.Update)
accounts.GET("/:id/grok-media-eligibility", h.Admin.Account.GetGrokMediaEligibility)
accounts.PUT("/:id/grok-media-eligibility", h.Admin.Account.UpdateGrokMediaEligibility)
accounts.PUT("/:id/upstream-billing-probe", h.Admin.Account.SetUpstreamBillingProbeEnabled)
accounts.POST("/:id/upstream-billing-probe", h.Admin.Account.ProbeUpstreamBilling)
accounts.GET("/:id/ollama-cloud-usage", h.Admin.Account.GetOllamaCloudUsage)
+3 -3
View File
@@ -48,8 +48,8 @@ func RegisterGatewayRoutes(
isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformGrok,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
// 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 openai/grok 一样经 OpenAI 网关转发。
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax:
// 国产 OpenAI 兼容供应商与 openai/grok 一样经 OpenAI 网关转发。
return true
default:
return false
@@ -57,7 +57,7 @@ func RegisterGatewayRoutes(
}
countTokensHandler := func(c *gin.Context) {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax:
h.OpenAIGateway.CountTokens(c)
case service.PlatformGrok:
h.OpenAIGateway.GrokCountTokens(c)
+26 -9
View File
@@ -285,17 +285,20 @@ func (a *Account) IsDeepseek() bool {
return a.Platform == PlatformDeepseek
}
// IsCNProvider 报告是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。
func (a *Account) IsMiniMax() bool {
return a.Platform == PlatformMiniMax
}
// IsCNProvider 报告是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek/minimax)。
func (a *Account) IsCNProvider() bool {
return a != nil && IsCNProvider(a.Platform)
}
// IsOpenAICompatible 报告账号是否走 OpenAI 网关(OpenAI 协议族)。
// openai/grok 原生走 OpenAI 网关;kimi/zhipu/deepseek 同为 OpenAI Chat Completions
// openai/grok 原生走 OpenAI 网关;国产供应商同为 OpenAI Chat Completions
// 兼容上游,也经 OpenAI 网关转发。
func (a *Account) IsOpenAICompatible() bool {
return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok ||
a.Platform == PlatformKimi || a.Platform == PlatformZhipu || a.Platform == PlatformDeepseek)
return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok || a.IsCNProvider())
}
func (a *Account) GeminiOAuthType() string {
@@ -1362,6 +1365,8 @@ func (a *Account) GetOpenAIBaseURL() string {
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
case PlatformMiniMax:
return DefaultMiniMaxBaseURL
default:
return "https://api.openai.com"
}
@@ -1387,7 +1392,7 @@ func (a *Account) IsCodingPlan() bool {
// GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于
// credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions
// (与既有行为完全一致)。responses 协议仅 deepseek / kimi 支持(官方原生
// (与既有行为完全一致)。responses 协议仅 deepseek / kimi / minimax 支持(官方原生
// Responses 端点,适配 Codex);zhipu 无此端点。
func (a *Account) GetAPIProtocol() string {
if a == nil || !a.IsCNProvider() {
@@ -1410,13 +1415,13 @@ func (a *Account) GetAPIProtocol() string {
// SupportsNativeCNResponses 报告该国产供应商是否提供原生 Responses 端点。
// DeepSeek 官方为 /responses(无 /v1);Kimi 按量付费与 Coding Plan 均为
// /v1/responses(moonshot.cn / kimi.com/coding)。
// /v1/responses(moonshot.cn / kimi.com/coding);MiniMax 为 /v1/responses。
func (a *Account) SupportsNativeCNResponses() bool {
if a == nil {
return false
}
switch a.Platform {
case PlatformDeepseek, PlatformKimi:
case PlatformDeepseek, PlatformKimi, PlatformMiniMax:
return true
default:
return false
@@ -1477,6 +1482,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
return DefaultZhipuAnthropicBaseURL
case PlatformDeepseek:
return DefaultDeepseekAnthropicBaseURL
case PlatformMiniMax:
return DefaultMiniMaxAnthropicBaseURL
}
case APIProtocolChatCompletions, APIProtocolResponses:
switch a.Platform {
@@ -1492,6 +1499,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
case PlatformMiniMax:
return DefaultMiniMaxBaseURL
}
}
return ""
@@ -1528,6 +1537,8 @@ func (a *Account) GetAnthropicProtocolBaseURL() string {
return DefaultZhipuAnthropicBaseURL
case PlatformDeepseek:
return DefaultDeepseekAnthropicBaseURL
case PlatformMiniMax:
return DefaultMiniMaxAnthropicBaseURL
default:
return ""
}
@@ -1555,6 +1566,8 @@ func (a *Account) GetOpenAIFormatBaseURL() string {
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
case PlatformMiniMax:
return DefaultMiniMaxBaseURL
default:
return a.GetOpenAIBaseURL()
}
@@ -1569,9 +1582,9 @@ func (a *Account) GetCNAPIKey() string {
return a.GetCredential("api_key")
}
// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu),
// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu / minimax),
// 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。
// 判定规则与 cc-switch coding_plan.rs::detect_provider 保持一致。
// 只认官方域名:自定义中转不得把第三方 Key 发往厂商官方额度端点。
func (a *Account) GetCodingPlanProvider() string {
if a == nil || a.GetAccountMode() != AccountModeCoding {
return ""
@@ -1582,6 +1595,10 @@ func (a *Account) GetCodingPlanProvider() string {
return PlatformKimi
case strings.Contains(baseURL, "bigmodel.cn"), strings.Contains(baseURL, "api.z.ai"):
return PlatformZhipu
case strings.Contains(baseURL, "minimax.io"),
strings.Contains(baseURL, "minimaxi.com"),
strings.Contains(baseURL, "minimax.com"):
return PlatformMiniMax
default:
return ""
}
@@ -78,7 +78,7 @@ func (a *Account) IsHeaderOverrideEligible() bool {
return false
}
switch a.Platform {
case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek:
case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
return a.Type == AccountTypeAPIKey
case PlatformGrok:
return a.Type == AccountTypeAPIKey || a.Type == AccountTypeOAuth
@@ -58,10 +58,8 @@ func EvaluateAccountSchedulingThreshold(account *Account, thresholds map[string]
winner = pickLatestResetSchedulingCandidate(anthropicThresholdCandidates(account), threshold, now)
case PlatformGrok:
winner = pickLatestResetSchedulingCandidate(grokThresholdCandidates(account), threshold, now)
case PlatformKimi:
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformKimi), threshold, now)
case PlatformZhipu:
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformZhipu), threshold, now)
case PlatformKimi, PlatformZhipu, PlatformMiniMax:
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, decision.Platform), threshold, now)
default:
return decision
}
+1 -1
View File
@@ -515,7 +515,7 @@ func (s *AccountService) TestCredentials(ctx context.Context, id int64) error {
case PlatformGrok:
// Grok OAuth credentials are validated via token exchange/refresh and request-path probes.
return nil
case PlatformKimi, PlatformZhipu, PlatformDeepseek:
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
// 国产 OpenAI 兼容供应商:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。
return nil
default:
@@ -19,12 +19,19 @@ func TestFetchOpenAIAccountModelsOAuthPopulatesPickerFields(t *testing.T) {
require.NoError(t, err)
models, err := svc.FetchOpenAIAccountModels(ctx, account)
require.NoError(t, err)
require.Len(t, models, 2)
require.Greater(t, len(models), 2)
for i, id := range []string{"new-oauth-model", "gpt-6-astra"} {
require.Equal(t, id, models[i].ID)
require.Equal(t, id, models[i].DisplayName)
require.Equal(t, "model", models[i].Type)
}
ids := make([]string, 0, len(models))
for _, model := range models {
ids = append(ids, model.ID)
}
require.Contains(t, ids, "gpt-image-2.5-flare")
require.Contains(t, ids, "gpt-image-2.5-sunburst")
after, err := gateway.FetchOpenAIModelsList(ctx, account)
require.NoError(t, err)
require.Equal(t, before.Body, after.Body, "picker fields must not change the shared catalog")
@@ -62,3 +69,18 @@ func TestFetchOpenAIAccountModelsPreservesEmptyCatalog(t *testing.T) {
require.NoError(t, err)
require.Empty(t, models, "an empty upstream catalog must not become a static model list")
}
func TestFetchOpenAIAccountModelsOAuthRespectsImageAllowlist(t *testing.T) {
newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-6-astra"}]}`)
svc := &AccountTestService{openaiGatewayService: &OpenAIGatewayService{}}
account := newCodexModelsTestAccount()
account.Credentials["model_mapping"] = map[string]any{"gpt-image-2.5-flare": "gpt-image-2.5-flare"}
models, err := svc.FetchOpenAIAccountModels(context.Background(), account)
require.NoError(t, err)
ids := []string{}
for _, model := range models {
ids = append(ids, model.ID)
}
require.Contains(t, ids, "gpt-image-2.5-flare")
require.NotContains(t, ids, "gpt-image-2.5-sunburst")
}
@@ -203,6 +203,26 @@ func (s *AccountTestService) FetchOpenAIAccountModels(ctx context.Context, accou
model.Type = "model"
}
}
// Codex discovery lists Responses drivers, not image_generation tool models.
// Add locally supported image choices only to the OAuth test picker; keep the
// shared upstream catalog and API-key discovery authoritative.
if account != nil && account.IsOpenAIOAuthLike() {
seen := make(map[string]bool, len(payload.Data))
for _, model := range payload.Data {
seen[model.ID] = true
}
for _, model := range openai.DefaultModels {
if IsGPTImageGenerationModel(model.ID) && account.IsModelSupported(model.ID) && !seen[model.ID] {
payload.Data = append(payload.Data, model)
seen[model.ID] = true
}
}
for model := range account.GetModelMapping() {
if IsGPTImageGenerationModel(model) && !strings.Contains(model, "*") && !seen[model] {
payload.Data = append(payload.Data, openai.Model{ID: model, Object: "model", Type: "model", OwnedBy: "openai", DisplayName: model})
}
}
}
return payload.Data, nil
}
@@ -3031,6 +3051,8 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
}
applyOpenAIImagesDefaults(parsed)
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Responses driver: %s; image model: %s\n", openAIImagesResponsesMainModelValue(), parsed.Model)})
responsesBody, err := buildOpenAIImagesResponsesRequest(parsed, parsed.Model)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to build image request: %s", err.Error()))
@@ -3104,6 +3126,12 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse image response: %s", err.Error()))
}
if len(results) == 0 {
if upstreamErr := extractOpenAIImagesUpstreamError(body); upstreamErr != nil {
return s.sendErrorAndEnd(c, upstreamErr.clientMessage())
}
if textErr := openAIImagesTextFallbackError(body); textErr != nil {
return s.sendErrorAndEnd(c, textErr.clientMessage())
}
return s.sendErrorAndEnd(c, "No images returned from responses API")
}
@@ -11,9 +11,11 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *testing.T) {
t.Setenv("SUB2API_IMAGES_MAIN_MODEL", "gpt-5.6-sol")
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -43,9 +45,11 @@ func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *tes
},
}
err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-2", "draw a cat")
err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-2.5-flare", "draw a cat")
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "gpt-image-2.5-flare", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
require.Contains(t, rec.Body.String(), "Calling Codex /responses image tool")
require.Contains(t, rec.Body.String(), "data:image/png;base64,aGVsbG8=")
@@ -91,3 +95,18 @@ func TestAccountTestService_OpenAIImageAPIKeyUsesConfiguredV1BaseURL(t *testing.
require.Contains(t, rec.Body.String(), "data:image/png;base64,aGVsbG8=")
require.Contains(t, rec.Body.String(), "\"success\":true")
}
func TestAccountTestService_OpenAIImageOAuthSurfacesSSEError(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", nil)
svc := &AccountTestService{httpUpstream: &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The selected image model is unavailable\"}}\n\n")),
}}}
account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"access_token": "test"}}
err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-2.5-flare", "draw a cup")
require.ErrorContains(t, err, "The selected image model is unavailable")
require.NotContains(t, rec.Body.String(), "No images returned")
}
+1 -1
View File
@@ -314,7 +314,7 @@ func defaultAllowImageGenerationForPlatform(platform string) bool {
func compositeDefaultModelsListCandidateIDs() []string {
seen := make(map[string]struct{})
ids := make([]string, 0)
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
for _, id := range defaultModelsListCandidateIDs(platform) {
if _, ok := seen[id]; ok {
continue
+29 -17
View File
@@ -56,6 +56,9 @@ func (s *adminServiceImpl) GetProxiesByIDs(ctx context.Context, ids []int64) ([]
}
func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error) {
if !isJSONTimeInRange(input.ExpiresAt) {
return nil, infraerrors.BadRequest("PROXY_EXPIRY_INVALID", "proxy expiry year must be between 0 and 9999")
}
// 规范化 fallback_mode
mode := input.FallbackMode
if mode == "" {
@@ -91,28 +94,34 @@ func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyIn
}
func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *UpdateProxyInput) (*Proxy, error) {
if !isJSONTimeInRange(input.ExpiresAt) {
return nil, infraerrors.BadRequest("PROXY_EXPIRY_INVALID", "proxy expiry year must be between 0 and 9999")
}
// 校验:backup_proxy_id 不能是自身
if input.BackupProxyID != nil && *input.BackupProxyID == id {
return nil, infraerrors.BadRequest("PROXY_BACKUP_SELF", "backup proxy cannot be itself")
}
// 规范化 fallback_mode
mode := input.FallbackMode
if mode == "" {
mode = FallbackModeNone
}
// 校验:mode=proxy 必须有 backup
if mode == FallbackModeProxy && input.BackupProxyID == nil {
return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy")
}
if input.ExpiryWarnDays < 0 {
return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0")
}
proxy, err := s.proxyRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
// Merge only supplied fields, then validate the resulting fallback configuration.
mode := proxy.FallbackMode
if input.FallbackMode != "" {
mode = input.FallbackMode
}
backupID := proxy.BackupProxyID
if input.BackupProxyID != nil || input.ClearBackupID {
backupID = input.BackupProxyID
}
if mode == FallbackModeProxy && backupID == nil {
return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy")
}
if input.ExpiryWarnDays != nil && *input.ExpiryWarnDays < 0 {
return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0")
}
if input.Name != "" {
proxy.Name = input.Name
}
@@ -134,11 +143,14 @@ func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *Upd
if input.Status != "" {
proxy.Status = input.Status
}
// 透传有效期与回退字段
proxy.ExpiresAt = input.ExpiresAt
if input.ExpiresAt != nil || input.ClearExpiresAt {
proxy.ExpiresAt = input.ExpiresAt
}
proxy.FallbackMode = mode
proxy.BackupProxyID = input.BackupProxyID
proxy.ExpiryWarnDays = input.ExpiryWarnDays
proxy.BackupProxyID = backupID
if input.ExpiryWarnDays != nil {
proxy.ExpiryWarnDays = *input.ExpiryWarnDays
}
if err := s.proxyRepo.Update(ctx, proxy); err != nil {
return nil, err
@@ -0,0 +1,41 @@
//go:build unit
package service
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestAdminProxyRejectsOutOfRangeExpiry(t *testing.T) {
for _, year := range []int{-1, 10000} {
expiry := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
t.Run(expiry.String(), func(t *testing.T) {
// A nil repository ensures rejection happens before any persistence or probe.
svc := &adminServiceImpl{}
_, err := svc.CreateProxy(context.Background(), &CreateProxyInput{ExpiresAt: &expiry})
require.Error(t, err)
require.Contains(t, err.Error(), "proxy expiry year")
_, err = svc.UpdateProxy(context.Background(), 1, &UpdateProxyInput{ExpiresAt: &expiry})
require.Error(t, err)
require.Contains(t, err.Error(), "proxy expiry year")
})
}
}
func TestAdminProxyAcceptsJSONExpiryBoundaries(t *testing.T) {
for _, year := range []int{0, 9999} {
expiry := time.Date(year, 12, 31, 23, 59, 59, 0, time.UTC)
repo := &updatingProxyRepoStub{proxyRepoStub: &proxyRepoStub{}, proxy: &Proxy{ID: 9}}
svc := &adminServiceImpl{proxyRepo: repo}
got, err := svc.UpdateProxy(context.Background(), 9, &UpdateProxyInput{ExpiresAt: &expiry})
require.NoError(t, err)
_, err = json.Marshal(got)
require.NoError(t, err)
require.Equal(t, &expiry, repo.proxy.ExpiresAt)
}
}
@@ -0,0 +1,81 @@
//go:build unit
package service
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestAdminProxyPartialUpdatePreservesOmittedSettings(t *testing.T) {
for _, input := range []*UpdateProxyInput{{Status: "inactive"}, {Name: "renamed"}, {Host: "new.example"}} {
expiry := time.Now().Add(24 * time.Hour)
backup := int64(10)
original := &Proxy{ID: 9, Name: "original", Host: "old.example", Status: StatusActive, ExpiresAt: &expiry, FallbackMode: FallbackModeProxy, BackupProxyID: &backup, ExpiryWarnDays: 7}
repo := &updatingProxyRepoStub{proxyRepoStub: &proxyRepoStub{}, proxy: original}
svc := &adminServiceImpl{proxyRepo: repo}
got, err := svc.UpdateProxy(context.Background(), 9, input)
require.NoError(t, err)
require.Equal(t, original.ExpiresAt, got.ExpiresAt)
require.Equal(t, original.FallbackMode, got.FallbackMode)
require.Equal(t, original.BackupProxyID, got.BackupProxyID)
require.Equal(t, original.ExpiryWarnDays, got.ExpiryWarnDays)
require.Equal(t, 1, repo.updateCalls)
}
}
func TestAdminProxyPartialUpdateClearsAndSetsSettings(t *testing.T) {
expiry := time.Now().Add(time.Hour)
backup := int64(10)
zero := 0
repo := &updatingProxyRepoStub{proxyRepoStub: &proxyRepoStub{}, proxy: &Proxy{ID: 9, ExpiresAt: &expiry, FallbackMode: FallbackModeProxy, BackupProxyID: &backup, ExpiryWarnDays: 7}}
svc := &adminServiceImpl{proxyRepo: repo}
got, err := svc.UpdateProxy(context.Background(), 9, &UpdateProxyInput{
ClearExpiresAt: true, FallbackMode: FallbackModeNone, ClearBackupID: true, ExpiryWarnDays: &zero,
})
require.NoError(t, err)
require.Nil(t, got.ExpiresAt)
require.Nil(t, got.BackupProxyID)
require.Equal(t, FallbackModeNone, got.FallbackMode)
require.Zero(t, got.ExpiryWarnDays)
days := 3
got, err = svc.UpdateProxy(context.Background(), 9, &UpdateProxyInput{
ExpiresAt: &expiry, FallbackMode: FallbackModeProxy, BackupProxyID: &backup, ExpiryWarnDays: &days,
})
require.NoError(t, err)
require.Equal(t, &expiry, got.ExpiresAt)
require.Equal(t, &backup, got.BackupProxyID)
require.Equal(t, FallbackModeProxy, got.FallbackMode)
require.Equal(t, days, got.ExpiryWarnDays)
}
func TestAdminProxyPartialUpdateValidatesMergedFallback(t *testing.T) {
backup := int64(10)
self := int64(9)
negative := -1
for _, tc := range []struct {
name string
input UpdateProxyInput
wantError bool
}{
{name: "reuse existing backup", input: UpdateProxyInput{FallbackMode: FallbackModeProxy}},
{name: "clear required backup", input: UpdateProxyInput{ClearBackupID: true}, wantError: true},
{name: "self backup", input: UpdateProxyInput{BackupProxyID: &self}, wantError: true},
{name: "negative warning", input: UpdateProxyInput{ExpiryWarnDays: &negative}, wantError: true},
} {
t.Run(tc.name, func(t *testing.T) {
repo := &updatingProxyRepoStub{proxyRepoStub: &proxyRepoStub{}, proxy: &Proxy{ID: 9, FallbackMode: FallbackModeProxy, BackupProxyID: &backup}}
svc := &adminServiceImpl{proxyRepo: repo}
_, err := svc.UpdateProxy(context.Background(), 9, &tc.input)
if tc.wantError {
require.Error(t, err)
require.Zero(t, repo.updateCalls)
} else {
require.NoError(t, err)
}
})
}
}
+5 -1
View File
@@ -534,6 +534,8 @@ type CreateProxyInput struct {
ExpiryWarnDays int
}
// UpdateProxyInput preserves omitted expiry/backup values; Clear flags explicitly
// remove them. A nil ExpiryWarnDays preserves the current warning period.
type UpdateProxyInput struct {
Name string
Protocol string
@@ -543,9 +545,11 @@ type UpdateProxyInput struct {
Password string
Status string
ExpiresAt *time.Time
ClearExpiresAt bool
FallbackMode string
BackupProxyID *int64
ExpiryWarnDays int
ClearBackupID bool
ExpiryWarnDays *int
}
type GenerateRedeemCodesInput struct {
@@ -213,7 +213,7 @@ func TestAdminService_CNProviderModelsListCandidatesKeepClaudeDefaults(t *testin
for _, model := range claude.DefaultModels {
want = append(want, model.ID)
}
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
require.Equal(t, want, defaultModelsListCandidateIDs(platform), "platform=%s", platform)
}
}
@@ -73,6 +73,10 @@ func (s *AnnouncementService) Create(ctx context.Context, input *CreateAnnouncem
return nil, ErrAnnouncementNilInput
}
if !isJSONTimeInRange(input.StartsAt) || !isJSONTimeInRange(input.EndsAt) {
return nil, ErrAnnouncementInvalidSchedule
}
title := strings.TrimSpace(input.Title)
content := strings.TrimSpace(input.Content)
if title == "" || len(title) > 200 {
@@ -134,6 +138,11 @@ func (s *AnnouncementService) Update(ctx context.Context, id int64, input *Updat
return nil, ErrAnnouncementNilInput
}
if (input.StartsAt != nil && !isJSONTimeInRange(*input.StartsAt)) ||
(input.EndsAt != nil && !isJSONTimeInRange(*input.EndsAt)) {
return nil, ErrAnnouncementInvalidSchedule
}
a, err := s.announcementRepo.GetByID(ctx, id)
if err != nil {
return nil, err
@@ -90,7 +90,7 @@ type AntigravityTokenInfo struct {
Email string `json:"email,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ProjectIDMissing bool `json:"-"`
PlanType string `json:"-"`
PlanType string `json:"plan_type,omitempty"`
PrivacyMode string `json:"-"`
}
@@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"testing"
)
@@ -80,3 +81,26 @@ func TestResolveDefaultTierID(t *testing.T) {
})
}
}
// The OAuth handler serializes this DTO before the browser creates or reauthorizes an account.
func TestAntigravityTokenInfoPreservesPlanTypeJSON(t *testing.T) {
for _, plan := range []string{"pro", "ultra", "free", ""} {
t.Run(plan, func(t *testing.T) {
data, err := json.Marshal(AntigravityTokenInfo{PlanType: plan})
if err != nil {
t.Fatal(err)
}
var response map[string]any
if err := json.Unmarshal(data, &response); err != nil {
t.Fatal(err)
}
if plan == "" {
if _, exists := response["plan_type"]; exists {
t.Fatal("unknown plan must remain omitted")
}
} else if response["plan_type"] != plan {
t.Fatalf("OAuth response plan_type = %v, want %q", response["plan_type"], plan)
}
})
}
}
@@ -0,0 +1,100 @@
//go:build unit
package service
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type visibilityUserRepo struct {
UserRepository
user *User
err error
}
func (r *visibilityUserRepo) GetByID(context.Context, int64) (*User, error) { return r.user, r.err }
type visibilitySubRepo struct {
UserSubscriptionRepository
subscriptions []UserSubscription
err error
calls int
}
func (r *visibilitySubRepo) ListActiveByUserID(_ context.Context, userID int64) ([]UserSubscription, error) {
r.calls++
// Match the repository's active-status and expiry predicates.
active := make([]UserSubscription, 0)
for _, sub := range r.subscriptions {
if sub.UserID == userID && sub.IsActive() {
active = append(active, sub)
}
}
return active, r.err
}
type visibilityGroupRepo struct {
GroupRepository
groups []Group
}
func (r *visibilityGroupRepo) ListActive(context.Context) ([]Group, error) { return r.groups, nil }
func TestGetUserGroupVisibilityIncludesActiveSubscriptions(t *testing.T) {
for _, restricted := range []bool{false, true} {
t.Run(map[bool]string{false: "unrestricted", true: "restricted"}[restricted], func(t *testing.T) {
now := time.Now()
subs := &visibilitySubRepo{subscriptions: []UserSubscription{
{UserID: 1, GroupID: 42, Status: SubscriptionStatusActive, ExpiresAt: now.Add(time.Hour)},
{UserID: 1, GroupID: 43, Status: SubscriptionStatusActive, ExpiresAt: now.Add(-time.Hour)},
{UserID: 1, GroupID: 44, Status: "expired", ExpiresAt: now.Add(time.Hour)},
{UserID: 2, GroupID: 45, Status: SubscriptionStatusActive, ExpiresAt: now.Add(time.Hour)},
}}
svc := &APIKeyService{
userRepo: &visibilityUserRepo{user: &User{ID: 1, AllowedGroups: []int64{7}, RestrictPublicGroups: restricted}},
userSubRepo: subs,
groupRepo: &visibilityGroupRepo{groups: []Group{{ID: 42, IsExclusive: true, SubscriptionType: "subscription"}}},
}
available, err := svc.GetAvailableGroups(context.Background(), 1)
require.NoError(t, err)
require.Len(t, available, 1)
visible, restrict, err := svc.GetUserGroupVisibility(context.Background(), 1)
require.NoError(t, err)
require.Equal(t, restricted, restrict)
require.Equal(t, map[int64]struct{}{7: {}, 42: {}}, visible)
require.Contains(t, visible, available[0].ID, "a subscribed group that can be bound must be visible")
})
}
}
func TestGetUserGroupVisibilityEmptyAndErrors(t *testing.T) {
failure := errors.New("repository unavailable")
for _, tc := range []struct {
name string
userErr, subErr error
}{
{name: "empty"}, {name: "user failure", userErr: failure}, {name: "subscription failure", subErr: failure},
} {
t.Run(tc.name, func(t *testing.T) {
subs := &visibilitySubRepo{err: tc.subErr}
svc := &APIKeyService{userRepo: &visibilityUserRepo{user: &User{ID: 1}, err: tc.userErr}, userSubRepo: subs}
got, _, err := svc.GetUserGroupVisibility(context.Background(), 1)
if tc.userErr != nil || tc.subErr != nil {
require.ErrorIs(t, err, failure)
require.Nil(t, got, "repository failures must not become anonymous visibility")
} else {
require.NoError(t, err)
require.NotNil(t, got, "an empty logged-in user must not become anonymous")
require.Empty(t, got)
}
if tc.userErr != nil {
require.Zero(t, subs.calls)
}
})
}
}
+10 -3
View File
@@ -1071,11 +1071,11 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword
return keys, nil
}
// GetUserGroupVisibility 返回 user_allowed_groups 授权给该用户的分组 ID 集合,
// GetUserGroupVisibility 返回 user_allowed_groups 授权及有效订阅的分组 ID 集合,
// 以及该用户是否开启了公开分组限制。开启时公开分组的可见性也要落在该集合内。
//
// 与 GetAvailableGroups 的区别:这里是「橱窗」语义(模型广场用),不检查订阅有效性,
// 也不关心分组是否活跃——仅回答"哪些专属分组对该用户可见"。返回值恒非 nil。
// 与 GetAvailableGroups 的区别:这里保留普通授权分组的「橱窗」语义,不检查
// 分组是否活跃;有效订阅也授予对应分组的可见性。返回值恒非 nil。
func (s *APIKeyService) GetUserGroupVisibility(ctx context.Context, userID int64) (map[int64]struct{}, bool, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
@@ -1085,6 +1085,13 @@ func (s *APIKeyService) GetUserGroupVisibility(ctx context.Context, userID int64
for _, id := range user.AllowedGroups {
allowed[id] = struct{}{}
}
subscriptions, err := s.userSubRepo.ListActiveByUserID(ctx, userID)
if err != nil {
return nil, false, fmt.Errorf("list active subscriptions: %w", err)
}
for _, sub := range subscriptions {
allowed[sub.GroupID] = struct{}{}
}
return allowed, user.RestrictPublicGroups, nil
}
@@ -23,7 +23,7 @@ func newTokenCostTestEnv(t *testing.T, groupPlatform string, pricing []ChannelMo
return map[int64]string{100: groupPlatform}, nil
},
}
cs := NewChannelService(repo, nil, nil, nil)
cs := NewChannelService(repo, nil, nil, nil, nil)
bs := NewBillingService(&config.Config{}, catalog)
return bs, NewModelPricingResolver(cs, bs)
}
@@ -75,7 +75,7 @@ func newAvailableChannelService(channels []Channel, groupRepo GroupRepository) *
repo := &mockChannelRepository{
listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil },
}
return NewChannelService(repo, groupRepo, nil, nil)
return NewChannelService(repo, groupRepo, nil, nil, nil)
}
func TestListAvailable_EmptyActiveGroups_NoGroupsAttached(t *testing.T) {
@@ -134,7 +134,7 @@ func TestListAvailable_ListAllErrorPropagates(t *testing.T) {
listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel },
}
groupRepo := &stubGroupRepoForAvailable{}
svc := NewChannelService(repo, groupRepo, nil, nil)
svc := NewChannelService(repo, groupRepo, nil, nil, nil)
out, err := svc.ListAvailable(context.Background())
require.Nil(t, out)
require.ErrorIs(t, err, sentinel)
@@ -175,6 +175,7 @@ var providerAdapters = map[string]providerAdapter{
MonitorProviderKimi: providerKimiChatAdapter,
MonitorProviderZhipu: providerZhipuChatAdapter,
MonitorProviderDeepseek: providerDeepseekChatAdapter,
MonitorProviderMiniMax: providerMiniMaxChatAdapter,
MonitorProviderAnthropic: {
buildPath: func(string) string { return providerAnthropicPath },
buildBody: func(model, prompt string) ([]byte, error) {
@@ -226,6 +227,9 @@ var providerZhipuChatAdapter = newOpenAICompatibleChatAdapter(providerZhipuPath)
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
var providerDeepseekChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath)
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
var providerMiniMaxChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath)
func newOpenAICompatibleChatAdapter(path string) providerAdapter {
return providerAdapter{
buildPath: func(string) string { return path },
@@ -458,6 +462,7 @@ var bodyMergeKeyDenyList = map[string]map[string]bool{
MonitorProviderKimi: {"model": true, "messages": true, "stream": true},
MonitorProviderZhipu: {"model": true, "messages": true, "stream": true},
MonitorProviderDeepseek: {"model": true, "messages": true, "stream": true},
MonitorProviderMiniMax: {"model": true, "messages": true, "stream": true},
}
func checkAPIMode(opts *CheckOptions) string {
@@ -479,7 +484,7 @@ func bodyMergeDenyKey(provider, apiMode string) string {
func isOpenAICompatibleChatProvider(provider string) bool {
switch provider {
case MonitorProviderOpenAI, MonitorProviderGrok,
MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek:
MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek, MonitorProviderMiniMax:
return true
default:
return false
@@ -69,6 +69,7 @@ const (
MonitorProviderKimi = "kimi"
MonitorProviderZhipu = "zhipu"
MonitorProviderDeepseek = "deepseek"
MonitorProviderMiniMax = "minimax"
// MonitorCheckMode 检测模式(channel_monitors.check_mode)。
// probe - LLM 探活(默认,原有行为)
@@ -151,7 +152,7 @@ var (
"CHANNEL_MONITOR_NOT_FOUND", "channel monitor not found",
)
ErrChannelMonitorInvalidProvider = infraerrors.BadRequest(
"CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok/antigravity/kimi/zhipu/deepseek",
"CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok/antigravity/kimi/zhipu/deepseek/minimax",
)
ErrChannelMonitorInvalidCheckMode = infraerrors.BadRequest(
"CHANNEL_MONITOR_INVALID_CHECK_MODE", "check_mode must be one of probe/quota/quota_probe; antigravity only supports quota",
@@ -163,7 +164,7 @@ var (
"CHANNEL_MONITOR_PROVIDER_INCOMPATIBLE", "monitor provider must match the linked account platform",
)
ErrChannelMonitorAccountNotSupportable = infraerrors.BadRequest(
"CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE", "linked account cannot serve as a quota data source (cn coding plan must be kimi/zhipu, cn payg must be kimi/deepseek, openai requires an oauth account, anthropic requires oauth or setup-token)",
"CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE", "linked account cannot serve as a quota data source (cn coding plan must be kimi/zhipu/minimax, cn payg must be kimi/deepseek, openai requires an oauth account, anthropic requires oauth or setup-token)",
)
ErrChannelMonitorInvalidAPIMode = infraerrors.BadRequest(
"CHANNEL_MONITOR_INVALID_API_MODE", "api_mode must be chat_completions or responses; responses is only supported for openai",
@@ -216,7 +216,7 @@ func (f *ChannelMonitorQuotaFetcher) fetchUncached(ctx context.Context, accountI
// (GetUsageForAccount / QueryUsageForAccount / QueryBalanceForAccount),
// 下游服务不再各自 GetByID(每次含 proxies/groups 联查)。
switch account.Platform {
case domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek:
case domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek, domain.PlatformMiniMax:
if account.IsCodingPlan() {
return f.fetchCNQuota(ctx, account, now)
}
@@ -173,6 +173,30 @@ func TestQuotaFetcher_CodingPlanAccountUsesCNQuota(t *testing.T) {
require.Equal(t, 0, cnBalance.calls)
}
func TestQuotaFetcher_MiniMaxCodingPlanUsesCNQuota(t *testing.T) {
fetcher, usage, cnQuota, cnBalance, accounts := newQuotaFetcherTestSetup(t)
accounts.accounts[19] = &Account{
ID: 19,
Platform: domain.PlatformMiniMax,
Credentials: map[string]any{"account_mode": AccountModeCoding},
}
cnQuota.result = &CNProviderQuotaProbeResult{
Success: true,
CredentialValid: true,
Tiers: []CNQuotaTier{
{Window: "5h", UsedPercent: 12},
},
}
snapshot := fetcher.Fetch(context.Background(), 19)
require.True(t, snapshot.Success)
require.Equal(t, "cn_quota", snapshot.Source)
require.Equal(t, 1, cnQuota.calls)
require.Equal(t, 0, cnBalance.calls)
require.Equal(t, 0, usage.getCalls())
}
func TestQuotaFetcher_PayGAccountUsesCNBalance(t *testing.T) {
fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t)
accounts.accounts[11] = &Account{
@@ -458,11 +458,26 @@ func TestMonitorAccountQuotaCapability_Matrix(t *testing.T) {
name: "zhipu coding default endpoint ok",
account: &Account{ID: 4, Platform: domain.PlatformZhipu, Credentials: map[string]any{"account_mode": AccountModeCoding}},
},
{
name: "minimax coding default endpoint ok",
account: &Account{ID: 14, Platform: domain.PlatformMiniMax, Credentials: map[string]any{"account_mode": AccountModeCoding}},
},
{
name: "custom-domain minimax coding unsupported",
account: &Account{ID: 16, Platform: domain.PlatformMiniMax, Type: AccountTypeAPIKey,
Credentials: map[string]any{"account_mode": AccountModeCoding, "base_url": "https://relay.example.com/v1"}},
wantErr: ErrChannelMonitorAccountNotSupportable,
},
{
name: "zhipu payg has no balance endpoint",
account: &Account{ID: 5, Platform: domain.PlatformZhipu},
wantErr: ErrChannelMonitorAccountNotSupportable,
},
{
name: "minimax payg has no balance endpoint",
account: &Account{ID: 15, Platform: domain.PlatformMiniMax},
wantErr: ErrChannelMonitorAccountNotSupportable,
},
{
name: "kimi payg ok",
account: &Account{ID: 6, Platform: domain.PlatformKimi},

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